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
67 changes: 67 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: test (${{ matrix.os }}, py${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python: ["3.9", "3.12"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}

- name: Install
run: python -m pip install --upgrade pip && python -m pip install .

# The suite is standard-library only and touches no sockets, so it runs
# unchanged on a sandboxed runner with no network egress.
- name: Run tests
run: python -m unittest discover -s tests -v

- name: Smoke test the CLI
run: |
netprobe --version
netprobe subnet 10.0.0.0/24
netprobe --json subnet 192.168.1.0/31
netprobe mac 00:0c:29:ab:cd:ef
shell: bash

lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install tools
run: python -m pip install --upgrade pip ruff

- name: Ruff
run: ruff check .

- name: Check the package builds
run: |
python -m pip install build
python -m build
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.ruff_cache/
.venv/
venv/

# Local config - netprobe.ini.example is the tracked template.
netprobe.ini
2 changes: 1 addition & 1 deletion netprobe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import argparse
import sys
from typing import Callable, Dict, List, Optional
from typing import Callable, List, Optional

from . import __version__

Expand Down
12 changes: 10 additions & 2 deletions netprobe/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@

# Importing a module here is enough - each one calls cli.register() at import
# time. Keep the list alphabetical so merge conflicts stay trivial.
from . import dnscheck, httpcheck, ipmath, latency, macvendor, output, portscan # noqa: F401
from . import dnscheck, httpcheck, ipmath, latency, macvendor, output, portscan

_MODULES = ["dnscheck", "httpcheck", "ipmath", "latency", "macvendor", "output", "portscan"]
_MODULES = [
"dnscheck",
"httpcheck",
"ipmath",
"latency",
"macvendor",
"output",
"portscan",
]


def loaded() -> list:
Expand Down
2 changes: 1 addition & 1 deletion netprobe/ipmath.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import argparse
import ipaddress
from typing import Dict, Iterator, List, Union
from typing import Dict, List, Union

from .cli import register

Expand Down
14 changes: 14 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
line-length = 88
target-version = "py39"

[lint]
# "UP" (pyupgrade) is deliberately left out. It rewrites `Dict[str, int]` to
# `dict[str, int]` and `Optional[X]` to `X | None`, which the codebase avoids
# on purpose: 3.9 is the supported floor and the typing-module spelling works
# there at runtime, not only inside `from __future__ import annotations`.
select = ["E", "F", "W", "I", "B", "C4"]

[lint.per-file-ignores]
# The CLI registry is populated by import side effects, so these imports are
# deliberately "unused".
"netprobe/commands.py" = ["F401"]
212 changes: 107 additions & 105 deletions tests/test_macvendor.py
Original file line number Diff line number Diff line change
@@ -1,105 +1,107 @@
"""Tests for MAC normalisation, flag bits and OUI lookup."""

import re
import unittest

from netprobe import macvendor


class TestNormalize(unittest.TestCase):
def test_every_common_notation_round_trips(self):
for text in (
"00:0c:29:ab:cd:ef",
"00-0C-29-AB-CD-EF",
"000c.29ab.cdef",
"000C29ABCDEF",
"00 0c 29 ab cd ef",
):
self.assertEqual(macvendor.normalize(text), "000c29abcdef", text)

def test_too_short_rejected(self):
with self.assertRaises(ValueError):
macvendor.normalize("00:0c:29:ab:cd")

def test_too_long_rejected(self):
with self.assertRaises(ValueError):
macvendor.normalize("00:0c:29:ab:cd:ef:11")

def test_non_hex_rejected(self):
with self.assertRaises(ValueError):
macvendor.normalize("zz:0c:29:ab:cd:ef")


class TestFormat(unittest.TestCase):
def test_styles(self):
mac = "000c29abcdef"
self.assertEqual(macvendor.format_mac(mac, "colon"), "00:0c:29:ab:cd:ef")
self.assertEqual(macvendor.format_mac(mac, "hyphen"), "00-0C-29-AB-CD-EF")
self.assertEqual(macvendor.format_mac(mac, "cisco"), "000c.29ab.cdef")
self.assertEqual(macvendor.format_mac(mac, "bare"), "000c29abcdef")

def test_unknown_style_rejected(self):
with self.assertRaises(ValueError):
macvendor.format_mac("000c29abcdef", "morse")


class TestFlagBits(unittest.TestCase):
"""First octet: bit 0 is multicast, bit 1 is locally administered."""

def test_universally_administered_unicast(self):
self.assertFalse(macvendor.is_locally_administered("00:0c:29:ab:cd:ef"))
self.assertFalse(macvendor.is_multicast("00:0c:29:ab:cd:ef"))

def test_locally_administered(self):
# 0x02 -> bit 1 set. Typical of randomised phone MACs.
self.assertTrue(macvendor.is_locally_administered("02:11:22:33:44:55"))
self.assertFalse(macvendor.is_multicast("02:11:22:33:44:55"))

def test_multicast(self):
# 01:00:5e:.. is the IPv4 multicast range.
self.assertTrue(macvendor.is_multicast("01:00:5e:00:00:01"))

def test_broadcast_is_both(self):
self.assertTrue(macvendor.is_multicast("ff:ff:ff:ff:ff:ff"))
self.assertTrue(macvendor.is_locally_administered("ff:ff:ff:ff:ff:ff"))


class TestLookup(unittest.TestCase):
def test_known_vendors(self):
self.assertEqual(macvendor.lookup("00:0c:29:11:22:33")["vendor"], "VMware, Inc.")
self.assertEqual(
macvendor.lookup("b8:27:eb:11:22:33")["vendor"],
"Raspberry Pi Foundation",
)

def test_unknown_prefix(self):
self.assertEqual(macvendor.lookup("aa:bb:cc:dd:ee:ff")["vendor"], "unknown")

def test_randomised_mac_is_annotated_rather_than_just_unknown(self):
row = macvendor.lookup("02:11:22:33:44:55")
self.assertTrue(row["locally_administered"])
self.assertIn("randomised", str(row["note"]))

def test_extra_table_overrides_builtin(self):
row = macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"})
self.assertEqual(row["vendor"], "Overridden")

def test_extra_table_does_not_mutate_builtin(self):
macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"})
self.assertEqual(macvendor.BUILTIN_OUI["000C29"], "VMware, Inc.")

def test_oui_is_uppercase_six_hex(self):
self.assertEqual(macvendor.oui("00:0c:29:ab:cd:ef"), "000C29")


class TestBuiltinTable(unittest.TestCase):
def test_every_key_is_well_formed(self):
bad = [k for k in macvendor.BUILTIN_OUI if not re.match(r"^[0-9A-F]{6}$", k)]
self.assertEqual(bad, [])

def test_no_empty_vendor_strings(self):
self.assertTrue(all(v.strip() for v in macvendor.BUILTIN_OUI.values()))


if __name__ == "__main__":
unittest.main()
"""Tests for MAC normalisation, flag bits and OUI lookup."""

import re
import unittest

from netprobe import macvendor


class TestNormalize(unittest.TestCase):
def test_every_common_notation_round_trips(self):
for text in (
"00:0c:29:ab:cd:ef",
"00-0C-29-AB-CD-EF",
"000c.29ab.cdef",
"000C29ABCDEF",
"00 0c 29 ab cd ef",
):
self.assertEqual(macvendor.normalize(text), "000c29abcdef", text)

def test_too_short_rejected(self):
with self.assertRaises(ValueError):
macvendor.normalize("00:0c:29:ab:cd")

def test_too_long_rejected(self):
with self.assertRaises(ValueError):
macvendor.normalize("00:0c:29:ab:cd:ef:11")

def test_non_hex_rejected(self):
with self.assertRaises(ValueError):
macvendor.normalize("zz:0c:29:ab:cd:ef")


class TestFormat(unittest.TestCase):
def test_styles(self):
mac = "000c29abcdef"
self.assertEqual(macvendor.format_mac(mac, "colon"), "00:0c:29:ab:cd:ef")
self.assertEqual(macvendor.format_mac(mac, "hyphen"), "00-0C-29-AB-CD-EF")
self.assertEqual(macvendor.format_mac(mac, "cisco"), "000c.29ab.cdef")
self.assertEqual(macvendor.format_mac(mac, "bare"), "000c29abcdef")

def test_unknown_style_rejected(self):
with self.assertRaises(ValueError):
macvendor.format_mac("000c29abcdef", "morse")


class TestFlagBits(unittest.TestCase):
"""First octet: bit 0 is multicast, bit 1 is locally administered."""

def test_universally_administered_unicast(self):
self.assertFalse(macvendor.is_locally_administered("00:0c:29:ab:cd:ef"))
self.assertFalse(macvendor.is_multicast("00:0c:29:ab:cd:ef"))

def test_locally_administered(self):
# 0x02 -> bit 1 set. Typical of randomised phone MACs.
self.assertTrue(macvendor.is_locally_administered("02:11:22:33:44:55"))
self.assertFalse(macvendor.is_multicast("02:11:22:33:44:55"))

def test_multicast(self):
# 01:00:5e:.. is the IPv4 multicast range.
self.assertTrue(macvendor.is_multicast("01:00:5e:00:00:01"))

def test_broadcast_is_both(self):
self.assertTrue(macvendor.is_multicast("ff:ff:ff:ff:ff:ff"))
self.assertTrue(macvendor.is_locally_administered("ff:ff:ff:ff:ff:ff"))


class TestLookup(unittest.TestCase):
def test_known_vendors(self):
self.assertEqual(
macvendor.lookup("00:0c:29:11:22:33")["vendor"], "VMware, Inc."
)
self.assertEqual(
macvendor.lookup("b8:27:eb:11:22:33")["vendor"],
"Raspberry Pi Foundation",
)

def test_unknown_prefix(self):
self.assertEqual(macvendor.lookup("aa:bb:cc:dd:ee:ff")["vendor"], "unknown")

def test_randomised_mac_is_annotated_rather_than_just_unknown(self):
row = macvendor.lookup("02:11:22:33:44:55")
self.assertTrue(row["locally_administered"])
self.assertIn("randomised", str(row["note"]))

def test_extra_table_overrides_builtin(self):
row = macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"})
self.assertEqual(row["vendor"], "Overridden")

def test_extra_table_does_not_mutate_builtin(self):
macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"})
self.assertEqual(macvendor.BUILTIN_OUI["000C29"], "VMware, Inc.")

def test_oui_is_uppercase_six_hex(self):
self.assertEqual(macvendor.oui("00:0c:29:ab:cd:ef"), "000C29")


class TestBuiltinTable(unittest.TestCase):
def test_every_key_is_well_formed(self):
bad = [k for k in macvendor.BUILTIN_OUI if not re.match(r"^[0-9A-F]{6}$", k)]
self.assertEqual(bad, [])

def test_no_empty_vendor_strings(self):
self.assertTrue(all(v.strip() for v in macvendor.BUILTIN_OUI.values()))


if __name__ == "__main__":
unittest.main()
Loading