From 316910e1ea36ed963010a11fc06f25374ad03959 Mon Sep 17 00:00:00 2001 From: Sean Whalen <44679+seanthegeek@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:43:46 -0400 Subject: [PATCH] fix: never close a caller-supplied file object in parse_report_file/extract_report (#909) parse_report_file() and extract_report() both closed a file-like object the caller passed in once they finished reading it (on the success path only). A function must not close a handle it did not open: the caller may still want to seek(0) and retry, log tell(), or reuse the handle. - parse_report_file(): the caller-supplied-object branch no longer calls .close(). A path is still opened and closed via `with`; bytes are still wrapped in a function-owned BytesIO that the function closes. - extract_report(): the seekable-stream branch previously aliased the caller's stream into `file_object` and then unconditionally closed it in `finally`, on the success path and on the exception path alike. An `owns_file_object` flag now tracks whether `file_object` is a buffer this function created (str/bytes input, or a copy made from a non-seekable caller stream) versus an alias of the caller's own seekable stream; only the former is closed. - parse_aggregate_report_file() forwards its input straight to extract_report() and does no closing of its own, so it inherits the fix; its docstring now says so and points to extract_report()'s for the exact post-call position. Both extract_report()'s and parse_report_file()'s docstrings now state the contract explicitly. extract_report()'s docstring initially claimed a successful call leaves a seekable caller stream "positioned at 0" and described the seek(0) as seeking "back" to the caller's prior position; neither is true. The unconditional stream.seek(0) after reading the 6-byte header runs regardless of where the caller's stream was positioned, and the subsequent content read (member/gzip/text) advances the stream again, so a successful call typically leaves it at EOF, not 0 (measured: XML 866, gzip 428, zip 901/974). The docstring now matches parse_report_file()'s wording: left open, positioned wherever the function's own reads left it. Added testParseReportFileLeavesCallerHandleOpenOnSuccess, testExtractReportLeavesSeekableCallerStreamOpen (success path), and testExtractReportLeavesSeekableCallerStreamOpenOnError (exception path, completing the "never closed on success or failure" claim, which had gone untested on its exception half) -- asserting real BytesIO `.closed` state and, for the success cases, that the handle is still readable after seek(0). All three fail against the pre-fix code; verified with `parsedmarc.__file__` that the import resolved to this worktree's package, not a stale venv install, before trusting the failure/pass results. Updated testParseReportFileLeavesCallerHandleOpenOnReadError's docstring, which described the old "closed on success" behavior as intentional. Also added testParseAggregateReportFileLeavesCallerStreamOpen: the contract paragraph added to parse_aggregate_report_file()'s docstring was previously untested through that entry point -- its existing stream-based tests only exercise extract_report() and parse_report_file() directly, while parse_aggregate_report_file() itself was only ever called with bytes. The new test passes a caller-owned BytesIO through parse_aggregate_report_file() and asserts it is still open after both a successful parse and an InvalidAggregateReport raised on garbage content. It fails against the pre-fix code (AssertionError: True is not false on the success-path assertion); verified via `parsedmarc.__file__` that the import resolved to this worktree's package before trusting the result. CHANGELOG: added an Unreleased/Changes entry (behavior change for library callers, naming all three affected functions) and corrected the existing Unreleased/Bug fixes entry for parse_report_file's path-close fix, whose closing sentence about caller-supplied objects this PR makes false and whose cross-reference to "the Changes section below" pointed the wrong direction (Changes is above Bug fixes). Co-authored-by: Claude Fable 5.1 --- CHANGELOG.md | 3 +- parsedmarc/__init__.py | 45 ++++++++++++++--- tests/test_init.py | 106 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 143 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b364d6b..fd5cbe61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ ### Changes - **The prebuilt Docker image (`ghcr.io/domainaware/parsedmarc`) is roughly 40% smaller to pull** ([#893](https://github.com/domainaware/parsedmarc/pull/893)). The runtime stage copied the built wheel out of the build stage and deleted it again at the end of the next `RUN`, but a `RUN` can only write a whiteout over a layer an earlier instruction already committed: the wheel shipped in every published image and every `docker pull` downloaded it (10,713,473 bytes of the 11.0.0 image, on both architectures). The wheel is now bind-mounted from the build stage instead, and a bind mount is never committed to a layer. `pip install` also runs with `--no-cache-dir`, which drops a further ~99 MB of pip's download cache that the image had been carrying in the same layer as `site-packages`. Measured on linux/amd64: 272,138,724 compressed bytes across six layers before, 163,757,439 across five after. +- **`parse_report_file()`, `extract_report()`, and `parse_aggregate_report_file()` no longer close a file object supplied by the caller, on success or failure.** A path input is still opened and closed by the function, and a `bytes`/`bytearray`/`memoryview` input is still wrapped in an internally-created `BytesIO` that the function closes; but a file-like object the caller passed in may be seeked, `tell()`'d, or reused afterward, and closing it out from under the caller (as these functions previously did on the success path) makes that impossible. `parse_aggregate_report_file()` forwards its input straight to `extract_report()`, so it inherits the fix without changes of its own. `extract_report()`'s handling of a caller-supplied *non-seekable* stream is unaffected: its contents are still copied into an internally-owned buffer that the function closes, since that buffer was never the caller's own handle. - **Bare `exit(...)` calls are now `sys.exit(...)` throughout the CLI and the maintainer map-tooling scripts.** `exit` is installed into `builtins` by the `site` module, not just for interactive sessions, so it isn't guaranteed to exist under `python -S` or an embedded interpreter that skips `site` — the failure paths that called it would raise `NameError` instead of actually exiting. `sys.exit` is always available and was already used elsewhere in `cli.py` and `find_unknown_base_reverse_dns.py`. Also removed the dead, immediately-recomputed `index_date` stores in the Elasticsearch and OpenSearch aggregate-report savers; behavior is unchanged. ### Bug fixes - `find_unknown_base_reverse_dns.py`'s missing-file checks for `base_reverse_dns_map.csv` and the `known_unknown`/PSL-override lists printed a clean error message but fell through into an unhandled `FileNotFoundError` traceback instead of exiting. -- **`parse_report_file()` now closes the file handle it opens itself for a path input if reading it raises.** When `input_` is a path, the function opened the file, read it, and closed it with no exception handling in between; an exception raised by `read()` (e.g. an `OSError` from the underlying storage) skipped the close, so the descriptor was left to be released only when Python's garbage collector eventually finalized the object — CPython's `io.IOBase.__del__` closes an unclosed file on finalization () — rather than being closed deterministically. This is the pattern CodeQL's `py/file-not-closed` query flags, found in a local code-quality scan. The path branch now opens the file with a `with` block, so the handle is closed on both the success and exception paths. A file-like object or bytes buffer supplied by the caller is unaffected: as before, it is closed only after a successful read, and left open if `read()` raises. +- **`parse_report_file()` now closes the file handle it opens itself for a path input if reading it raises.** When `input_` is a path, the function opened the file, read it, and closed it with no exception handling in between; an exception raised by `read()` (e.g. an `OSError` from the underlying storage) skipped the close, so the descriptor was left to be released only when Python's garbage collector eventually finalized the object — CPython's `io.IOBase.__del__` closes an unclosed file on finalization () — rather than being closed deterministically. This is the pattern CodeQL's `py/file-not-closed` query flags, found in a local code-quality scan. The path branch now opens the file with a `with` block, so the handle is closed on both the success and exception paths. (A caller-supplied file-like object's closing behavior changed again later in this same Unreleased version — see the *Changes* section above.) - **A SIGHUP configuration reload no longer breaks every subsequent save to Elasticsearch or OpenSearch.** With `[elasticsearch]` or `[opensearch]` configured in watch mode, reloading the configuration re-registered the search client under the client library's `default` connection alias and then closed the previous run's clients. The close step re-resolved that alias instead of remembering the client it was created for, so it closed the *newly built* client and deleted the `default` alias outright — after which every report save failed with `KeyError: "There is no connection with alias 'default'."` until parsedmarc was restarted, and the original client was left open. Each backend's handle now closes the exact client it was created for and gives up the alias only while the alias still points at that client. Both the Elasticsearch and OpenSearch backends were affected. - **A configuration reload that fails part-way no longer leaves reports being written to the new Elasticsearch or OpenSearch hosts under the old configuration.** The search client is registered under the client library's process-wide `default` connection alias as soon as it is constructed — before the index migration runs, and before the outputs configured after it are created. When a later step then failed on a SIGHUP reload — for example an `[opensearch]` section that cannot build its client (an unsupported `auth_type`, `awssigv4` without an `aws_region`, AWS credentials that will not load) — parsedmarc logged `Config reload failed, continuing with previous config` and kept the old configuration. But the alias had already been handed to the new client, so every subsequent save resolved it to the *new* hosts while the index prefixes, suffixes, and `index_prefix_domain_map` still came from the old configuration. Reports were silently written to a destination that was never successfully configured, with the log saying nothing had changed. The half-built client was never closed either, nor were the other clients (S3, Kafka, PostgreSQL, and so on) built earlier in the same failed reload — the same leak occurred on every attempt of the startup retry loop, which calls the same function. Building the output clients is now all-or-nothing: if any step fails, everything built so far is closed and the module-level state the search backends keep is put back — each configured backend's `default` alias restored to exactly the client it named beforehand, and the Elasticsearch `serverless` flag (which decides whether `number_of_shards`/`number_of_replicas` are sent when an index is created) to its old value — so a failed reload no longer changes where reports are written, or how indexes are created. diff --git a/parsedmarc/__init__.py b/parsedmarc/__init__.py index b4af9f85..ea290320 100644 --- a/parsedmarc/__init__.py +++ b/parsedmarc/__init__.py @@ -1223,6 +1223,16 @@ def extract_report(content: bytes | str | BinaryIO) -> str: Extracts report text from zip- or gzip-compressed content, and returns plain XML or JSON content decoded as-is. + A caller-supplied file-like object is read from but never closed by + this function, on either the success or the exception path; it is + left open and positioned wherever the function's own reads left it, + so the caller is free to seek(0) and reuse it. A seekable stream is + seeked to position 0 unconditionally (not back to wherever the caller + had left it) after its 6-byte header is sniffed, and then read from + there, so a successful call typically leaves it at EOF. A non-seekable + stream is drained into a buffer this function creates and closes + itself; the caller's stream is left open but exhausted. + Args: content: The report as a base64-encoded string, file-like object, or bytes. A string that is not valid base64 is returned @@ -1232,6 +1242,12 @@ def extract_report(content: bytes | str | BinaryIO) -> str: str: The extracted text """ file_object: BinaryIO | None = None + # True while file_object is a BytesIO this function created itself + # (from a str/bytes input, or as a buffer copied from a non-seekable + # caller stream); it is closed in the ``finally`` below. It is set to + # False when file_object instead aliases a caller-supplied seekable + # stream, which this function must never close. + owns_file_object = True header: bytes try: if isinstance(content, str): @@ -1265,7 +1281,10 @@ def extract_report(content: bytes | str | BinaryIO) -> str: raise ParserError("File objects must be opened in binary (rb) mode") header = bytes(header_raw) stream.seek(0) + # file_object aliases the caller's own stream here, so it + # must not be closed by this function. file_object = stream + owns_file_object = False else: header_raw = stream.read(6) if isinstance(header_raw, str): @@ -1299,7 +1318,7 @@ def extract_report(content: bytes | str | BinaryIO) -> str: f"Invalid archive file: {error.__str__()}{_exc_origin(error)}" ) from error finally: - if file_object: + if file_object and owns_file_object: try: file_object.close() except Exception: @@ -1340,6 +1359,11 @@ def parse_aggregate_report_file( """Parses a file at the given path, a file-like object, or bytes as an aggregate DMARC report + ``_input`` is forwarded to ``extract_report()`` unchanged, so a + caller-supplied file-like object is read from but never closed, on + either the success or the exception path; see ``extract_report()`` + for exactly how such an object is left positioned afterward. + Args: _input (str | bytes | IO): A path to a file, a file-like object, or bytes offline (bool): Do not query online for geolocation or DNS @@ -2283,6 +2307,11 @@ def parse_report_file( """Parses a DMARC aggregate report, DMARC failure report, or SMTP TLS report from a file at the given path, a file-like object, or bytes + A path is opened and closed by this function. A caller-supplied file + object is read from but never closed, on either the success or the + exception path; it is left open and positioned wherever its own + ``read()`` left it, so the caller is free to ``seek(0)`` and reuse it. + Args: input_ (str | os.PathLike | bytes | BinaryIO): A path to a file, a file-like object, or bytes @@ -2332,14 +2361,18 @@ def parse_report_file( content = file_object.read() else: if isinstance(input_, (bytes, bytearray, memoryview)): + # The BytesIO wrapper is created here, so this function owns + # it and closes it once the bytes have been read out. file_object = BytesIO(bytes(input_)) + content = file_object.read() + file_object.close() else: - # A caller-supplied file-like object is only closed on success, - # matching long-standing behavior; it is left open if read() - # raises. + # A caller-supplied file-like object is never closed by this + # function, on success or failure: the caller may want to + # seek(0) and retry, inspect tell(), or otherwise reuse the + # handle afterward. file_object = input_ - content = file_object.read() - file_object.close() + content = file_object.read() if content.startswith(MAGIC_ZIP) or content.startswith(MAGIC_GZIP): content = extract_report(content) diff --git a/tests/test_init.py b/tests/test_init.py index 7a9077ed..f45284df 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -2083,6 +2083,39 @@ class Test(unittest.TestCase): self.assertEqual(report["policy_published"]["domain"], "example.com") print("Passed!") + def testParseAggregateReportFileLeavesCallerStreamOpen(self): + """A caller-supplied BytesIO passed to parse_aggregate_report_file + is left open (never closed), both on a successful parse and when + unrecognized content raises InvalidAggregateReport. + + parse_aggregate_report_file() forwards ``_input`` straight to + extract_report() (see both functions' docstrings), so it inherits + extract_report()'s contract of never closing a caller-supplied + file-like object, on either the success or the exception path. + This is a regression test for that contract reached through the + public parse_aggregate_report_file() entry point -- extract_report() + and parse_report_file() are covered by their own direct tests, but + neither exercises parse_aggregate_report_file() with a stream, only + with bytes (see testParseAggregateReportFile above). A real BytesIO + is used, not a mock, so the assertion is on real observable state + (``closed``), and because MagicMock auto-implements ``__fspath__``. + """ + sample_path = "samples/aggregate/rfc9990-sample.xml" + with open(sample_path, "rb") as f: + data = f.read() + + success_stream = BytesIO(data) + report = parsedmarc.parse_aggregate_report_file( + success_stream, offline=True, always_use_local_files=True + ) + self.assertEqual(report["report_metadata"]["org_name"], "Sample Reporter") + self.assertFalse(success_stream.closed) + + garbage_stream = BytesIO(b"this is not a valid report") + with self.assertRaises(parsedmarc.InvalidAggregateReport): + parsedmarc.parse_aggregate_report_file(garbage_stream, offline=True) + self.assertFalse(garbage_stream.closed) + def testParseInvalidAggregateSample(self): """Test invalid aggregate samples are handled""" print() @@ -2216,6 +2249,47 @@ class TestExtractReport(unittest.TestCase): result = parsedmarc.extract_report(bio) self.assertIn("", result) + def testExtractReportLeavesSeekableCallerStreamOpen(self): + """A caller-supplied seekable stream is left open after a + successful call, and remains usable afterward. + + Before this fix, extract_report's seekable-stream branch set + file_object = stream (aliasing the caller's own handle) and then + unconditionally closed file_object in its `finally` block on + every path, success included -- closing a handle it did not open. + This asserts the real observable state of a real BytesIO handle + (``closed`` and a post-seek re-read), not a mock's bookkeeping. + """ + xml = b'' + bio = BytesIO(xml) + + result = parsedmarc.extract_report(bio) + + self.assertIn("", result) + self.assertFalse(bio.closed) + bio.seek(0) + self.assertEqual(bio.read(), xml) + + def testExtractReportLeavesSeekableCallerStreamOpenOnError(self): + """A caller-supplied seekable stream is left open when + extract_report raises ParserError, the other half of the "never + closed on success or failure" claim above. + + Before this fix, extract_report's seekable-stream branch aliased + file_object to the caller's own stream and its `finally` block + closed file_object unconditionally -- including on this + exception path, since content matching no known format still + reaches the header sniff, `stream.seek(0)`, and the `finally` + close before raising. Uses a real BytesIO so the assertion is on + its own observable ``closed`` state, not a mock's bookkeeping. + """ + bio = BytesIO(b"this is not a valid archive") + + with self.assertRaises(parsedmarc.ParserError): + parsedmarc.extract_report(bio) + + self.assertFalse(bio.closed) + def testExtractReportFromNonSeekableStream(self): """extract_report handles non-seekable streams""" xml = b'' @@ -2799,13 +2873,13 @@ class TestParseReportFile(unittest.TestCase): def testParseReportFileLeavesCallerHandleOpenOnReadError(self): """A caller-supplied file-like object is left open (not closed) - when reading it raises, preserving parse_report_file's - long-standing contract for handles it did not open itself. + when reading it raises. Only a path input (opened internally by parse_report_file) is closed on the exception path; a handle the caller passed in is - closed on success only, exactly as before the fix for the - internally-opened-path leak. + never closed by parse_report_file, on success or failure -- the + caller may want to seek(0) and retry, log tell(), or reuse the + handle otherwise. A plain class is used instead of MagicMock because MagicMock auto-implements ``__fspath__`` (supported since Python 3.8's @@ -2831,6 +2905,30 @@ class TestParseReportFile(unittest.TestCase): self.assertFalse(fake_handle.close_called) + def testParseReportFileLeavesCallerHandleOpenOnSuccess(self): + """A caller-supplied file-like object is left open after a + successful parse, and remains usable afterward. + + Before this fix, parse_report_file called .close() on any + caller-supplied file object once it had been read, on the success + path only. A function must not close a handle it did not open -- + the caller may still want to seek(0) and re-read it, inspect + tell(), or otherwise reuse it. This asserts the real observable + state of a real BytesIO handle (``closed`` and post-seek + ``read()``), not a mock's call-tracking. + """ + xml_path = "samples/aggregate/!example.com!1538204542!1538463818.xml" + with open(xml_path, "rb") as f: + data = f.read() + handle = BytesIO(data) + + result = parsedmarc.parse_report_file(handle, offline=True) + + self.assertEqual(result["report_type"], "aggregate") + self.assertFalse(handle.closed) + handle.seek(0) + self.assertEqual(handle.read(), data) + class TestParseReportEmail(unittest.TestCase): """Tests for parse_report_email edge cases"""