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:
@@ -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-символа)"
|
||||
}
|
||||
|
||||
|
||||
@@ -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), что делает тест хрупким
|
||||
# и медленным для обычного прогона.
|
||||
|
||||
Reference in New Issue
Block a user