Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions fp/__init__.py → src/fp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from __future__ import annotations

__version__ = "0.1.0"

import hashlib
import locale
import os
Expand Down
File renamed without changes.
File renamed without changes.
Empty file added tests/__init__.py
Empty file.
68 changes: 68 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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()
86 changes: 86 additions & 0 deletions tests/test_fingerprint.py
Original file line number Diff line number Diff line change
@@ -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()
64 changes: 64 additions & 0 deletions tools/dep_guard.py
Original file line number Diff line number Diff line change
@@ -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())
Loading