diff --git a/.github/workflows/bench.yaml b/.github/workflows/bench.yaml index a12206b..ab3ec5f 100644 --- a/.github/workflows/bench.yaml +++ b/.github/workflows/bench.yaml @@ -14,7 +14,7 @@ jobs: name: Run benchmarks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: diff --git a/.github/workflows/cla.yaml b/.github/workflows/cla.yaml index c4f291e..88894fc 100644 --- a/.github/workflows/cla.yaml +++ b/.github/workflows/cla.yaml @@ -5,14 +5,21 @@ on: types: [created] pull_request_target: types: [opened, closed, synchronize] + workflow_dispatch: + inputs: + pr_number: + description: 'Pull request number to check' + required: true + type: number jobs: cla: name: Check CLA runs-on: ubuntu-latest + if: github.actor != 'dependabot[bot]' steps: - name: CLA Assistant - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' uses: secondlife-3p/contributor-assistant@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/llsd/base.py b/llsd/base.py index e7204ca..676eb92 100644 --- a/llsd/base.py +++ b/llsd/base.py @@ -410,14 +410,24 @@ 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 - must be seekable for parsing + if something.seekable(): + self._stream = something + else: + raise LLSDParseError( + "Cannot parse LLSD from non-seekable 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( + "Cannot parse LLSD from {0}. " + "Expected bytes or a file-like object (io.IOBase subclass).".format( + type(something).__name__ + ) + ) def starts_with(self, pattern): """ diff --git a/tests/llsd_test.py b/tests/llsd_test.py index 073a974..c1900db 100644 --- a/tests/llsd_test.py +++ b/tests/llsd_test.py @@ -1977,3 +1977,59 @@ 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. + ''' + + 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. + 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)) + + 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)) + + 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)) + +