Skip to content

Commit 7004a26

Browse files
authored
CM-72131 add windows resources information (#540)
1 parent 0bd39d2 commit 7004a26

7 files changed

Lines changed: 207 additions & 19 deletions

File tree

cycode/cli/utils/host_info.py

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,28 @@
44
import re
55
import socket
66
import subprocess
7+
import sys
78
import tempfile
89
from pathlib import Path
9-
from typing import Optional
10+
from typing import Any, Optional
1011

12+
from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY
1113
from cycode.logger import get_logger
1214

1315
logger = get_logger('HOST INFO')
1416

17+
pythoncom: Optional[Any] = None
18+
win32com_client: Optional[Any] = None
19+
if sys.platform == 'win32':
20+
try:
21+
import pythoncom
22+
import win32com.client as win32com_client
23+
except ImportError as e:
24+
logger.debug('pywin32 is unavailable', exc_info=e)
25+
1526
_SUBPROCESS_TIMEOUT_SEC = 5
1627

17-
_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial'
28+
_DEVICE_ID_CACHE_FILE_NAME = 'device-id'
1829

1930
_PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'}
2031

@@ -123,8 +134,7 @@ def _resolve_serial_number() -> Optional[str]:
123134

124135

125136
def _serial_number_cache_path() -> Path:
126-
# The username suffix avoids collisions on OSes with a shared temp dir
127-
return Path(tempfile.gettempdir()) / f'.cycode-device-serial-{getpass.getuser()}'
137+
return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / _DEVICE_ID_CACHE_FILE_NAME
128138

129139

130140
def _read_serial_number_cache() -> Optional[str]:
@@ -137,14 +147,13 @@ def _read_serial_number_cache() -> Optional[str]:
137147
def _write_serial_number_cache(serial: str) -> None:
138148
try:
139149
cache_path = _serial_number_cache_path()
150+
cache_path.parent.mkdir(parents=True, exist_ok=True)
140151

141-
# The serial identifies the machine, and the temp dir is shared, so the cache is created
142-
# readable by its owner alone (what mkstemp does) and moved into place atomically - a hook
143-
# racing another one never reads a half-written cache, and the rename can't be redirected
144-
# by a symlink planted at the destination the way an in-place write could.
145-
file_descriptor, temp_path = tempfile.mkstemp(
146-
dir=cache_path.parent, prefix=f'{_SERIAL_NUMBER_CACHE_FILE_NAME}.'
147-
)
152+
# The serial identifies the machine, so the cache is created readable by its owner alone
153+
# (what mkstemp does) and moved into place atomically - a hook racing another one never
154+
# reads a half-written cache, and the rename can't be redirected by a symlink planted at
155+
# the destination the way an in-place write could.
156+
file_descriptor, temp_path = tempfile.mkstemp(dir=cache_path.parent, prefix=f'{_DEVICE_ID_CACHE_FILE_NAME}.')
148157
try:
149158
with os.fdopen(file_descriptor, 'w', encoding='utf-8') as temp_file:
150159
temp_file.write(serial)
@@ -165,15 +174,17 @@ def _get_macos_serial_number() -> Optional[str]:
165174

166175

167176
def _get_windows_serial_number() -> Optional[str]:
168-
import pythoncom # from pywin32
169-
import win32com.client # from pywin32
177+
"""Read the OEM serial over WMI."""
178+
if pythoncom is None or win32com_client is None:
179+
return None
170180

171181
pythoncom.CoInitialize()
172182
try:
173-
wmi_service = win32com.client.GetObject('winmgmts:')
183+
wmi_service = win32com_client.GetObject('winmgmts:')
174184
for bios in wmi_service.InstancesOf('Win32_BIOS'):
175185
serial = bios.SerialNumber
176-
return serial.strip() if serial else None
186+
# whitespace-only is what whiteboxes and some hypervisors report
187+
return serial.strip() or None if serial else None
177188
finally:
178189
pythoncom.CoUninitialize()
179190
return None

images/cycode.ico

118 KB
Binary file not shown.

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyinstaller.spec

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
import os
66
import platform
7+
import re
78
import subprocess
89
import sys
910

11+
_IS_WINDOWS = platform.system() == 'Windows'
12+
1013
_INIT_FILE_PATH = os.path.join('cycode', '__init__.py')
1114
_CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME')
1215
_ONEDIR_MODE = os.environ.get('CYCODE_ONEDIR_MODE') is not None
@@ -47,6 +50,48 @@ _hiddenimports = [
4750
if sys.version_info >= (3, 10):
4851
_hiddenimports += ['truststore', 'truststore._windows', 'truststore._macos', 'truststore._openssl']
4952

53+
54+
def _build_windows_version_info(version: str):
55+
"""Windows-only VERSIONINFO resource."""
56+
from PyInstaller.utils.win32.versioninfo import (
57+
FixedFileInfo,
58+
StringFileInfo,
59+
StringStruct,
60+
StringTable,
61+
VarFileInfo,
62+
VarStruct,
63+
VSVersionInfo,
64+
)
65+
66+
numbers = [int(part) for part in re.match(r'\d+(?:\.\d+)*', version).group(0).split('.')]
67+
filevers = tuple((numbers + [0, 0, 0, 0])[:4])
68+
69+
return VSVersionInfo(
70+
ffi=FixedFileInfo(filevers=filevers, prodvers=filevers),
71+
kids=[
72+
StringFileInfo(
73+
[
74+
StringTable(
75+
'040904B0', # US English, Unicode
76+
[
77+
StringStruct('CompanyName', 'Cycode Ltd.'),
78+
StringStruct('FileDescription', 'Cycode CLI'),
79+
StringStruct('FileVersion', version),
80+
StringStruct('InternalName', 'cycode-cli'),
81+
StringStruct('OriginalFilename', 'cycode-cli.exe'),
82+
StringStruct('ProductName', 'Cycode CLI'),
83+
StringStruct('ProductVersion', version),
84+
StringStruct('LegalCopyright', 'Copyright (c) Cycode Ltd.'),
85+
StringStruct('Comments', 'MIT licensed. https://github.com/cycodehq/cycode-cli'),
86+
],
87+
)
88+
]
89+
),
90+
VarFileInfo([VarStruct('Translation', [0x0409, 1200])]),
91+
],
92+
)
93+
94+
5095
a = Analysis(
5196
scripts=['cycode/cli/main.py'],
5297
excludes=['tests', 'setuptools', 'pkg_resources'],
@@ -61,9 +106,7 @@ if platform.system() == 'Darwin':
61106
# wins the dedup, which breaks `import cryptography` at runtime. Drop every collected
62107
# libssl/libcrypto and inject Homebrew's, which satisfies both consumers.
63108
try:
64-
openssl_lib = os.path.join(
65-
subprocess.check_output(['brew', '--prefix', 'openssl@3'], text=True).strip(), 'lib'
66-
)
109+
openssl_lib = os.path.join(subprocess.check_output(['brew', '--prefix', 'openssl@3'], text=True).strip(), 'lib')
67110
a.binaries = [b for b in a.binaries if 'libssl' not in b[0] and 'libcrypto' not in b[0]]
68111
for name in ('libssl.3.dylib', 'libcrypto.3.dylib'):
69112
a.binaries.append((name, os.path.join(openssl_lib, name), 'BINARY'))
@@ -82,6 +125,8 @@ exe = EXE(
82125
target_arch=None,
83126
codesign_identity=_CODESIGN_IDENTITY,
84127
entitlements_file='entitlements.plist',
128+
icon='images/cycode.ico' if _IS_WINDOWS else None,
129+
version=_build_windows_version_info(CLI_VERSION) if _IS_WINDOWS else None,
85130
)
86131

87132
if _ONEDIR_MODE:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ typer = "^0.15.3"
5050
tenacity = ">=9.1.2,<9.2.0"
5151
mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" }
5252
truststore = { version = ">=0.10.4,<0.11.0", markers = "python_version >= '3.10'" }
53+
pywin32 = { version = ">=312,<313", markers = "python_version >= '3.10' and sys_platform == 'win32'"}
5354
pydantic = ">=2.11.5,<3.0.0"
5455
pathvalidate = ">=3.3.1,<4.0.0"
5556
tomli-w = ">=1.0.0,<2.0.0"

tests/cli/utils/__init__.py

Whitespace-only changes.

tests/cli/utils/test_host_info.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from pathlib import Path
2+
from types import SimpleNamespace
3+
from typing import Optional
4+
5+
import pytest
6+
7+
from cycode.cli.utils import host_info
8+
9+
_SERIAL = 'C02XY1234567'
10+
_IOREG_OUTPUT = """
11+
+-o Root <class IORegistryEntry, id 1, retain 42>
12+
"IOPlatformSerialNumber" = "C02XY1234567"
13+
"""
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def _home_in_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
18+
monkeypatch.setattr(Path, 'home', classmethod(lambda _cls: tmp_path))
19+
return tmp_path
20+
21+
22+
def _cache_path(tmp_path: Path) -> Path:
23+
return tmp_path / '.cycode' / 'device-id'
24+
25+
26+
class _ComCalls:
27+
def __init__(self) -> None:
28+
self.initialized = 0
29+
self.uninitialized = 0
30+
31+
32+
def _install_fake_pywin32(
33+
monkeypatch: pytest.MonkeyPatch,
34+
serial: Optional[str] = _SERIAL,
35+
get_object_error: Optional[Exception] = None,
36+
) -> _ComCalls:
37+
calls = _ComCalls()
38+
39+
pythoncom = SimpleNamespace(
40+
CoInitialize=lambda: setattr(calls, 'initialized', calls.initialized + 1),
41+
CoUninitialize=lambda: setattr(calls, 'uninitialized', calls.uninitialized + 1),
42+
)
43+
44+
class _Bios:
45+
SerialNumber = serial
46+
47+
class _WmiService:
48+
def InstancesOf(self, class_name: str) -> list: # noqa: N802 - mirrors the COM API
49+
assert class_name == 'Win32_BIOS'
50+
return [_Bios()]
51+
52+
def get_object(moniker: str) -> _WmiService:
53+
assert moniker == 'winmgmts:'
54+
if get_object_error is not None:
55+
raise get_object_error
56+
return _WmiService()
57+
58+
win32com_client = SimpleNamespace(GetObject=get_object)
59+
60+
# host_info imports pywin32 at module level (guarded by sys.platform), so patch the bound names
61+
monkeypatch.setattr(host_info, 'pythoncom', pythoncom, raising=False)
62+
monkeypatch.setattr(host_info, 'win32com_client', win32com_client, raising=False)
63+
return calls
64+
65+
66+
@pytest.fixture
67+
def _windows(monkeypatch: pytest.MonkeyPatch) -> None:
68+
monkeypatch.setattr(host_info.platform, 'system', lambda: 'Windows')
69+
70+
71+
def test_cache_path_is_under_the_cycode_home_dir(tmp_path: Path) -> None:
72+
assert host_info._serial_number_cache_path() == _cache_path(tmp_path)
73+
74+
75+
def test_cached_value_short_circuits_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
76+
_cache_path(tmp_path).parent.mkdir(parents=True)
77+
_cache_path(tmp_path).write_text('CACHED-ID', encoding='utf-8')
78+
79+
def _fail() -> str:
80+
raise AssertionError('must not resolve when the cache is warm')
81+
82+
monkeypatch.setattr(host_info, '_resolve_serial_number', _fail)
83+
84+
assert host_info.get_serial_number() == 'CACHED-ID'
85+
86+
87+
@pytest.mark.usefixtures('_windows')
88+
def test_windows_reads_bios_serial_over_wmi_and_caches_it(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
89+
calls = _install_fake_pywin32(monkeypatch)
90+
91+
assert host_info.get_serial_number() == _SERIAL
92+
assert _cache_path(tmp_path).read_text(encoding='utf-8') == _SERIAL
93+
assert (calls.initialized, calls.uninitialized) == (1, 1)
94+
95+
96+
@pytest.mark.usefixtures('_windows')
97+
def test_windows_uninitializes_com_when_wmi_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
98+
calls = _install_fake_pywin32(monkeypatch, get_object_error=OSError('WMI is unavailable'))
99+
100+
assert host_info.get_serial_number() is None
101+
assert (calls.initialized, calls.uninitialized) == (1, 1)
102+
assert not _cache_path(tmp_path).exists()
103+
104+
105+
@pytest.mark.usefixtures('_windows')
106+
def test_windows_blank_serial_is_none_and_not_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
107+
_install_fake_pywin32(monkeypatch, serial=' ')
108+
109+
assert host_info.get_serial_number() is None
110+
assert not _cache_path(tmp_path).exists()
111+
112+
113+
@pytest.mark.usefixtures('_windows')
114+
def test_windows_without_pywin32_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
115+
monkeypatch.setattr(host_info, 'pythoncom', None, raising=False)
116+
monkeypatch.setattr(host_info, 'win32com_client', None, raising=False)
117+
118+
assert host_info.get_serial_number() is None
119+
120+
121+
def test_macos_serial_number_is_parsed_from_ioreg(monkeypatch: pytest.MonkeyPatch) -> None:
122+
monkeypatch.setattr(host_info.platform, 'system', lambda: 'Darwin')
123+
monkeypatch.setattr(host_info, '_run', lambda *_args, **_kwargs: _IOREG_OUTPUT)
124+
125+
assert host_info.get_serial_number() == _SERIAL
126+
127+
128+
def test_linux_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
129+
monkeypatch.setattr(host_info.platform, 'system', lambda: 'Linux')
130+
131+
assert host_info.get_serial_number() is None

0 commit comments

Comments
 (0)