diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7a0ce52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install -e ".[dev]" + - run: python -m unittest discover -s tests -t . + + dependency-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python tools/dep_guard.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3a3a2f4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "fp-py" +version = "0.1.0" +description = "Lightweight device fingerprinting using only the Python standard library." +readme = "README.md" +requires-python = ">=3.9" +license = { file = "LICENSE" } +authors = [{ name = "NativeLite" }] +keywords = ["fingerprint", "device", "stdlib", "zero-dependency", "nativelite"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Security", +] + +# nativelite core rule: ZERO third-party runtime dependencies. Do not add to this. +dependencies = [] + +[project.urls] +Homepage = "https://github.com/nativelite/fp-py" +Issues = "https://github.com/nativelite/fp-py/issues" + +# Dev-only tooling. Never a runtime dependency; not shipped in the wheel's deps. +[project.optional-dependencies] +dev = ["coverage"] + +[tool.hatch.build.targets.wheel] +packages = ["src/fp"] + +[tool.hatch.build.targets.sdist] +include = ["src/fp", "README.md", "LICENSE", "CHANGELOG.md"] diff --git a/fp/__init__.py b/src/fp/__init__.py similarity index 99% rename from fp/__init__.py rename to src/fp/__init__.py index e612d79..88447d8 100644 --- a/fp/__init__.py +++ b/src/fp/__init__.py @@ -7,6 +7,8 @@ from __future__ import annotations +__version__ = "0.1.0" + import hashlib import locale import os diff --git a/fp/__main__.py b/src/fp/__main__.py similarity index 100% rename from fp/__main__.py rename to src/fp/__main__.py diff --git a/fp/client.py b/src/fp/client.py similarity index 100% rename from fp/client.py rename to src/fp/client.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..d4acb1a --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,68 @@ +"""Tests for fp.client (stdlib unittest + mock; no live network).""" +from __future__ import annotations + +import json +import unittest +from unittest import mock +from urllib.error import URLError + +from fp import client + + +def _fake_response(body: bytes): + resp = mock.MagicMock() + resp.read.return_value = body + cm = mock.MagicMock() + cm.__enter__.return_value = resp + return cm + + +class GetFingerprintTests(unittest.TestCase): + def test_delegates_to_fingerprint(self): + with mock.patch("fp.client.fingerprint", return_value="x" * 64): + self.assertEqual(client.get_fingerprint(), "x" * 64) + + +class PostFingerprintTests(unittest.TestCase): + def setUp(self): + patcher = mock.patch("fp.client.get_fingerprint", return_value="fp123") + self.addCleanup(patcher.stop) + patcher.start() + + def test_success_returns_decoded_json(self): + with mock.patch("fp.client.urllib.request.urlopen", + return_value=_fake_response(b'{"ok": true}')): + self.assertEqual(client.post_fingerprint("http://x"), {"ok": True}) + + def test_merges_extra_data_into_payload(self): + captured = {} + + def fake_urlopen(req, timeout=5): + captured["body"] = json.loads(req.data.decode("utf-8")) + return _fake_response(b'{}') + + with mock.patch("fp.client.urllib.request.urlopen", side_effect=fake_urlopen): + client.post_fingerprint("http://x", data={"user": "a"}) + self.assertEqual(captured["body"], {"fingerprint": "fp123", "user": "a"}) + + def test_invalid_json_raises_valueerror(self): + with mock.patch("fp.client.urllib.request.urlopen", + return_value=_fake_response(b'not json')): + with self.assertRaises(ValueError): + client.post_fingerprint("http://x") + + def test_urlerror_returns_error_dict(self): + with mock.patch("fp.client.urllib.request.urlopen", + side_effect=URLError("boom")): + out = client.post_fingerprint("http://x") + self.assertEqual(out["error"]["type"], "URLError") + + def test_timeout_returns_error_dict(self): + with mock.patch("fp.client.urllib.request.urlopen", + side_effect=TimeoutError("slow")): + out = client.post_fingerprint("http://x") + self.assertEqual(out["error"]["type"], "TimeoutError") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py new file mode 100644 index 0000000..c451c5a --- /dev/null +++ b/tests/test_fingerprint.py @@ -0,0 +1,86 @@ +"""Tests for fp core fingerprinting (stdlib unittest only).""" +from __future__ import annotations + +import hashlib +import io +import unittest +from unittest import mock + +import fp + + +class FingerprintTests(unittest.TestCase): + def test_fingerprint_is_64_char_hex(self): + digest = fp.fingerprint() + self.assertEqual(len(digest), 64) + int(digest, 16) # raises if not hex + + def test_fingerprint_deterministic_for_fixed_components(self): + components = {"b": "2", "a": "1"} + raw = "|".join(f"{k}:{components[k]}" for k in sorted(components)) + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + with mock.patch.object(fp, "get_components", return_value=dict(components)): + self.assertEqual(fp.fingerprint(), expected) + + def test_get_components_returns_string_map(self): + comps = fp.get_components() + self.assertIsInstance(comps, dict) + for key in ("system", "machine", "python_version", "machine_id", + "locale", "mac", "ip", "timezone"): + self.assertIn(key, comps) + self.assertIsInstance(comps[key], str) + + def test_get_components_fallbacks_on_error(self): + with mock.patch("fp.socket.gethostbyname", side_effect=OSError), \ + mock.patch("fp.uuid.getnode", side_effect=OSError), \ + mock.patch("fp.locale.getlocale", side_effect=OSError): + comps = fp.get_components() + self.assertEqual(comps["ip"], "unknown") + self.assertEqual(comps["mac"], "unknown") + self.assertEqual(comps["locale"], "unknown") + + +class MachineIdTests(unittest.TestCase): + def test_linux_reads_machine_id_file(self): + with mock.patch.object(fp.sys, "platform", "linux"), \ + mock.patch.object(fp.Path, "read_text", return_value="abc123\n"): + self.assertEqual(fp.machine_id(), "abc123") + + def test_darwin_parses_ioreg(self): + out = b'"IOPlatformUUID" = "DEAD-BEEF"' + with mock.patch.object(fp.sys, "platform", "darwin"), \ + mock.patch.object(fp.subprocess, "check_output", return_value=out): + self.assertEqual(fp.machine_id(), "DEAD-BEEF") + + def test_unknown_when_platform_unsupported(self): + with mock.patch.object(fp.sys, "platform", "sunos"): + self.assertEqual(fp.machine_id(), "unknown") + + def test_unknown_on_linux_read_failure(self): + with mock.patch.object(fp.sys, "platform", "linux"), \ + mock.patch.object(fp.Path, "read_text", side_effect=OSError): + self.assertEqual(fp.machine_id(), "unknown") + + +class CliTests(unittest.TestCase): + def _run(self, argv): + buf = io.StringIO() + with mock.patch.object(fp.sys, "argv", argv), \ + mock.patch("sys.stdout", buf): + from fp.__main__ import main + main() + return buf.getvalue() + + def test_cli_default_prints_hash(self): + with mock.patch("fp.__main__.fingerprint", return_value="d" * 64): + out = self._run(["fp"]) + self.assertIn("d" * 64, out) + + def test_cli_components_prints_json(self): + with mock.patch("fp.__main__.get_components", return_value={"a": "1"}): + out = self._run(["fp", "--components"]) + self.assertIn('"a": "1"', out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/dep_guard.py b/tools/dep_guard.py new file mode 100644 index 0000000..2586cc4 --- /dev/null +++ b/tools/dep_guard.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""nativelite zero-dependency guard (stdlib only). + +Fails (exit 1) if the package declares any third-party runtime dependency or +imports anything outside the standard library. Run in CI on every push/PR. +""" +from __future__ import annotations + +import ast +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PKG_DIR = ROOT / "src" / "fp" +OWN = {"fp"} + + +def check_manifest() -> list[str]: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + deps = data.get("project", {}).get("dependencies", None) + if deps is None: + return ["pyproject.toml [project].dependencies is missing (must be [])"] + if deps != []: + return [f"runtime dependencies must be empty, found: {deps!r}"] + return [] + + +def _roots(tree: ast.AST): + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + yield a.name.split(".")[0] + elif isinstance(node, ast.ImportFrom): + if node.level: # relative import -> own package + continue + if node.module: + yield node.module.split(".")[0] + + +def check_imports() -> list[str]: + allowed = set(sys.stdlib_module_names) | OWN + problems: list[str] = [] + for path in sorted(PKG_DIR.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for root in _roots(tree): + if root not in allowed: + problems.append(f"{path.relative_to(ROOT)}: non-stdlib import '{root}'") + return problems + + +def main() -> int: + problems = check_manifest() + check_imports() + if problems: + print("Dependency guard FAILED:") + for p in problems: + print(f" - {p}") + return 1 + print("Dependency guard OK: zero third-party runtime dependencies.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())