fix(plugin): PSObject.Properties индексатор вместо .Name на пустой коллекции

server.lock.json вида "{}" (валидный JSON, ноль ключей) — реальный вход
(placeholder до заполнения релизным пайплайном), но под
Set-StrictMode -Version Latest член-перечисление .PSObject.Properties.Name
бросает PropertyNotFoundException, когда коллекция .Properties пуста, — Read-
KompasMcpLock падала сырым .NET-трейсом вместо дружественного сообщения.
Тесты этого не ловили: любой объект хотя бы с одним свойством (даже без
version) уже не задевает пустую коллекцию.

Фикс: PSObject.Properties['имя'] (индексатор) вместо .Properties.Name
-contains — он возвращает $null и не бросает независимо от того, пуста
коллекция или нет. Применено ко всем трём проверкам (version/url/sha256).
Отдельно исключён случай $lock -eq $null (JSON "null" и пустой файл
разбираются в $null, а $null.PSObject тоже бросает под StrictMode).

Добавлены тесты: лок "{}", лок "null", лок "" (пустая JSON-строка вместо
объекта) — все дают сообщение с именем файла, а не .NET-трейс. Известные
пробелы покрытия (IOException-гонка вокруг Directory.Move,
AbandonedMutexException) зафиксированы комментарием в тестовом файле — не
автотестируются осознанно (нужен настоящий межпроцессный тайминг).
This commit is contained in:
2026-07-31 02:43:13 +03:00
parent 2fb212bb14
commit 58eba795cf
2 changed files with 66 additions and 8 deletions
+19 -8
View File
@@ -16,25 +16,36 @@ function Read-KompasMcpLock {
throw "не удалось разобрать server.lock.json ($Path): $($_.Exception.Message)"
}
# Под Set-StrictMode -Version Latest обращение к отсутствующему свойству
# PSCustomObject бросает PropertyNotFoundException — поэтому сначала проверяем
# наличие ключа через PSObject.Properties.Name и только потом читаем значение.
$propNames = @($lock.PSObject.Properties.Name)
# JSON-содержимое "null" (и пустой файл) разбирается ConvertFrom-Json в $null
# без исключения — но $null.PSObject бросает PropertyNotFoundException под
# Set-StrictMode, поэтому его нужно исключить раньше, чем что-либо трогать.
if ($null -eq $lock) {
throw "server.lock.json ($Path) повреждён: файл разбирается в null (пустой/повреждённый лок)"
}
if (-not ($propNames -contains 'version') -or [string]::IsNullOrWhiteSpace([string]$lock.version)) {
# Под Set-StrictMode -Version Latest член-перечисление .Properties.Name
# бросает PropertyNotFoundException, если коллекция .Properties ПУСТА (это
# ровно случай лока вида "{}" — валидный JSON без единого ключа). Индексатор
# PSObject.Properties['имя'] свободен от этого: он всегда возвращает либо
# PSPropertyInfo, либо $null, независимо от того, пуста коллекция или нет —
# поэтому наличие каждого поля проверяем именно так, а не через -contains.
$versionProp = $lock.PSObject.Properties['version']
if ($null -eq $versionProp -or [string]::IsNullOrWhiteSpace([string]$versionProp.Value)) {
throw "server.lock.json ($Path) повреждён: нет поля version"
}
$version = [string]$lock.version
$version = [string]$versionProp.Value
if ($version -eq '0.0.0') {
throw "релиз сервера ещё не опубликован (server.lock.json пуст). Соберите сервер локально и укажите путь в KOMPAS_MCP_EXE."
}
if (-not ($propNames -contains 'url') -or [string]::IsNullOrWhiteSpace([string]$lock.url)) {
$urlProp = $lock.PSObject.Properties['url']
if ($null -eq $urlProp -or [string]::IsNullOrWhiteSpace([string]$urlProp.Value)) {
throw "server.lock.json ($Path) указывает опубликованную версию $version, но не содержит url для загрузки"
}
if (-not ($propNames -contains 'sha256') -or [string]::IsNullOrWhiteSpace([string]$lock.sha256) -or ([string]$lock.sha256 -notmatch '^[0-9a-fA-F]{64}$')) {
$sha256Prop = $lock.PSObject.Properties['sha256']
if ($null -eq $sha256Prop -or [string]::IsNullOrWhiteSpace([string]$sha256Prop.Value) -or ([string]$sha256Prop.Value -notmatch '^[0-9a-fA-F]{64}$')) {
throw "server.lock.json ($Path) указывает опубликованную версию $version, но sha256 отсутствует или некорректен (ожидались 64 hex-символа)"
}
+47
View File
@@ -214,4 +214,51 @@ Describe 'Read-KompasMcpLock' {
{ Read-KompasMcpLock -Path $path } | Should -Throw '*sha256*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает внятно на локе вида "{}" (валидный JSON, ноль ключей)' {
# Регрессия: под Set-StrictMode -Version Latest член-перечисление
# .PSObject.Properties.Name бросает PropertyNotFoundException, когда
# коллекция .Properties ПУСТА — ровно случай "{}". Это не гипотетика:
# placeholder-лок до заполнения релизным пайплайном литерально "{}".
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value '{}' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*version*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает внятно, если лок — это JSON null' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value 'null' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*server.lock.json*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает внятно, если лок — это пустая JSON-строка вместо объекта' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value '""' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*version*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}
# Известные пробелы покрытия (осознанно НЕ автотестируются здесь):
# - ветка `catch [System.IO.IOException]` вокруг [System.IO.Directory]::Move
# в Install-KompasMcpServer (настоящий TOCTOU между двумя процессами —
# детерминированно в unit-тесте не воспроизводится без реальной гонки потоков
# на файловой системе);
# - ветка `catch [System.Threading.AbandonedMutexException]` в
# Resolve-KompasMcpExecutable — проверена вручную (owner-процесс убит,
# пока waiter уже заблокирован в WaitOne(), waiter получает
# AbandonedMutexException и корректно продолжает работу), но не автотестом:
# надёжное воспроизведение требует реального межпроцессного таймингового
# сценария (Start-Process + Stop-Process -Force), что делает тест хрупким
# и медленным для обычного прогона.