add Recovery.Attributes module

This commit is contained in:
mmacia committed 2026-09-16 22:53:50 +00:00
1 parent ca35ee7d5f
commit 84ac6cb875
2 files changed
+146

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
defmodule FeedReader.Recovery.Attributes do
@moduledoc """
Repairs malformed quotes in XML attributes.
A small number of feeds place literal quotes inside a quoted attribute value.
This module repairs those quotes only within one complete markup element, so
valid quotes in neighboring elements cannot be rewritten accidentally.
Incomplete elements are retained as state for the next input chunk.
"""
@doc "Repairs literal quotes in streamed XML attributes."
def normalize(input) do
Stream.transform(
input,
fn -> <<>> end,
fn chunk, pending ->
{output, pending} = scan(pending <> chunk, [])
{if(output == [], do: [], else: [IO.iodata_to_binary(Enum.reverse(output))]), pending}
end,
fn
<<>> -> []
pending -> [pending]
end
)
end
defp scan(<<>>, output), do: {output, <<>>}
defp scan(data, output) do
case :binary.match(data, "<") do
:nomatch ->
{[data | output], <<>>}
{position, 1} when position > 0 ->
{prefix, rest} = :erlang.split_binary(data, position)
scan(rest, [prefix | output])
{0, 1} ->
case markup_end(data) do
:incomplete -> {output, data}
{markup, rest} -> scan(rest, [repair(markup) | output])
end
end
end
defp markup_end(<<"<![CDATA[", _rest::binary>> = data), do: special_end(data, "]]>")
defp markup_end(<<"<!--", _rest::binary>> = data), do: special_end(data, "-->")
defp markup_end(data) do
case :binary.match(data, ">") do
:nomatch -> :incomplete
{position, 1} -> :erlang.split_binary(data, position + 1)
end
end
defp special_end(data, closing) do
case :binary.match(data, closing) do
:nomatch -> :incomplete
{position, length} -> :erlang.split_binary(data, position + length)
end
end
defp repair(<<"<![CDATA[", _rest::binary>> = markup), do: markup
defp repair(<<"<!--", _rest::binary>> = markup), do: markup
defp repair(<<"<", rest::binary>>), do: "<" <> repair_tag(rest, nil, false, [])
defp repair_tag(<<>>, _quote, _expect, output), do: IO.iodata_to_binary(Enum.reverse(output))
defp repair_tag(<<char, rest::binary>>, nil, expect, output) when char in [32, 9, 10, 13],
do: repair_tag(rest, nil, expect, [<<char>> | output])
defp repair_tag(<<char, rest::binary>>, nil, true, output) when char in [34, 39],
do: repair_tag(rest, {char, false}, false, [<<char>> | output])
defp repair_tag(<<char, rest::binary>>, nil, _expect, output),
do: repair_tag(rest, nil, char == ?=, [<<char>> | output])
defp repair_tag(<<quote, rest::binary>>, {quote, has_content}, _expect, output) do
cond do
delimiter?(rest) ->
repair_tag(rest, nil, false, [<<quote>> | output])
empty_quote?(rest, quote, has_content) ->
<<_quote, after_quote::binary>> = rest
repair_tag(after_quote, nil, false, [<<quote>> | output])
true ->
repair_tag(rest, {quote, true}, false, ["&quot;" | output])
end
end
defp repair_tag(<<char, rest::binary>>, {quote, _has_content}, _expect, output),
do: repair_tag(rest, {quote, true}, false, [<<char>> | output])
defp delimiter?(<<char, _rest::binary>>) when char in [32, 9, 10, 13, 47, 62, 63], do: true
defp delimiter?(_rest), do: false
defp empty_quote?(<<quote, rest::binary>>, quote, false), do: delimiter?(rest)
defp empty_quote?(_rest, _quote, _has_content), do: false
end
@@ -0,0 +1,48 @@
defmodule FeedReader.Recovery.AttributesTest do
use ExUnit.Case, async: true
alias FeedReader.Recovery.Attributes
test "preserves valid attributes" do
xml = ~s(<link href="https://example.com" rel="alternate" />)
assert normalize([xml]) == xml
end
test "repairs literal double quotes inside an attribute value" do
xml = ~s(<link title="How to "Sustain Heroku"" />)
assert normalize([xml]) == ~s(<link title="How to &quot;Sustain Heroku&quot;" />)
end
test "repairs malformed attributes split across chunks" do
chunks = [~s(<link title="How to "Sustain), ~s( Heroku"" />)]
assert normalize(chunks) == ~s(<link title="How to &quot;Sustain Heroku&quot;" />)
end
test "preserves empty attributes and single-quoted attributes" do
xml = ~s(<source url="" label='A "quoted" source' />)
assert normalize([xml]) == xml
end
test "does not repair quotes in CDATA or comments" do
xml = ~s(<![CDATA[<p title="A "quoted" value">Text</p>]]><!-- A "quoted" comment -->)
assert normalize([xml]) == xml
end
test "does not repair across adjacent elements" do
xml = ~s(<source url="">Source</source><category domain="region">News</category>)
assert normalize([xml]) == xml
end
test "preserves text outside markup" do
assert normalize(["before ", ~s(<item title="Story" />), " after"]) ==
~s(before <item title="Story" /> after)
end
defp normalize(chunks), do: chunks |> Attributes.normalize() |> Enum.join()
end