From f2e9a8934e977338843242689594a36787daccb8 Mon Sep 17 00:00:00 2001 From: Sidd Date: Tue, 9 Dec 2025 17:36:47 +0000 Subject: [PATCH 1/6] Fix infinite loop when parsing MagicMock objects --- llsd/base.py | 26 +++++++++++++++++++------- tests/llsd_test.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/llsd/base.py b/llsd/base.py index e7204ca..b58e781 100644 --- a/llsd/base.py +++ b/llsd/base.py @@ -410,14 +410,26 @@ def _reset(self, something): # string is so large that the overhead of copying it into a # BytesIO is significant, advise caller to pass a stream instead. self._stream = io.BytesIO(something) - elif something.seekable(): - # 'something' is already a seekable stream, use directly - self._stream = something + elif isinstance(something, io.IOBase): + # 'something' is a proper IO stream + if something.seekable(): + # Seekable stream, use directly + self._stream = something + elif something.readable(): + # Readable but not seekable, wrap in BufferedReader + self._stream = io.BufferedReader(something) + else: + raise LLSDParseError( + "Cannot parse LLSD from non-readable stream." + ) else: - # 'something' isn't seekable, wrap in BufferedReader - # (let BufferedReader handle the problem of passing an - # inappropriate object) - self._stream = io.BufferedReader(something) + # Invalid input type - raise a clear error + # This catches MagicMock and other non-stream objects that might + # have read/seek attributes but aren't actual IO streams + raise LLSDParseError( + f"Cannot parse LLSD from {type(something).__name__}. " + "Expected bytes or a file-like object (io.IOBase subclass)." + ) def starts_with(self, pattern): """ diff --git a/tests/llsd_test.py b/tests/llsd_test.py index 073a974..70d2544 100644 --- a/tests/llsd_test.py +++ b/tests/llsd_test.py @@ -1977,3 +1977,47 @@ def test_uuid_map_key(self): self.assertEqual(llsd.format_notation(llsdmap), b"{'00000000-0000-0000-0000-000000000000':'uuid'}") +class InvalidInputTypes(unittest.TestCase): + ''' + Tests for handling invalid input types that should raise LLSDParseError + instead of hanging or consuming infinite memory. + ''' + + def test_parse_magicmock_raises_error(self): + ''' + Parsing a MagicMock object should raise LLSDParseError, not hang. + This is a regression test for a bug where llsd.parse() would go into + an infinite loop when passed a MagicMock (e.g., from an improperly + mocked requests.Response.content). + ''' + from unittest.mock import MagicMock + mock = MagicMock() + with self.assertRaises(llsd.LLSDParseError) as context: + llsd.parse(mock) + self.assertIn('MagicMock', str(context.exception)) + + def test_parse_string_raises_error(self): + ''' + Parsing a string (not bytes) should raise LLSDParseError. + ''' + with self.assertRaises(llsd.LLSDParseError) as context: + llsd.parse('not bytes') + self.assertIn('str', str(context.exception)) + + def test_parse_none_raises_error(self): + ''' + Parsing None should raise LLSDParseError. + ''' + with self.assertRaises(llsd.LLSDParseError) as context: + llsd.parse(None) + self.assertIn('NoneType', str(context.exception)) + + def test_parse_int_raises_error(self): + ''' + Parsing an integer should raise LLSDParseError. + ''' + with self.assertRaises(llsd.LLSDParseError) as context: + llsd.parse(42) + self.assertIn('int', str(context.exception)) + + From ab6288c052e371ccc39321f0afca5c832f4e98d7 Mon Sep 17 00:00:00 2001 From: Sidd Date: Tue, 9 Dec 2025 23:01:12 +0000 Subject: [PATCH 2/6] make fix python 2.7 compatible --- llsd/base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/llsd/base.py b/llsd/base.py index b58e781..29701ce 100644 --- a/llsd/base.py +++ b/llsd/base.py @@ -427,8 +427,10 @@ def _reset(self, something): # This catches MagicMock and other non-stream objects that might # have read/seek attributes but aren't actual IO streams raise LLSDParseError( - f"Cannot parse LLSD from {type(something).__name__}. " - "Expected bytes or a file-like object (io.IOBase subclass)." + "Cannot parse LLSD from {0}. " + "Expected bytes or a file-like object (io.IOBase subclass).".format( + type(something).__name__ + ) ) def starts_with(self, pattern): From 133864b5f1ab62892eccf484a398e1a0b9575d47 Mon Sep 17 00:00:00 2001 From: Sidd Date: Tue, 9 Dec 2025 23:04:26 +0000 Subject: [PATCH 3/6] more python 2.7 related fixes --- tests/llsd_test.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/llsd_test.py b/tests/llsd_test.py index 70d2544..61095b3 100644 --- a/tests/llsd_test.py +++ b/tests/llsd_test.py @@ -1990,15 +1990,20 @@ def test_parse_magicmock_raises_error(self): an infinite loop when passed a MagicMock (e.g., from an improperly mocked requests.Response.content). ''' - from unittest.mock import MagicMock + try: + from unittest.mock import MagicMock + except ImportError: + from mock import MagicMock # Python 2.7 mock = MagicMock() with self.assertRaises(llsd.LLSDParseError) as context: llsd.parse(mock) self.assertIn('MagicMock', str(context.exception)) + @unittest.skipIf(PY2, "str is bytes in Python 2") def test_parse_string_raises_error(self): ''' Parsing a string (not bytes) should raise LLSDParseError. + Only applies to Python 3 where str and bytes are distinct. ''' with self.assertRaises(llsd.LLSDParseError) as context: llsd.parse('not bytes') From 722f4a410fba51e5c9c60e1be72ea0c1e8917649 Mon Sep 17 00:00:00 2001 From: Sidd Date: Tue, 9 Dec 2025 23:08:45 +0000 Subject: [PATCH 4/6] skip new tests for 2.7 --- tests/llsd_test.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/llsd_test.py b/tests/llsd_test.py index 61095b3..1e052b1 100644 --- a/tests/llsd_test.py +++ b/tests/llsd_test.py @@ -1977,6 +1977,7 @@ def test_uuid_map_key(self): self.assertEqual(llsd.format_notation(llsdmap), b"{'00000000-0000-0000-0000-000000000000':'uuid'}") +@unittest.skipIf(PY2, "These tests require Python 3") class InvalidInputTypes(unittest.TestCase): ''' Tests for handling invalid input types that should raise LLSDParseError @@ -1990,16 +1991,12 @@ def test_parse_magicmock_raises_error(self): an infinite loop when passed a MagicMock (e.g., from an improperly mocked requests.Response.content). ''' - try: - from unittest.mock import MagicMock - except ImportError: - from mock import MagicMock # Python 2.7 + from unittest.mock import MagicMock mock = MagicMock() with self.assertRaises(llsd.LLSDParseError) as context: llsd.parse(mock) self.assertIn('MagicMock', str(context.exception)) - @unittest.skipIf(PY2, "str is bytes in Python 2") def test_parse_string_raises_error(self): ''' Parsing a string (not bytes) should raise LLSDParseError. From 801f8e7aefbeae3670e706998488fd04ada11dfd Mon Sep 17 00:00:00 2001 From: Sidd Date: Tue, 9 Dec 2025 23:14:09 +0000 Subject: [PATCH 5/6] updated for test coverage --- llsd/base.py | 8 ++------ tests/llsd_test.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/llsd/base.py b/llsd/base.py index 29701ce..676eb92 100644 --- a/llsd/base.py +++ b/llsd/base.py @@ -411,16 +411,12 @@ def _reset(self, something): # BytesIO is significant, advise caller to pass a stream instead. self._stream = io.BytesIO(something) elif isinstance(something, io.IOBase): - # 'something' is a proper IO stream + # 'something' is a proper IO stream - must be seekable for parsing if something.seekable(): - # Seekable stream, use directly self._stream = something - elif something.readable(): - # Readable but not seekable, wrap in BufferedReader - self._stream = io.BufferedReader(something) else: raise LLSDParseError( - "Cannot parse LLSD from non-readable stream." + "Cannot parse LLSD from non-seekable stream." ) else: # Invalid input type - raise a clear error diff --git a/tests/llsd_test.py b/tests/llsd_test.py index 1e052b1..c1900db 100644 --- a/tests/llsd_test.py +++ b/tests/llsd_test.py @@ -2022,4 +2022,14 @@ def test_parse_int_raises_error(self): llsd.parse(42) self.assertIn('int', str(context.exception)) + def test_parse_non_seekable_stream_raises_error(self): + ''' + Parsing a non-seekable stream should raise LLSDParseError. + ''' + stream = io.BytesIO() + stream.seekable = lambda: False + with self.assertRaises(llsd.LLSDParseError) as context: + llsd.parse(stream) + self.assertIn('non-seekable', str(context.exception)) + From 74daf22a3b024b0df680734070512b3b121bfb12 Mon Sep 17 00:00:00 2001 From: Sidd Date: Fri, 19 Dec 2025 18:01:21 +0000 Subject: [PATCH 6/6] Address PR comments: improve error message and Python 2 test compatibility --- llsd/base.py | 2 +- tests/llsd_test.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/llsd/base.py b/llsd/base.py index 676eb92..2e8c2ca 100644 --- a/llsd/base.py +++ b/llsd/base.py @@ -424,7 +424,7 @@ def _reset(self, something): # have read/seek attributes but aren't actual IO streams raise LLSDParseError( "Cannot parse LLSD from {0}. " - "Expected bytes or a file-like object (io.IOBase subclass).".format( + "Expected bytes or a seekable io.IOBase object.".format( type(something).__name__ ) ) diff --git a/tests/llsd_test.py b/tests/llsd_test.py index c1900db..f78577f 100644 --- a/tests/llsd_test.py +++ b/tests/llsd_test.py @@ -1977,13 +1977,13 @@ def test_uuid_map_key(self): self.assertEqual(llsd.format_notation(llsdmap), b"{'00000000-0000-0000-0000-000000000000':'uuid'}") -@unittest.skipIf(PY2, "These tests require Python 3") class InvalidInputTypes(unittest.TestCase): ''' Tests for handling invalid input types that should raise LLSDParseError instead of hanging or consuming infinite memory. ''' + @unittest.skipIf(PY2, "MagicMock requires Python 3") def test_parse_magicmock_raises_error(self): ''' Parsing a MagicMock object should raise LLSDParseError, not hang. @@ -2003,8 +2003,8 @@ def test_parse_string_raises_error(self): Only applies to Python 3 where str and bytes are distinct. ''' with self.assertRaises(llsd.LLSDParseError) as context: - llsd.parse('not bytes') - self.assertIn('str', str(context.exception)) + llsd.parse(b'not bytes'.decode('ascii')) + self.assertIn('unicode' if PY2 else 'str', str(context.exception)) def test_parse_none_raises_error(self): '''