Repository: pywemo/pywemo (language: python)

Issue:
Handle invalid data received from device
## Description:

Apply some error checking on Device responses to catch cases where invalid data is present. Add [Hypothesis](https://hypothesis.readthedocs.io/en/latest/) tests to improve coverage for these edge cases.

## Checklist:
  - [x] The code change is tested and works locally.
  - [x] There is no commented out code in this PR.
  - [x] I have read and agree to the [CONTRIBUTING document](
        https://github.com/pywemo/pywemo/blob/master/CONTRIBUTING.md).


Discussion / hints:

Fix patch:
diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile
new file mode 100644
index 00000000..cedac2ea
--- /dev/null
+++ b/.clusterfuzzlite/Dockerfile
@@ -0,0 +1,6 @@
+FROM gcr.io/oss-fuzz-base/base-builder-python:v1
+RUN apt-get update && \
+    apt-get install -y make autoconf automake libtool pip
+COPY . $SRC/pywemo
+WORKDIR pywemo
+COPY .clusterfuzzlite/build.sh $SRC/
diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh
new file mode 100755
index 00000000..9a7bd3fd
--- /dev/null
+++ b/.clusterfuzzlite/build.sh
@@ -0,0 +1,36 @@
+#!/bin/sh -eu
+
+cd "$SRC/pywemo"
+source "./scripts/common.sh"
+enterVenv
+# Upgrade pip first.
+pip install \
+  --require-hashes \
+  --no-deps \
+  --only-binary :all: \
+  -c .clusterfuzzlite/requirements.txt \
+  pip
+# Install fuzzer dependencies.
+pip install \
+  --require-hashes \
+  --no-deps \
+  --only-binary :all: \
+  -r ./.clusterfuzzlite/requirements.txt
+# Install pyWeMo dependencies.
+poetryInstall
+
+# Use pyinstaller to build fuzzers.
+for fuzzer in $(find . -name '*_fuzz.py'); do
+  fuzzer_basename=$(basename -s .py $fuzzer)
+  fuzzer_package=${fuzzer_basename}.pkg
+
+  pyinstaller --distpath $OUT --onefile --name $fuzzer_package $fuzzer
+
+  echo "#!/bin/sh
+# LLVMFuzzerTestOneInput for fuzzer detection.
+this_dir=\$(dirname \"\$0\")
+LD_PRELOAD=\$this_dir/sanitizer_with_fuzzer.so \
+ASAN_OPTIONS=\$ASAN_OPTIONS:symbolize=1:external_symbolizer_path=\$this_dir/llvm-symbolizer:detect_leaks=0 \
+\$this_dir/$fuzzer_package \$@" > $OUT/$fuzzer_basename
+  chmod +x $OUT/$fuzzer_basename
+done
diff --git a/.clusterfuzzlite/project.yaml b/.clusterfuzzlite/project.yaml
new file mode 100644
index 00000000..d1ad0ae5
--- /dev/null
+++ b/.clusterfuzzlite/project.yaml
@@ -0,0 +1,1 @@
+language: python
diff --git a/.clusterfuzzlite/requirements.txt b/.clusterfuzzlite/requirements.txt
new file mode 100644
index 00000000..0776bef8
--- /dev/null
+++ b/.clusterfuzzlite/requirements.txt
@@ -0,0 +1,49 @@
+altgraph==0.17.3 ; python_full_version >= "3.8.1" and python_version < "3.12" \
+    --hash=sha256:ad33358114df7c9416cdb8fa1eaa5852166c505118717021c6a8c7c7abbd03dd \
+    --hash=sha256:c8ac1ca6772207179ed8003ce7687757c04b0b71536f81e2ac5755c6226458fe
+atheris==2.2.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" \
+    --hash=sha256:0f02b03542266e438649e97ebbf5f96b94661cebca49a85420424cf65fdd1e87 \
+    --hash=sha256:13eb3154994fe6d1330d76d970eefe25b433af2d775e690992890b714d54b68e \
+    --hash=sha256:2087933ec91df9d3fb2ee22649c9811536515519d88bcc587d67018c3f0227f6 \
+    --hash=sha256:30f1255319fe87765639e451a178a4732b1185af43b5c9b842591f03830cf8ec \
+    --hash=sha256:33213d1f2e7f88d976c00dc3d19cc91e1304cd3d4dfba8f3ad5383093f7c55ab \
+    --hash=sha256:6606b443ddf3932333aa6854bad2c4a000a65f1faeb4f559e8ea376af255278b \
+    --hash=sha256:8e692836e6a8c41724c354a4f68fe35349bbc88af4708d835abef074ad5cb3f2 \
+    --hash=sha256:9fb4c5e6df23b6b3650e5cb8e4780d189ae34443a23339750742a0de92ea0101 \
+    --hash=sha256:ac727bcfc3345e2562722c517c836abe65ab8d7e4eaad1d792a6c85765868aff \
+    --hash=sha256:d06fdfe077d8188c5acdccb8d4cbb34a3fc2d6f84dbb7b807f523cc5f6e9b8a4 \
+    --hash=sha256:ebccc878ba5b958a42a97edcedff9ff46882b7c968e84cbf32632588457838f4 \
+    --hash=sha256:f305a4c95575e8372774d20c28f6345f21ab7d926da8ebdd1b92f92475f5ef0b \
+    --hash=sha256:fc0e4bf0830003bab8509147385184eae347a191e8f16e5e2d650aaafdcd5eb0 \
+    --hash=sha256:fd4d8050f548e2583ed4c4600211f341537f077bf54399f7b4f4d814b112f35b
+macholib==1.16.2 ; python_full_version >= "3.8.1" and python_version < "3.12" and sys_platform == "darwin" \
+    --hash=sha256:44c40f2cd7d6726af8fa6fe22549178d3a4dfecc35a9cd15ea916d9c83a688e0 \
+    --hash=sha256:557bbfa1bb255c20e9abafe7ed6cd8046b48d9525db2f9b77d3122a63a2a8bf8
+pefile==2023.2.7 ; python_full_version >= "3.8.1" and python_version < "3.12" and sys_platform == "win32" \
+    --hash=sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc \
+    --hash=sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6
+pip==23.1.2 ; python_full_version >= "3.8.1" and python_version < "3.12" \
+    --hash=sha256:0e7c86f486935893c708287b30bd050a36ac827ec7fe5e43fe7cb198dd835fba \
+    --hash=sha256:3ef6ac33239e4027d9a5598a381b9d30880a1477e50039db2eac6e8a8f6d1b18
+pyinstaller-hooks-contrib==2023.3 ; python_full_version >= "3.8.1" and python_version < "3.12" \
+    --hash=sha256:062ad7a1746e1cfc24d3a8c4be4e606fced3b123bda7d419f14fcf7507804b07 \
+    --hash=sha256:bb39e1038e3e0972420455e0b39cd9dce73f3d80acaf4bf2b3615fea766ff370
+pyinstaller==5.11.0 ; python_full_version >= "3.8.1" and python_version < "3.12" \
+    --hash=sha256:036a062a228af41f6bb6370a4e87cef34858cc839200a07ace7f8738ef64ad86 \
+    --hash=sha256:049cdc3524aefb5ca015a63d2c81b6bf1567cc818ac066859fbfde702c6165d3 \
+    --hash=sha256:0af9d11a09ce217d32f95c79c984054457b310671387ff32bae1496876308556 \
+    --hash=sha256:2a1fe6d0da22f207cfa4b3221fe365503cba071c77aac19f76a75503f67d9ff9 \
+    --hash=sha256:42fdea67e4c2217cedd54d17d1d402736df3ba718db2b497df65df5a68ae4f93 \
+    --hash=sha256:8454bac8f3cb2219a3ce2227fd039bdaf943dcba60e8c55732958ea3a6d81263 \
+    --hash=sha256:a445a91b85c9a1ea3985268643a674900dd86f244cc4be4ff4ec4c6367ff99a9 \
+    --hash=sha256:b3c6299fd7526c6ca87ea5f9017fb1928d47046df0b9f983d6bbd893801010dc \
+    --hash=sha256:b4cac0e7b0d63c6a869843113008f59fd5b38b2959ffa6059e7fac4bb05de92b \
+    --hash=sha256:b8a4f6834e5c85150948e22c74dd3ab8b98aa4ccdf964d880ac14d2f78d9c1a4 \
+    --hash=sha256:cb87cee0b3c81ccd74d4bf3f4faf03b5e1e39bb91f1a894b2ce4cd22363bf779 \
+    --hash=sha256:e359571327bbef434fc61324891399f9117efbb685b5065234eebb01713650a8
+pywin32-ctypes==0.2.0 ; python_full_version >= "3.8.1" and python_version < "3.12" and sys_platform == "win32" \
+    --hash=sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942 \
+    --hash=sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98
+setuptools==67.7.2 ; python_full_version >= "3.8.1" and python_version < "3.12" \
+    --hash=sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b \
+    --hash=sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990
diff --git a/.dockerignore b/.dockerignore
new file mode 120000
index 00000000..3e4e48b0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,1 @@
+.gitignore
\ No newline at end of file
diff --git a/.github/workflows/cflite.yml b/.github/workflows/cflite.yml
new file mode 100644
index 00000000..2b420f05
--- /dev/null
+++ b/.github/workflows/cflite.yml
@@ -0,0 +1,53 @@
+name: ClusterFuzzLite
+
+on:
+  push:
+    paths:
+      - .github/workflows/fuzz.yml
+      - .clusterfuzzlite/**
+      - pywemo/**.py
+      - tests/**_fuzz.py
+  pull_request:
+    paths:
+      - .github/workflows/fuzz.yml
+      - .clusterfuzzlite/**
+      - pywemo/**.py
+      - tests/**_fuzz.py
+  schedule:
+    - cron: '27 3 * * *'  # Daily.
+
+permissions: read-all
+
+jobs:
+  fuzz:
+    name: Fuzzing
+    runs-on: ubuntu-latest
+    concurrency:
+      group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.event_name }}-${{ github.ref }}
+      cancel-in-progress: true
+    strategy:
+      fail-fast: false
+      matrix:
+        sanitizer:
+        - address
+        # - undefined
+        # - memory
+    steps:
+    - name: Build Fuzzers (${{ matrix.sanitizer }})
+      id: build
+      uses: google/clusterfuzzlite/actions/build_fuzzers@1e163f06cba7820da5154ac9fe1a32d7fe6f73a3 # v1
+      with:
+        language: python
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        sanitizer: ${{ matrix.sanitizer }}
+        upload-build: ${{ github.event_name == 'push' }}
+
+    - name: Run Fuzzers (${{ matrix.sanitizer }})
+      id: run
+      if: github.event_name != 'push'
+      uses: google/clusterfuzzlite/actions/run_fuzzers@1e163f06cba7820da5154ac9fe1a32d7fe6f73a3 # v1
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        fuzz-seconds: ${{ github.event_name == 'schedule' && 3600 || 600 }}
+        mode: ${{ github.event_name == 'schedule' && 'batch' || 'code-change' }}
+        sanitizer: ${{ matrix.sanitizer }}
diff --git a/.github/workflows/cflite_cron.yml b/.github/workflows/cflite_cron.yml
new file mode 100644
index 00000000..78cb6424
--- /dev/null
+++ b/.github/workflows/cflite_cron.yml
@@ -0,0 +1,24 @@
+name: ClusterFuzzLite cron tasks
+
+on:
+  schedule:
+    - cron: '52 13 * * 2'  # Every Tuesday.
+
+permissions: read-all
+
+jobs:
+  Pruning:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Build Fuzzers
+      id: build
+      uses: google/clusterfuzzlite/actions/build_fuzzers@1e163f06cba7820da5154ac9fe1a32d7fe6f73a3 # v1
+      with:
+        language: python
+    - name: Run Fuzzers
+      id: run
+      uses: google/clusterfuzzlite/actions/run_fuzzers@1e163f06cba7820da5154ac9fe1a32d7fe6f73a3 # v1
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        fuzz-seconds: 600
+        mode: 'prune'
diff --git a/.gitignore b/.gitignore
index 27a66c25..eaa2a5b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -107,3 +107,6 @@ desktop.ini
 
 # monkeytype
 monkeytype.sqlite3
+
+# git
+.git


Test patch:
diff --git a/tests/atheris_stub.py b/tests/atheris_stub.py
new file mode 100644
index 00000000..ea02f88a
--- /dev/null
+++ b/tests/atheris_stub.py
@@ -0,0 +1,14 @@
+"""Stub to handle cases when Atheris is not installed.
+
+Atheris is only needed during fuzzing with OSS-Fuzz. It does not need to be
+installed while building/testing pyWeMo. This provides stub methods for when
+Atheris is not installed.
+"""
+try:
+    from atheris import *  # noqa
+except ImportError:
+    import contextlib
+
+    @contextlib.contextmanager
+    def instrument_imports():
+        yield
diff --git a/tests/test_registry_notify_fuzz.py b/tests/test_registry_notify_fuzz.py
index c953e426..65de069a 100644
--- a/tests/test_registry_notify_fuzz.py
+++ b/tests/test_registry_notify_fuzz.py
@@ -1,18 +1,23 @@
 """Exercise the NOTIFY subscription http endpoint."""
 from __future__ import annotations
 
+import contextlib
 import string
+import sys
 import unittest.mock as mock
 
+from tests import atheris_stub as atheris
+
+with atheris.instrument_imports():
+    from lxml import etree
+    import pywemo
+
 import pytest
 import requests
 from hypothesis import example, given
 from hypothesis import strategies as st
-from lxml import etree
 
-import pywemo
-
-from .ouimeaux_device.test_device import mocked_requests_get
+from tests.ouimeaux_device.test_device import mocked_requests_get
 
 MOCK_SERVICE_RETURN_VALUES = {
     "bridge": {
@@ -131,6 +136,18 @@
 @mock.patch("urllib3.PoolManager.request", side_effect=mocked_requests_get)
 def make_device(device_class, *args):
     class WrappedDevice(device_class):
+        @property
+        def device_type(self) -> str:
+            return device_class.__name__
+
+        @property
+        def name(self):
+            return device_class.__name__
+
+        @name.setter
+        def name(self, name):
+            pass
+
         def _check_required_services(self, services):
             for service in self._required_services:
                 service_mock = mock.Mock()
@@ -141,7 +158,9 @@ def _check_required_services(self, services):
                 self.services[service.name] = service_mock
                 setattr(self, service.name, service_mock)
 
-    return WrappedDevice("http://192.168.1.100:49158/setup.xml")
+    device = WrappedDevice("http://192.168.1.100:49158/setup.xml")
+    device.session.url = "http://127.0.0.1:49158/"
+    return device
 
 
 DEVICES = {
@@ -156,6 +175,24 @@ def _check_required_services(self, services):
     )
 }
 DEVICE_NAMES = sorted(DEVICES.keys())
+REGISTRY = pywemo.SubscriptionRegistry()
+
+
+@contextlib.contextmanager
+def registry():
+    REGISTRY.start()
+    pywemo.subscribe.Subscription.scheduler_active = False
+    try:
+        yield REGISTRY
+    finally:
+        pywemo.subscribe.Subscription.scheduler_active = True
+        REGISTRY.stop()
+
+
+@pytest.fixture(scope="module", autouse=True)
+def pytest_registry():
+    with registry():
+        yield
 
 
 class ConvertChildrenToText:
@@ -353,14 +390,6 @@ def properties(names=PROPERTY_NAMES, values=PROPERTY_VALUES):
     )
 
 
-@pytest.fixture(scope="module")
-def registry(request):
-    sub = pywemo.SubscriptionRegistry()
-    sub.start()
-    request.addfinalizer(sub.stop)
-    return sub
-
-
 @given(name=st.sampled_from(DEVICE_NAMES), properties=properties())
 # Previous problem cases.
 @example(name="Bridge", properties=[("StatusChange", "1")])
@@ -481,17 +510,25 @@ def registry(request):
         )
     ],
 )
-def test_notify(name, properties, registry):
+def test_notify(name, properties):
     device = DEVICES[name]
-    registry.devices["127.0.0.1"] = device
+    REGISTRY.register(device)
+    REGISTRY.on(device, None, lambda d, t, v: d.subscription_update(t, v))
+    try:
+        response = requests.request(
+            "NOTIFY",
+            f"http://127.0.0.1:{REGISTRY.port}/sub/basicevent",
+            data=toXml(properties),
+        )
+        assert response.status_code == 200
+    finally:
+        REGISTRY.unregister(device)
 
-    def callback(device, type_, value):
-        device.subscription_update(type_, value)
 
-    registry.on(device, None, callback)
-    response = requests.request(
-        "NOTIFY",
-        f"http://127.0.0.1:{registry.port}/",
-        data=toXml(properties),
+if __name__ == "__main__":
+    atheris.Setup(
+        sys.argv,
+        atheris.instrument_func(test_notify.hypothesis.fuzz_one_input),
     )
-    assert response.status_code == 200
+    with registry():
+        atheris.Fuzz()