feat(plugin): bootstrap-модуль установки сервера с проверкой SHA256
This commit is contained in:
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user