diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 60a36bed..064f7823 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -4,17 +4,28 @@ import re import socket import subprocess +import sys import tempfile from pathlib import Path -from typing import Optional +from typing import Any, Optional +from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY from cycode.logger import get_logger logger = get_logger('HOST INFO') +pythoncom: Optional[Any] = None +win32com_client: Optional[Any] = None +if sys.platform == 'win32': + try: + import pythoncom + import win32com.client as win32com_client + except ImportError as e: + logger.debug('pywin32 is unavailable', exc_info=e) + _SUBPROCESS_TIMEOUT_SEC = 5 -_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' +_DEVICE_ID_CACHE_FILE_NAME = 'device-id' _PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} @@ -123,8 +134,7 @@ def _resolve_serial_number() -> Optional[str]: def _serial_number_cache_path() -> Path: - # The username suffix avoids collisions on OSes with a shared temp dir - return Path(tempfile.gettempdir()) / f'.cycode-device-serial-{getpass.getuser()}' + return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / _DEVICE_ID_CACHE_FILE_NAME def _read_serial_number_cache() -> Optional[str]: @@ -137,14 +147,13 @@ def _read_serial_number_cache() -> Optional[str]: def _write_serial_number_cache(serial: str) -> None: try: cache_path = _serial_number_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) - # The serial identifies the machine, and the temp dir is shared, so the cache is created - # readable by its owner alone (what mkstemp does) and moved into place atomically - a hook - # racing another one never reads a half-written cache, and the rename can't be redirected - # by a symlink planted at the destination the way an in-place write could. - file_descriptor, temp_path = tempfile.mkstemp( - dir=cache_path.parent, prefix=f'{_SERIAL_NUMBER_CACHE_FILE_NAME}.' - ) + # The serial identifies the machine, so the cache is created readable by its owner alone + # (what mkstemp does) and moved into place atomically - a hook racing another one never + # reads a half-written cache, and the rename can't be redirected by a symlink planted at + # the destination the way an in-place write could. + file_descriptor, temp_path = tempfile.mkstemp(dir=cache_path.parent, prefix=f'{_DEVICE_ID_CACHE_FILE_NAME}.') try: with os.fdopen(file_descriptor, 'w', encoding='utf-8') as temp_file: temp_file.write(serial) @@ -165,15 +174,17 @@ def _get_macos_serial_number() -> Optional[str]: def _get_windows_serial_number() -> Optional[str]: - import pythoncom # from pywin32 - import win32com.client # from pywin32 + """Read the OEM serial over WMI.""" + if pythoncom is None or win32com_client is None: + return None pythoncom.CoInitialize() try: - wmi_service = win32com.client.GetObject('winmgmts:') + wmi_service = win32com_client.GetObject('winmgmts:') for bios in wmi_service.InstancesOf('Win32_BIOS'): serial = bios.SerialNumber - return serial.strip() if serial else None + # whitespace-only is what whiteboxes and some hypervisors report + return serial.strip() or None if serial else None finally: pythoncom.CoUninitialize() return None diff --git a/images/cycode.ico b/images/cycode.ico new file mode 100644 index 00000000..7e912054 Binary files /dev/null and b/images/cycode.ico differ diff --git a/poetry.lock b/poetry.lock index 455c9eb5..927d1b2f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2278,4 +2278,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "29c8657164d291cce6b7f70b23ae4c69fb3eb153e6e7e45c0868b2aee246721d" +content-hash = "6e4e2f5667418c910955f532c5243b795d3631d6e21b6cffcd9d4f900f595758" diff --git a/pyinstaller.spec b/pyinstaller.spec index cf909167..715b5763 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -4,9 +4,12 @@ import os import platform +import re import subprocess import sys +_IS_WINDOWS = platform.system() == 'Windows' + _INIT_FILE_PATH = os.path.join('cycode', '__init__.py') _CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME') _ONEDIR_MODE = os.environ.get('CYCODE_ONEDIR_MODE') is not None @@ -47,6 +50,48 @@ _hiddenimports = [ if sys.version_info >= (3, 10): _hiddenimports += ['truststore', 'truststore._windows', 'truststore._macos', 'truststore._openssl'] + +def _build_windows_version_info(version: str): + """Windows-only VERSIONINFO resource.""" + from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, + ) + + numbers = [int(part) for part in re.match(r'\d+(?:\.\d+)*', version).group(0).split('.')] + filevers = tuple((numbers + [0, 0, 0, 0])[:4]) + + return VSVersionInfo( + ffi=FixedFileInfo(filevers=filevers, prodvers=filevers), + kids=[ + StringFileInfo( + [ + StringTable( + '040904B0', # US English, Unicode + [ + StringStruct('CompanyName', 'Cycode Ltd.'), + StringStruct('FileDescription', 'Cycode CLI'), + StringStruct('FileVersion', version), + StringStruct('InternalName', 'cycode-cli'), + StringStruct('OriginalFilename', 'cycode-cli.exe'), + StringStruct('ProductName', 'Cycode CLI'), + StringStruct('ProductVersion', version), + StringStruct('LegalCopyright', 'Copyright (c) Cycode Ltd.'), + StringStruct('Comments', 'MIT licensed. https://github.com/cycodehq/cycode-cli'), + ], + ) + ] + ), + VarFileInfo([VarStruct('Translation', [0x0409, 1200])]), + ], + ) + + a = Analysis( scripts=['cycode/cli/main.py'], excludes=['tests', 'setuptools', 'pkg_resources'], @@ -61,9 +106,7 @@ if platform.system() == 'Darwin': # wins the dedup, which breaks `import cryptography` at runtime. Drop every collected # libssl/libcrypto and inject Homebrew's, which satisfies both consumers. try: - openssl_lib = os.path.join( - subprocess.check_output(['brew', '--prefix', 'openssl@3'], text=True).strip(), 'lib' - ) + openssl_lib = os.path.join(subprocess.check_output(['brew', '--prefix', 'openssl@3'], text=True).strip(), 'lib') a.binaries = [b for b in a.binaries if 'libssl' not in b[0] and 'libcrypto' not in b[0]] for name in ('libssl.3.dylib', 'libcrypto.3.dylib'): a.binaries.append((name, os.path.join(openssl_lib, name), 'BINARY')) @@ -82,6 +125,8 @@ exe = EXE( target_arch=None, codesign_identity=_CODESIGN_IDENTITY, entitlements_file='entitlements.plist', + icon='images/cycode.ico' if _IS_WINDOWS else None, + version=_build_windows_version_info(CLI_VERSION) if _IS_WINDOWS else None, ) if _ONEDIR_MODE: diff --git a/pyproject.toml b/pyproject.toml index 2fbb0f36..7cc92481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ typer = "^0.15.3" tenacity = ">=9.1.2,<9.2.0" mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" } truststore = { version = ">=0.10.4,<0.11.0", markers = "python_version >= '3.10'" } +pywin32 = { version = ">=312,<313", markers = "python_version >= '3.10' and sys_platform == 'win32'"} pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" diff --git a/tests/cli/utils/__init__.py b/tests/cli/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/utils/test_host_info.py b/tests/cli/utils/test_host_info.py new file mode 100644 index 00000000..8d405517 --- /dev/null +++ b/tests/cli/utils/test_host_info.py @@ -0,0 +1,131 @@ +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import pytest + +from cycode.cli.utils import host_info + +_SERIAL = 'C02XY1234567' +_IOREG_OUTPUT = """ + +-o Root + "IOPlatformSerialNumber" = "C02XY1234567" +""" + + +@pytest.fixture(autouse=True) +def _home_in_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(Path, 'home', classmethod(lambda _cls: tmp_path)) + return tmp_path + + +def _cache_path(tmp_path: Path) -> Path: + return tmp_path / '.cycode' / 'device-id' + + +class _ComCalls: + def __init__(self) -> None: + self.initialized = 0 + self.uninitialized = 0 + + +def _install_fake_pywin32( + monkeypatch: pytest.MonkeyPatch, + serial: Optional[str] = _SERIAL, + get_object_error: Optional[Exception] = None, +) -> _ComCalls: + calls = _ComCalls() + + pythoncom = SimpleNamespace( + CoInitialize=lambda: setattr(calls, 'initialized', calls.initialized + 1), + CoUninitialize=lambda: setattr(calls, 'uninitialized', calls.uninitialized + 1), + ) + + class _Bios: + SerialNumber = serial + + class _WmiService: + def InstancesOf(self, class_name: str) -> list: # noqa: N802 - mirrors the COM API + assert class_name == 'Win32_BIOS' + return [_Bios()] + + def get_object(moniker: str) -> _WmiService: + assert moniker == 'winmgmts:' + if get_object_error is not None: + raise get_object_error + return _WmiService() + + win32com_client = SimpleNamespace(GetObject=get_object) + + # host_info imports pywin32 at module level (guarded by sys.platform), so patch the bound names + monkeypatch.setattr(host_info, 'pythoncom', pythoncom, raising=False) + monkeypatch.setattr(host_info, 'win32com_client', win32com_client, raising=False) + return calls + + +@pytest.fixture +def _windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(host_info.platform, 'system', lambda: 'Windows') + + +def test_cache_path_is_under_the_cycode_home_dir(tmp_path: Path) -> None: + assert host_info._serial_number_cache_path() == _cache_path(tmp_path) + + +def test_cached_value_short_circuits_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _cache_path(tmp_path).parent.mkdir(parents=True) + _cache_path(tmp_path).write_text('CACHED-ID', encoding='utf-8') + + def _fail() -> str: + raise AssertionError('must not resolve when the cache is warm') + + monkeypatch.setattr(host_info, '_resolve_serial_number', _fail) + + assert host_info.get_serial_number() == 'CACHED-ID' + + +@pytest.mark.usefixtures('_windows') +def test_windows_reads_bios_serial_over_wmi_and_caches_it(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls = _install_fake_pywin32(monkeypatch) + + assert host_info.get_serial_number() == _SERIAL + assert _cache_path(tmp_path).read_text(encoding='utf-8') == _SERIAL + assert (calls.initialized, calls.uninitialized) == (1, 1) + + +@pytest.mark.usefixtures('_windows') +def test_windows_uninitializes_com_when_wmi_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls = _install_fake_pywin32(monkeypatch, get_object_error=OSError('WMI is unavailable')) + + assert host_info.get_serial_number() is None + assert (calls.initialized, calls.uninitialized) == (1, 1) + assert not _cache_path(tmp_path).exists() + + +@pytest.mark.usefixtures('_windows') +def test_windows_blank_serial_is_none_and_not_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _install_fake_pywin32(monkeypatch, serial=' ') + + assert host_info.get_serial_number() is None + assert not _cache_path(tmp_path).exists() + + +@pytest.mark.usefixtures('_windows') +def test_windows_without_pywin32_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(host_info, 'pythoncom', None, raising=False) + monkeypatch.setattr(host_info, 'win32com_client', None, raising=False) + + assert host_info.get_serial_number() is None + + +def test_macos_serial_number_is_parsed_from_ioreg(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(host_info.platform, 'system', lambda: 'Darwin') + monkeypatch.setattr(host_info, '_run', lambda *_args, **_kwargs: _IOREG_OUTPUT) + + assert host_info.get_serial_number() == _SERIAL + + +def test_linux_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(host_info.platform, 'system', lambda: 'Linux') + + assert host_info.get_serial_number() is None