From dba9e5c39dd889eb2bb20711c50999786d38a702 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 7 Sep 2026 07:37:43 +0300 Subject: [PATCH 1/5] CM-72131 add windows resources information --- cycode/cli/utils/host_info.py | 64 ++++++++++------ images/cycode.ico | Bin 0 -> 121293 bytes pyinstaller.spec | 51 ++++++++++++- tests/cli/utils/__init__.py | 0 tests/cli/utils/test_host_info.py | 119 ++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 images/cycode.ico create mode 100644 tests/cli/utils/__init__.py create mode 100644 tests/cli/utils/test_host_info.py diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 60a36bed..18ea2e80 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -5,16 +5,19 @@ import socket import subprocess import tempfile +import uuid from pathlib import Path from typing import Optional +from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY from cycode.logger import get_logger logger = get_logger('HOST INFO') _SUBPROCESS_TIMEOUT_SEC = 5 -_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' +_DEVICE_ID_CACHE_FILE_NAME = 'device-id' +_LEGACY_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' _PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} @@ -98,13 +101,18 @@ def get_last_login_user() -> Optional[str]: def get_serial_number() -> Optional[str]: - # The serial is immutable hardware info, but resolving it shells out (ioreg/WMI) + # The serial is immutable host info, but resolving it costs a syscall chain (ioreg/registry) # and this runs in a fresh process per AI hook event - cache it on disk. cached = _read_serial_number_cache() if cached: return cached serial = _resolve_serial_number() + if not serial and platform.system() == 'Windows': + # MachineGuid is missing only on stripped/preinstallation images. Mint our own stable id + # rather than reporting no device at all; the cache write below makes it persistent. + serial = str(uuid.uuid4()) + if serial: _write_serial_number_cache(serial) return serial @@ -116,15 +124,22 @@ def _resolve_serial_number() -> Optional[str]: if system == 'Darwin': return _get_macos_serial_number() if system == 'Windows': - return _get_windows_serial_number() + return _get_windows_machine_guid() except Exception as e: logger.debug('Failed to resolve serial number', exc_info=e) return None 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 _remove_legacy_serial_number_cache() -> None: + try: + legacy_path = Path(tempfile.gettempdir()) / f'{_LEGACY_SERIAL_NUMBER_CACHE_FILE_NAME}-{getpass.getuser()}' + legacy_path.unlink(missing_ok=True) + except Exception as e: + logger.debug('Failed to remove legacy serial number cache', exc_info=e) def _read_serial_number_cache() -> Optional[str]: @@ -137,18 +152,18 @@ 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) os.replace(temp_path, cache_path) + _remove_legacy_serial_number_cache() except Exception: Path(temp_path).unlink(missing_ok=True) raise @@ -164,16 +179,17 @@ def _get_macos_serial_number() -> Optional[str]: return match.group(1) if match else None -def _get_windows_serial_number() -> Optional[str]: - import pythoncom # from pywin32 - import win32com.client # from pywin32 +def _get_windows_machine_guid() -> Optional[str]: + """Read the per-installation machine GUID from the registry. + """ + import winreg # Windows-only stdlib module - pythoncom.CoInitialize() - try: - wmi_service = win32com.client.GetObject('winmgmts:') - for bios in wmi_service.InstancesOf('Win32_BIOS'): - serial = bios.SerialNumber - return serial.strip() if serial else None - finally: - pythoncom.CoUninitialize() - return None + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r'SOFTWARE\Microsoft\Cryptography', + 0, + winreg.KEY_READ | winreg.KEY_WOW64_64KEY, + ) as key: + machine_guid, _ = winreg.QueryValueEx(key, 'MachineGuid') + + return machine_guid.strip() if machine_guid else None diff --git a/images/cycode.ico b/images/cycode.ico new file mode 100644 index 0000000000000000000000000000000000000000..7e91205402e07a48b254e5d6c29e8b28768a895d GIT binary patch literal 121293 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 (3, 10): _hiddenimports += ['truststore', 'truststore._windows', 'truststore._macos', 'truststore._openssl'] +_IS_WINDOWS = platform.system() == 'Windows' + + +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/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..e1f26ac7 --- /dev/null +++ b/tests/cli/utils/test_host_info.py @@ -0,0 +1,119 @@ +import sys +import types +from pathlib import Path +from typing import Optional + +import pytest + +from cycode.cli.utils import host_info + +_MACHINE_GUID = 'e0e8e0a1-2222-4c3f-9d4a-000000000001' +_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 _fake_winreg(machine_guid: Optional[str] = _MACHINE_GUID) -> types.ModuleType: + module = types.ModuleType('winreg') + module.HKEY_LOCAL_MACHINE = 0 + module.KEY_READ = 0x20019 + module.KEY_WOW64_64KEY = 0x0100 + + class _Key: + def __enter__(self) -> '_Key': # noqa: PYI034 + return self + + def __exit__(self, *_args: object) -> None: + return None + + def open_key(_hive: int, sub_key: str, _reserved: int, access: int) -> _Key: + assert sub_key == r'SOFTWARE\Microsoft\Cryptography' + assert access & module.KEY_WOW64_64KEY # must read the 64-bit view, not WOW6432Node + return _Key() + + def query_value_ex(_key: '_Key', name: str) -> tuple: + assert name == 'MachineGuid' + if machine_guid is None: + raise FileNotFoundError(name) + return machine_guid, 1 + + module.OpenKey = open_key + module.QueryValueEx = query_value_ex + return module + + +@pytest.fixture +def _windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(host_info.platform, 'system', lambda: 'Windows') + + +def test_cache_path_is_under_cycode_home_and_not_temp(tmp_path: Path) -> None: + cache_path = host_info._serial_number_cache_path() + assert cache_path == tmp_path / '.cycode' / 'device-id' + + +def test_cached_value_short_circuits_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + cache_path = tmp_path / '.cycode' / 'device-id' + cache_path.parent.mkdir(parents=True) + cache_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_machine_guid_and_caches_it(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setitem(sys.modules, 'winreg', _fake_winreg()) + + assert host_info.get_serial_number() == _MACHINE_GUID + assert (tmp_path / '.cycode' / 'device-id').read_text(encoding='utf-8') == _MACHINE_GUID + + +@pytest.mark.usefixtures('_windows') +def test_windows_falls_back_to_generated_uuid_and_reuses_it(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, 'winreg', _fake_winreg(machine_guid=None)) + + first = host_info.get_serial_number() + second = host_info.get_serial_number() + + assert first is not None + assert first == second # persisted, so the id is stable across processes + + +@pytest.mark.usefixtures('_windows') +def test_windows_write_removes_legacy_temp_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + legacy_dir = tmp_path / 'temp' + legacy_dir.mkdir() + monkeypatch.setattr(host_info.tempfile, 'gettempdir', lambda: str(legacy_dir)) + monkeypatch.setattr(host_info.getpass, 'getuser', lambda: 'tester') + legacy_path = legacy_dir / '.cycode-device-serial-tester' + legacy_path.write_text('OLD-BIOS-SERIAL', encoding='utf-8') + + monkeypatch.setitem(sys.modules, 'winreg', _fake_winreg()) + + assert host_info.get_serial_number() == _MACHINE_GUID + assert not legacy_path.exists() + + +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() == 'C02XY1234567' + + +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 From b3180292f54714cd9deb4b09ead472f4fe8ee038 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 7 Sep 2026 07:40:21 +0300 Subject: [PATCH 2/5] CM-72131 formatting --- cycode/cli/utils/host_info.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 18ea2e80..14cc2012 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -180,8 +180,7 @@ def _get_macos_serial_number() -> Optional[str]: def _get_windows_machine_guid() -> Optional[str]: - """Read the per-installation machine GUID from the registry. - """ + """Read the per-installation machine GUID from the registry.""" import winreg # Windows-only stdlib module with winreg.OpenKey( From ee53d4eb5951e049355c2e26ebac644dbd9b9285 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 7 Sep 2026 08:30:50 +0300 Subject: [PATCH 3/5] CM-72131 returned wmi call with eager declaration --- cycode/cli/utils/host_info.py | 48 ++++++++------ poetry.lock | 2 +- pyinstaller.spec | 4 +- pyproject.toml | 1 + tests/cli/utils/test_host_info.py | 106 ++++++++++++++++++------------ 5 files changed, 96 insertions(+), 65 deletions(-) diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 14cc2012..cff8ff58 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -4,16 +4,25 @@ import re import socket import subprocess +import sys import tempfile -import uuid 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 _DEVICE_ID_CACHE_FILE_NAME = 'device-id' @@ -101,18 +110,13 @@ def get_last_login_user() -> Optional[str]: def get_serial_number() -> Optional[str]: - # The serial is immutable host info, but resolving it costs a syscall chain (ioreg/registry) + # The serial is immutable hardware info, but resolving it shells out (ioreg/WMI) # and this runs in a fresh process per AI hook event - cache it on disk. cached = _read_serial_number_cache() if cached: return cached serial = _resolve_serial_number() - if not serial and platform.system() == 'Windows': - # MachineGuid is missing only on stripped/preinstallation images. Mint our own stable id - # rather than reporting no device at all; the cache write below makes it persistent. - serial = str(uuid.uuid4()) - if serial: _write_serial_number_cache(serial) return serial @@ -124,7 +128,7 @@ def _resolve_serial_number() -> Optional[str]: if system == 'Darwin': return _get_macos_serial_number() if system == 'Windows': - return _get_windows_machine_guid() + return _get_windows_serial_number() except Exception as e: logger.debug('Failed to resolve serial number', exc_info=e) return None @@ -179,16 +183,18 @@ def _get_macos_serial_number() -> Optional[str]: return match.group(1) if match else None -def _get_windows_machine_guid() -> Optional[str]: - """Read the per-installation machine GUID from the registry.""" - import winreg # Windows-only stdlib module - - with winreg.OpenKey( - winreg.HKEY_LOCAL_MACHINE, - r'SOFTWARE\Microsoft\Cryptography', - 0, - winreg.KEY_READ | winreg.KEY_WOW64_64KEY, - ) as key: - machine_guid, _ = winreg.QueryValueEx(key, 'MachineGuid') +def _get_windows_serial_number() -> Optional[str]: + """Read the OEM serial over WMI.""" + if pythoncom is None or win32com_client is None: + return None - return machine_guid.strip() if machine_guid else None + pythoncom.CoInitialize() + try: + wmi_service = win32com_client.GetObject('winmgmts:') + for bios in wmi_service.InstancesOf('Win32_BIOS'): + serial = bios.SerialNumber + # 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/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 555c7c92..715b5763 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -8,6 +8,8 @@ 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 @@ -48,8 +50,6 @@ _hiddenimports = [ if sys.version_info >= (3, 10): _hiddenimports += ['truststore', 'truststore._windows', 'truststore._macos', 'truststore._openssl'] -_IS_WINDOWS = platform.system() == 'Windows' - def _build_windows_version_info(version: str): """Windows-only VERSIONINFO resource.""" 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/test_host_info.py b/tests/cli/utils/test_host_info.py index e1f26ac7..3dd1ff0d 100644 --- a/tests/cli/utils/test_host_info.py +++ b/tests/cli/utils/test_host_info.py @@ -1,13 +1,12 @@ -import sys -import types from pathlib import Path +from types import SimpleNamespace from typing import Optional import pytest from cycode.cli.utils import host_info -_MACHINE_GUID = 'e0e8e0a1-2222-4c3f-9d4a-000000000001' +_SERIAL = 'C02XY1234567' _IOREG_OUTPUT = """ +-o Root "IOPlatformSerialNumber" = "C02XY1234567" @@ -20,33 +19,44 @@ def _home_in_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return tmp_path -def _fake_winreg(machine_guid: Optional[str] = _MACHINE_GUID) -> types.ModuleType: - module = types.ModuleType('winreg') - module.HKEY_LOCAL_MACHINE = 0 - module.KEY_READ = 0x20019 - module.KEY_WOW64_64KEY = 0x0100 +class _ComCalls: + def __init__(self) -> None: + self.initialized = 0 + self.uninitialized = 0 - class _Key: - def __enter__(self) -> '_Key': # noqa: PYI034 - return self - def __exit__(self, *_args: object) -> None: - return None +def _install_fake_pywin32( + monkeypatch: pytest.MonkeyPatch, + serial: Optional[str] = _SERIAL, + get_object_error: Optional[Exception] = None, +) -> _ComCalls: + calls = _ComCalls() - def open_key(_hive: int, sub_key: str, _reserved: int, access: int) -> _Key: - assert sub_key == r'SOFTWARE\Microsoft\Cryptography' - assert access & module.KEY_WOW64_64KEY # must read the 64-bit view, not WOW6432Node - return _Key() + pythoncom = SimpleNamespace( + CoInitialize=lambda: setattr(calls, 'initialized', calls.initialized + 1), + CoUninitialize=lambda: setattr(calls, 'uninitialized', calls.uninitialized + 1), + ) - def query_value_ex(_key: '_Key', name: str) -> tuple: - assert name == 'MachineGuid' - if machine_guid is None: - raise FileNotFoundError(name) - return machine_guid, 1 + class _Bios: + SerialNumber = serial - module.OpenKey = open_key - module.QueryValueEx = query_value_ex - return module + 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 @@ -55,8 +65,7 @@ def _windows(monkeypatch: pytest.MonkeyPatch) -> None: def test_cache_path_is_under_cycode_home_and_not_temp(tmp_path: Path) -> None: - cache_path = host_info._serial_number_cache_path() - assert cache_path == tmp_path / '.cycode' / 'device-id' + assert host_info._serial_number_cache_path() == tmp_path / '.cycode' / 'device-id' def test_cached_value_short_circuits_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -73,22 +82,37 @@ def _fail() -> str: @pytest.mark.usefixtures('_windows') -def test_windows_reads_machine_guid_and_caches_it(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setitem(sys.modules, 'winreg', _fake_winreg()) +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() == _MACHINE_GUID - assert (tmp_path / '.cycode' / 'device-id').read_text(encoding='utf-8') == _MACHINE_GUID + assert host_info.get_serial_number() == _SERIAL + assert (tmp_path / '.cycode' / 'device-id').read_text(encoding='utf-8') == _SERIAL + assert (calls.initialized, calls.uninitialized) == (1, 1) @pytest.mark.usefixtures('_windows') -def test_windows_falls_back_to_generated_uuid_and_reuses_it(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setitem(sys.modules, 'winreg', _fake_winreg(machine_guid=None)) +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')) - first = host_info.get_serial_number() - second = host_info.get_serial_number() + assert host_info.get_serial_number() is None + assert (calls.initialized, calls.uninitialized) == (1, 1) + assert not (tmp_path / '.cycode' / 'device-id').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 first is not None - assert first == second # persisted, so the id is stable across processes + assert host_info.get_serial_number() is None + assert not (tmp_path / '.cycode' / 'device-id').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 @pytest.mark.usefixtures('_windows') @@ -98,11 +122,11 @@ def test_windows_write_removes_legacy_temp_cache(monkeypatch: pytest.MonkeyPatch monkeypatch.setattr(host_info.tempfile, 'gettempdir', lambda: str(legacy_dir)) monkeypatch.setattr(host_info.getpass, 'getuser', lambda: 'tester') legacy_path = legacy_dir / '.cycode-device-serial-tester' - legacy_path.write_text('OLD-BIOS-SERIAL', encoding='utf-8') + legacy_path.write_text('OLD-CACHED-SERIAL', encoding='utf-8') - monkeypatch.setitem(sys.modules, 'winreg', _fake_winreg()) + _install_fake_pywin32(monkeypatch) - assert host_info.get_serial_number() == _MACHINE_GUID + assert host_info.get_serial_number() == _SERIAL assert not legacy_path.exists() @@ -110,7 +134,7 @@ def test_macos_serial_number_is_parsed_from_ioreg(monkeypatch: pytest.MonkeyPatc monkeypatch.setattr(host_info.platform, 'system', lambda: 'Darwin') monkeypatch.setattr(host_info, '_run', lambda *_args, **_kwargs: _IOREG_OUTPUT) - assert host_info.get_serial_number() == 'C02XY1234567' + assert host_info.get_serial_number() == _SERIAL def test_linux_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: From 0f9b23672c4691b7adb8ae6d2f93365ef4277222 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 7 Sep 2026 09:47:20 +0300 Subject: [PATCH 4/5] CM-72131 temp revert of cached dir location --- cycode/cli/utils/host_info.py | 29 ++++++++--------------- tests/cli/utils/test_host_info.py | 38 +++++++++++-------------------- 2 files changed, 23 insertions(+), 44 deletions(-) diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index cff8ff58..2b5c6f80 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Any, Optional -from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY from cycode.logger import get_logger logger = get_logger('HOST INFO') @@ -25,8 +24,7 @@ _SUBPROCESS_TIMEOUT_SEC = 5 -_DEVICE_ID_CACHE_FILE_NAME = 'device-id' -_LEGACY_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' +_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' _PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} @@ -135,15 +133,8 @@ def _resolve_serial_number() -> Optional[str]: def _serial_number_cache_path() -> Path: - return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / _DEVICE_ID_CACHE_FILE_NAME - - -def _remove_legacy_serial_number_cache() -> None: - try: - legacy_path = Path(tempfile.gettempdir()) / f'{_LEGACY_SERIAL_NUMBER_CACHE_FILE_NAME}-{getpass.getuser()}' - legacy_path.unlink(missing_ok=True) - except Exception as e: - logger.debug('Failed to remove legacy serial number cache', exc_info=e) + # The username suffix avoids collisions on OSes with a shared temp dir + return Path(tempfile.gettempdir()) / f'{_SERIAL_NUMBER_CACHE_FILE_NAME}-{getpass.getuser()}' def _read_serial_number_cache() -> Optional[str]: @@ -156,18 +147,18 @@ 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, 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}.') + # 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}.' + ) try: with os.fdopen(file_descriptor, 'w', encoding='utf-8') as temp_file: temp_file.write(serial) os.replace(temp_path, cache_path) - _remove_legacy_serial_number_cache() except Exception: Path(temp_path).unlink(missing_ok=True) raise diff --git a/tests/cli/utils/test_host_info.py b/tests/cli/utils/test_host_info.py index 3dd1ff0d..579a1acf 100644 --- a/tests/cli/utils/test_host_info.py +++ b/tests/cli/utils/test_host_info.py @@ -14,11 +14,16 @@ @pytest.fixture(autouse=True) -def _home_in_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setattr(Path, 'home', classmethod(lambda _cls: tmp_path)) +def _temp_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(host_info.tempfile, 'gettempdir', lambda: str(tmp_path)) + monkeypatch.setattr(host_info.getpass, 'getuser', lambda: 'tester') return tmp_path +def _cache_path(tmp_path: Path) -> Path: + return tmp_path / '.cycode-device-serial-tester' + + class _ComCalls: def __init__(self) -> None: self.initialized = 0 @@ -64,14 +69,12 @@ def _windows(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(host_info.platform, 'system', lambda: 'Windows') -def test_cache_path_is_under_cycode_home_and_not_temp(tmp_path: Path) -> None: - assert host_info._serial_number_cache_path() == tmp_path / '.cycode' / 'device-id' +def test_cache_path_is_per_user_in_the_temp_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 / '.cycode' / 'device-id' - cache_path.parent.mkdir(parents=True) - cache_path.write_text('CACHED-ID', encoding='utf-8') + _cache_path(tmp_path).write_text('CACHED-ID', encoding='utf-8') def _fail() -> str: raise AssertionError('must not resolve when the cache is warm') @@ -86,7 +89,7 @@ def test_windows_reads_bios_serial_over_wmi_and_caches_it(monkeypatch: pytest.Mo calls = _install_fake_pywin32(monkeypatch) assert host_info.get_serial_number() == _SERIAL - assert (tmp_path / '.cycode' / 'device-id').read_text(encoding='utf-8') == _SERIAL + assert _cache_path(tmp_path).read_text(encoding='utf-8') == _SERIAL assert (calls.initialized, calls.uninitialized) == (1, 1) @@ -96,7 +99,7 @@ def test_windows_uninitializes_com_when_wmi_fails(monkeypatch: pytest.MonkeyPatc assert host_info.get_serial_number() is None assert (calls.initialized, calls.uninitialized) == (1, 1) - assert not (tmp_path / '.cycode' / 'device-id').exists() + assert not _cache_path(tmp_path).exists() @pytest.mark.usefixtures('_windows') @@ -104,7 +107,7 @@ def test_windows_blank_serial_is_none_and_not_cached(monkeypatch: pytest.MonkeyP _install_fake_pywin32(monkeypatch, serial=' ') assert host_info.get_serial_number() is None - assert not (tmp_path / '.cycode' / 'device-id').exists() + assert not _cache_path(tmp_path).exists() @pytest.mark.usefixtures('_windows') @@ -115,21 +118,6 @@ def test_windows_without_pywin32_returns_none(monkeypatch: pytest.MonkeyPatch) - assert host_info.get_serial_number() is None -@pytest.mark.usefixtures('_windows') -def test_windows_write_removes_legacy_temp_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - legacy_dir = tmp_path / 'temp' - legacy_dir.mkdir() - monkeypatch.setattr(host_info.tempfile, 'gettempdir', lambda: str(legacy_dir)) - monkeypatch.setattr(host_info.getpass, 'getuser', lambda: 'tester') - legacy_path = legacy_dir / '.cycode-device-serial-tester' - legacy_path.write_text('OLD-CACHED-SERIAL', encoding='utf-8') - - _install_fake_pywin32(monkeypatch) - - assert host_info.get_serial_number() == _SERIAL - assert not legacy_path.exists() - - 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) From 733566604bdac90ce9ddf1c04755297de4663272 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 7 Sep 2026 10:19:24 +0300 Subject: [PATCH 5/5] CM-72131 returned the change of cache dir location --- cycode/cli/utils/host_info.py | 19 +++++++++---------- tests/cli/utils/test_host_info.py | 10 +++++----- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 2b5c6f80..064f7823 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any, Optional +from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY from cycode.logger import get_logger logger = get_logger('HOST INFO') @@ -24,7 +25,7 @@ _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'} @@ -133,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'{_SERIAL_NUMBER_CACHE_FILE_NAME}-{getpass.getuser()}' + return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / _DEVICE_ID_CACHE_FILE_NAME def _read_serial_number_cache() -> Optional[str]: @@ -147,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) diff --git a/tests/cli/utils/test_host_info.py b/tests/cli/utils/test_host_info.py index 579a1acf..8d405517 100644 --- a/tests/cli/utils/test_host_info.py +++ b/tests/cli/utils/test_host_info.py @@ -14,14 +14,13 @@ @pytest.fixture(autouse=True) -def _temp_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setattr(host_info.tempfile, 'gettempdir', lambda: str(tmp_path)) - monkeypatch.setattr(host_info.getpass, 'getuser', lambda: 'tester') +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-serial-tester' + return tmp_path / '.cycode' / 'device-id' class _ComCalls: @@ -69,11 +68,12 @@ def _windows(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(host_info.platform, 'system', lambda: 'Windows') -def test_cache_path_is_per_user_in_the_temp_dir(tmp_path: Path) -> None: +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: