From 937b57b66702777dcf8c0ed05d1c2ad5def054f3 Mon Sep 17 00:00:00 2001 From: Moises Macia Date: Mon, 24 Aug 2026 20:26:21 +0000 Subject: [PATCH] normalize attributes quotes --- lib/feed_reader/parser.ex | 164 ++++++++++++++++++++++++++++ test/feed_reader/collector_test.exs | 19 ++++ 2 files changed, 183 insertions(+) diff --git a/lib/feed_reader/parser.ex b/lib/feed_reader/parser.ex index 6c873a5..d1e4cf3 100644 --- a/lib/feed_reader/parser.ex +++ b/lib/feed_reader/parser.ex @@ -88,6 +88,8 @@ defmodule FeedReader.Parser do |> normalize_xml_prefix() |> transcode_input(encoding) |> normalize_encoding_declaration(encoding) + |> normalize_entities() + |> normalize_attribute_quotes() end defp normalize_xml_prefix(input) do @@ -182,6 +184,168 @@ defmodule FeedReader.Parser do defp flush_declaration({:prolog, prefix}), do: [prefix] defp flush_declaration(:ready), do: [] + defp normalize_entities(input) do + Stream.transform( + input, + fn -> {:normal, <<>>} end, + &normalize_entity_chunk/2, + &flush_entities/1 + ) + end + + defp normalize_entity_chunk(chunk, {mode, pending}) do + {output, mode, pending} = scan_entities(pending <> chunk, mode, []) + emit_entity_chunk(output, {mode, pending}) + end + + defp scan_entities(data, :normal, output) do + case next_markup(data) do + :none -> + case partial_markup_prefix(data) do + :none -> + {append_output(output, data), :normal, <<>>} + + length -> + split = byte_size(data) - length + + {append_output(output, binary_part(data, 0, split)), :normal, + binary_part(data, split, length)} + end + + {position, :cdata} -> + {prefix, rest} = :erlang.split_binary(data, position) + + scan_entities(binary_part(rest, 9, byte_size(rest) - 9), :cdata, [ + " + {prefix, rest} = :erlang.split_binary(data, position) + + scan_entities(binary_part(rest, 4, byte_size(rest) - 4), :comment, [ + "") do + :nomatch -> + keep = min(byte_size(data), 2) + split = byte_size(data) - keep + + {append_output(output, binary_part(data, 0, split)), :comment, + binary_part(data, split, keep)} + + {position, 3} -> + {prefix, rest} = :erlang.split_binary(data, position + 3) + scan_entities(rest, :normal, [prefix | output]) + end + end + + defp next_markup(data) do + [ + {:cdata, :binary.match(data, " Enum.reduce(:none, &earliest_markup/2) + end + + defp partial_markup_prefix(<<>>), do: :none + + defp partial_markup_prefix(data) do + max_length = min(byte_size(data), 8) + + Enum.find_value(max_length..1//-1, :none, fn length -> + suffix = binary_part(data, byte_size(data) - length, length) + + if suffix in [binary_part(" + entity = if valid_entity?(entity), do: entity, else: escape_entity(entity) + scan_entities(rest, :normal, [entity | output]) + + {:invalid, entity, rest} -> + scan_entities(rest, :normal, [escape_entity(entity) | output]) + + :incomplete -> + {output, :normal, "&" <> data} + end + end + + defp consume_entity(<<>>, _entity), do: :incomplete + defp consume_entity(<<";", rest::binary>>, entity), do: {:complete, entity <> ";", rest} + + defp consume_entity(<>, entity) + when char in [9, 10, 13, 32, 34, 38, 39, 60], + do: {:invalid, entity, <>} + + defp consume_entity(<>, entity), + do: consume_entity(rest, entity <> <>) + + defp valid_entity?(entity), + do: Regex.match?(~r/^&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);$/i, entity) + + defp escape_entity(entity), do: "&" <> binary_part(entity, 1, byte_size(entity) - 1) + + defp append_output(output, <<>>), do: output + defp append_output(output, chunk), do: [chunk | output] + + defp emit_entity_chunk(output, state) do + output = output |> Enum.reverse() |> IO.iodata_to_binary() + if output == <<>>, do: {[], state}, else: {[output], state} + end + + defp flush_entities({_mode, <<>>}), do: [] + defp flush_entities({_mode, pending}), do: [pending] + + defp normalize_attribute_quotes(input) do + Stream.map(input, fn chunk -> + Regex.replace( + ~r/(=\")([^\"]*)(\")([^\"]*)(\")(\")(?=\s*(?:\/>|>))/, + chunk, + "\\1\\2"\\4"\\6" + ) + end) + end + defp event_stream(input, format, opts, chunk_size) do Stream.resource( fn -> start_parser(input, format, opts, chunk_size) end, diff --git a/test/feed_reader/collector_test.exs b/test/feed_reader/collector_test.exs index a8e80f8..e4ed123 100644 --- a/test/feed_reader/collector_test.exs +++ b/test/feed_reader/collector_test.exs @@ -72,6 +72,25 @@ defmodule FeedReader.CollectorTest do FeedReader.parse_binary(xml, max_items: 1) end + test "escapes invalid entities while preserving CDATA" do + xml = + ~s(Examplehttps://example.com/?a=1&b=2A & B

]]>
) + + assert {:ok, %ParseResult{items: [%Item{canonical_url: url, content_full: content}]}} = + FeedReader.parse_binary(xml, max_items: 1, chunk_size: 1) + + assert url == "https://example.com/?a=1&b=2" + assert content == "

A & B

" + end + + test "repairs literal quotes inside XML attributes" do + xml = + ~s(ExamplePost) + + assert {:ok, %ParseResult{items: [%Item{canonical_url: "https://example.com/post"}]}} = + FeedReader.parse_binary(xml, max_items: 1) + end + test "rejects invalid chunks and binary arguments" do assert {:error, :invalid_chunk} = FeedReader.parse_stream([@xml, 123], max_items: 2)