feat(plugin): bootstrap-модуль установки сервера с проверкой SHA256

This commit is contained in:
2026-07-31 02:03:34 +03:00
parent 62fbc3742b
commit 1049a9b01a
2 changed files with 242 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
Set-StrictMode -Version Latest
# ВАЖНО: ни одна функция этого модуля не пишет в success stream — stdout занят JSON-RPC.
function Read-KompasMcpLock {
[CmdletBinding()]
param([Parameter(Mandatory)] [string] $Path)
if (-not (Test-Path -LiteralPath $Path)) { throw "нет файла пина версии: $Path" }
$lock = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if ($lock.version -eq '0.0.0' -or [string]::IsNullOrWhiteSpace($lock.url)) {
throw "релиз сервера ещё не опубликован (server.lock.json пуст). Соберите сервер локально и укажите путь в KOMPAS_MCP_EXE."
}
return $lock
}
function Test-KompasMcpInstall {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Directory,
[Parameter(Mandatory)] $Lock
)
$marker = Join-Path $Directory 'install.json'
$exe = Join-Path $Directory 'kompas-mcp.exe'
if (-not (Test-Path -LiteralPath $marker)) { return $false }
if (-not (Test-Path -LiteralPath $exe)) { return $false }
try {
$installed = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json
}
catch {
# Битый/чужой маркер — считаем установку негодной, а не падаем.
return $false
}
return ($installed.version -eq $Lock.version) -and ($installed.sha256 -eq $Lock.sha256)
}
function Install-KompasMcpServer {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $Lock,
[Parameter(Mandatory)] [string] $InstallRoot,
[scriptblock] $Downloader
)
$target = Join-Path $InstallRoot $Lock.version
if (Test-KompasMcpInstall -Directory $target -Lock $Lock) { return $target }
if ($null -eq $Downloader) {
$Downloader = {
param($Url, $Destination)
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing
}
}
# Временный каталог — под InstallRoot: перенос каталога атомарен только в пределах тома.
$tempRoot = Join-Path $InstallRoot '.tmp'
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
$work = Join-Path $tempRoot ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $work -Force | Out-Null
try {
$zip = Join-Path $work 'server.zip'
& $Downloader $Lock.url $zip
if (-not (Test-Path -LiteralPath $zip)) { throw "загрузка не создала файл: $($Lock.url)" }
$actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
$expected = ([string]$Lock.sha256).ToLowerInvariant()
if ($actual -ne $expected) {
throw "SHA256 архива не совпал: ожидался $expected, получен $actual"
}
$payload = Join-Path $work 'payload'
Expand-Archive -LiteralPath $zip -DestinationPath $payload -Force
if (-not (Test-Path -LiteralPath (Join-Path $payload 'kompas-mcp.exe'))) {
throw "в архиве нет kompas-mcp.exe в корне"
}
$marker = [ordered]@{
version = $Lock.version
sha256 = $expected
sourceSha = $Lock.sourceSha
installedAt = (Get-Date).ToString('o')
}
# -Encoding utf8 достаточно: значения маркера чисто ASCII (версия/хеш/ISO-дата),
# BOM (если добавится под 5.1) не мешает Get-Content -Raw | ConvertFrom-Json.
Set-Content -LiteralPath (Join-Path $payload 'install.json') `
-Value ($marker | ConvertTo-Json) -Encoding utf8
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
if (Test-Path -LiteralPath $target) {
# Гонку с соседним процессом выиграл он — чужая установка уже на месте.
if (Test-KompasMcpInstall -Directory $target -Lock $Lock) { return $target }
throw "каталог версии уже существует, но установка в нём негодная: $target"
}
try {
[System.IO.Directory]::Move($payload, $target)
}
catch [System.IO.IOException] {
# Гонку выиграл сосед — это успех, если его установка годная.
if (-not (Test-KompasMcpInstall -Directory $target -Lock $Lock)) { throw }
}
return $target
}
finally {
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Resolve-KompasMcpExecutable {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $Lock,
[string] $InstallRoot
)
if ([string]::IsNullOrWhiteSpace($InstallRoot)) {
$InstallRoot = Join-Path $env:LOCALAPPDATA 'kompas-mcp'
}
# Именованный mutex без префикса "Global\": это однопользовательская
# десктопная машина, не терминальный сервер — сессионного мьютекса достаточно,
# а "Global\" на некоторых конфигурациях требует отдельной привилегии.
$mutex = New-Object System.Threading.Mutex($false, "kompas-mcp-install-$($Lock.version)")
[void]$mutex.WaitOne()
try {
$dir = Install-KompasMcpServer -Lock $Lock -InstallRoot $InstallRoot
return (Join-Path $dir 'kompas-mcp.exe')
}
finally {
$mutex.ReleaseMutex()
$mutex.Dispose()
}
}
Export-ModuleMember -Function Read-KompasMcpLock, Test-KompasMcpInstall, Install-KompasMcpServer, Resolve-KompasMcpExecutable
+101
View File
@@ -0,0 +1,101 @@
BeforeAll {
Import-Module (Join-Path $PSScriptRoot '..' '..' 'plugin' 'scripts' 'KompasMcpBootstrap.psm1') -Force
function New-TempRoot {
$root = Join-Path ([System.IO.Path]::GetTempPath()) ("kompasmcp_" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $root -Force | Out-Null
return $root
}
# Готовит zip с «сервером» и возвращает лок с настоящим SHA256.
function New-FakeRelease([string] $Root, [string] $Version) {
$payload = Join-Path $Root 'payload'
New-Item -ItemType Directory -Path $payload -Force | Out-Null
Set-Content -LiteralPath (Join-Path $payload 'kompas-mcp.exe') -Value 'not-a-real-exe' -Encoding ascii
$zip = Join-Path $Root 'release.zip'
Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $zip -Force
Remove-Item -LiteralPath $payload -Recurse -Force
$sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
return [pscustomobject]@{
Zip = $zip
Lock = [pscustomobject]@{ version = $Version; sourceSha = 'deadbeef'; url = "file:///$zip"; sha256 = $sha }
}
}
}
Describe 'Install-KompasMcpServer' {
It 'ставит сервер, пишет маркер и возвращает путь к каталогу версии' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
}
$dir | Should -Be (Join-Path $installRoot '1.0.0')
Test-Path -LiteralPath (Join-Path $dir 'kompas-mcp.exe') | Should -BeTrue
(Get-Content -LiteralPath (Join-Path $dir 'install.json') -Raw | ConvertFrom-Json).sha256 |
Should -Be $rel.Lock.sha256
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'отказывается ставить при несовпадении SHA256 и не оставляет каталог версии' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$rel.Lock.sha256 = '0' * 64
$installRoot = Join-Path $root 'install'
{ Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
} } | Should -Throw '*SHA256*'
Test-Path -LiteralPath (Join-Path $installRoot '1.0.0') | Should -BeFalse
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'не скачивает повторно, если установка уже годная' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$script:calls = 0
$downloader = { param($Url, $Destination) $script:calls++; Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force }
Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null
Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader $downloader | Out-Null
$script:calls | Should -Be 1
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'считает установку негодной, если маркер не совпадает с локом' {
$root = New-TempRoot
try {
$rel = New-FakeRelease -Root $root -Version '1.0.0'
$installRoot = Join-Path $root 'install'
$dir = Install-KompasMcpServer -Lock $rel.Lock -InstallRoot $installRoot -Downloader {
param($Url, $Destination) Copy-Item -LiteralPath $rel.Zip -Destination $Destination -Force
}
Set-Content -LiteralPath (Join-Path $dir 'install.json') -Value '{"version":"1.0.0","sha256":"нечто"}' -Encoding utf8
Test-KompasMcpInstall -Directory $dir -Lock $rel.Lock | Should -BeFalse
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Describe 'Read-KompasMcpLock' {
It 'падает внятно, когда релиза ещё нет' {
$root = New-TempRoot
try {
$path = Join-Path $root 'server.lock.json'
Set-Content -LiteralPath $path -Value '{"version":"0.0.0","sourceSha":"","url":"","sha256":""}' -Encoding utf8
{ Read-KompasMcpLock -Path $path } | Should -Throw '*релиз*'
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
}