From ca35ee7d5f1b8f5b98360c626e4e8a06dcf6060f Mon Sep 17 00:00:00 2001 From: Moises Macia Date: Mon, 14 Sep 2026 21:45:04 +0000 Subject: [PATCH] add Recovery.Entities module --- lib/feed_reader/recovery/entities.ex | 131 ++++++++++++++++++++ test/feed_reader/recovery/entities_test.exs | 40 ++++++ 2 files changed, 171 insertions(+) create mode 100644 lib/feed_reader/recovery/entities.ex create mode 100644 test/feed_reader/recovery/entities_test.exs diff --git a/lib/feed_reader/recovery/entities.ex b/lib/feed_reader/recovery/entities.ex new file mode 100644 index 0000000..a80ec19 --- /dev/null +++ b/lib/feed_reader/recovery/entities.ex @@ -0,0 +1,131 @@ +defmodule FeedReader.Recovery.Entities do + @moduledoc """ + Recovers entity and markup irregularities in streamed XML. + + Some feeds contain raw ampersands or malformed entity references that Saxy + rejects. This module preserves valid named and numeric entities, escapes + invalid references, and leaves CDATA and comments untouched. Its scanner + carries incomplete entities and markup delimiters between chunks so the + result does not depend on the input chunk size. + """ + + @doc "Escapes invalid entity references while preserving CDATA and comments." + def normalize(input) do + Stream.transform(input, fn -> {:normal, <<>>} end, &normalize_chunk/2, &flush/1) + end + + defp normalize_chunk(chunk, {mode, pending}) do + {output, mode, pending} = scan(pending <> chunk, mode, []) + emit(output, {mode, pending}) + end + + defp scan(data, :normal, output) do + case next_markup(data) do + :none -> + case partial_prefix(data) do + :none -> + {append(output, data), :normal, <<>>} + + length -> + split = byte_size(data) - length + + {append(output, binary_part(data, 0, split)), :normal, + binary_part(data, split, length)} + end + + {position, :cdata} -> + {prefix, rest} = :erlang.split_binary(data, position) + scan(binary_part(rest, 9, byte_size(rest) - 9), :cdata, [" + {prefix, rest} = :erlang.split_binary(data, position) + scan(binary_part(rest, 4, byte_size(rest) - 4), :comment, ["", :comment) + + defp scan_delimited(data, output, closing, mode) do + case :binary.match(data, closing) do + :nomatch -> + keep = min(byte_size(data), 2) + split = byte_size(data) - keep + {append(output, binary_part(data, 0, split)), mode, binary_part(data, split, keep)} + + {position, length} -> + {prefix, rest} = :erlang.split_binary(data, position + length) + scan(rest, :normal, [prefix | output]) + end + end + + defp next_markup(data) do + [ + {:cdata, :binary.match(data, " Enum.reduce(:none, &earliest/2) + end + + defp partial_prefix(<<>>), do: :none + + defp partial_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(" + scan(rest, :normal, [if(valid?(entity), do: entity, else: escape(entity)) | output]) + + {:invalid, entity, rest} -> + scan(rest, :normal, [escape(entity) | output]) + + :incomplete -> + {output, :normal, "&" <> data} + end + end + + defp consume(<<>>, _entity), do: :incomplete + defp consume(<<";", rest::binary>>, entity), do: {:complete, entity <> ";", rest} + + defp consume(<>, entity) when char in [9, 10, 13, 32, 34, 38, 39, 60], + do: {:invalid, entity, <>} + + defp consume(<>, entity), do: consume(rest, entity <> <>) + defp valid?(entity), do: Regex.match?(~r/^&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);$/i, entity) + defp escape(entity), do: "&" <> binary_part(entity, 1, byte_size(entity) - 1) + defp append(output, <<>>), do: output + defp append(output, chunk), do: [chunk | output] + + defp emit(output, state), + do: + if(output == [], + do: {[], state}, + else: {[output |> Enum.reverse() |> IO.iodata_to_binary()], state} + ) + + defp flush({_mode, <<>>}), do: [] + defp flush({_mode, pending}), do: [pending] +end diff --git a/test/feed_reader/recovery/entities_test.exs b/test/feed_reader/recovery/entities_test.exs new file mode 100644 index 0000000..8782ad3 --- /dev/null +++ b/test/feed_reader/recovery/entities_test.exs @@ -0,0 +1,40 @@ +defmodule FeedReader.Recovery.EntitiesTest do + use ExUnit.Case, async: true + + alias FeedReader.Recovery.Entities + + test "preserves valid named and numeric entities" do + xml = "& < > " ' © 😀" + + assert normalize([xml]) == xml + end + + test "escapes raw and invalid entity references" do + assert normalize(["A & B &unknown; C ¬-an-entity D"]) == + "A & B &unknown; C &not-an-entity D" + end + + test "preserves ampersands inside CDATA" do + chunks = ["A & B

]]>"] + + assert normalize(chunks) == "A & B

]]>" + end + + test "preserves ampersands inside comments" do + chunks = [""] + + assert normalize(chunks) == "" + end + + test "handles an entity split across chunks" do + assert normalize(["A &am", "p; B &#", "169; C"]) == "A & B © C" + end + + test "handles CDATA and comment terminators split across chunks" do + chunks = ["E & F" + end + + defp normalize(chunks), do: chunks |> Entities.normalize() |> Enum.join() +end