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
41 changes: 26 additions & 15 deletions cycode/cli/utils/host_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}

Expand Down Expand Up @@ -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]:
Expand All @@ -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)
Expand All @@ -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
Binary file added images/cycode.ico
Binary file not shown.
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 48 additions & 3 deletions pyinstaller.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand All @@ -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'))
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Empty file added tests/cli/utils/__init__.py
Empty file.
131 changes: 131 additions & 0 deletions tests/cli/utils/test_host_info.py
Original file line number Diff line number Diff line change
@@ -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 <class IORegistryEntry, id 1, retain 42>
"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
Loading