382 lines
12 KiB
PowerShell
382 lines
12 KiB
PowerShell
# Public runtime의 사용자 업로드 저장소 계약 SSOT.
|
|
# 이 파일은 함수만 선언하며 dot-source 시 외부 상태를 변경하지 않는다.
|
|
|
|
function Get-PublicRuntimeCanonicalPath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path
|
|
)
|
|
|
|
$fullPath = [System.IO.Path]::GetFullPath($Path)
|
|
$pathRoot = [System.IO.Path]::GetPathRoot($fullPath)
|
|
if ([string]::IsNullOrWhiteSpace($pathRoot)) {
|
|
throw "Path does not have a filesystem root"
|
|
}
|
|
$comparisonPath = $fullPath.TrimEnd('\', '/')
|
|
$comparisonRoot = $pathRoot.TrimEnd('\', '/')
|
|
if ([string]::Equals(
|
|
$comparisonPath,
|
|
$comparisonRoot,
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
)) {
|
|
return $pathRoot
|
|
}
|
|
return $comparisonPath
|
|
}
|
|
|
|
function Test-PublicRuntimePathIsSameOrChild {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Candidate,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Parent
|
|
)
|
|
|
|
$candidateFull = Get-PublicRuntimeCanonicalPath -Path $Candidate
|
|
$parentFull = Get-PublicRuntimeCanonicalPath -Path $Parent
|
|
if ([string]::Equals(
|
|
$candidateFull,
|
|
$parentFull,
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
)) {
|
|
return $true
|
|
}
|
|
$prefix = $parentFull
|
|
if (-not $prefix.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
|
|
$prefix += [System.IO.Path]::DirectorySeparatorChar
|
|
}
|
|
return $candidateFull.StartsWith(
|
|
$prefix,
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
)
|
|
}
|
|
|
|
function Assert-PublicRuntimePathHasNoReparsePoint {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path
|
|
)
|
|
|
|
$cursor = Get-PublicRuntimeCanonicalPath -Path $Path
|
|
while (-not [string]::IsNullOrWhiteSpace($cursor)) {
|
|
if (Test-Path -LiteralPath $cursor) {
|
|
$item = Get-Item -LiteralPath $cursor -Force
|
|
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "USER_UPLOAD_DIR cannot traverse a symlink or reparse point: $cursor"
|
|
}
|
|
}
|
|
$parent = [System.IO.Directory]::GetParent($cursor)
|
|
if ($null -eq $parent) {
|
|
break
|
|
}
|
|
$cursor = $parent.FullName
|
|
}
|
|
}
|
|
|
|
function Resolve-PublicRuntimeUploadRoot {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$SourceRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$UploadRoot,
|
|
[switch]$CreateIfMissing,
|
|
[switch]$ProbeWritable
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($UploadRoot)) {
|
|
throw "USER_UPLOAD_DIR is empty"
|
|
}
|
|
$driveAbsolute = $UploadRoot -match '^[A-Za-z]:[\\/]'
|
|
$uncAbsolute = $UploadRoot -match '^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)'
|
|
if (-not $driveAbsolute -and -not $uncAbsolute) {
|
|
throw "USER_UPLOAD_DIR must be a fully qualified absolute Windows path"
|
|
}
|
|
if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) {
|
|
throw "Public runtime source root is unavailable: $SourceRoot"
|
|
}
|
|
|
|
$resolvedSourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path
|
|
$gitRootOutput = @(& git.exe -C $resolvedSourceRoot rev-parse --show-toplevel)
|
|
if ($LASTEXITCODE -ne 0 -or $gitRootOutput.Count -eq 0) {
|
|
throw "Could not resolve the public runtime Git root"
|
|
}
|
|
$gitRoot = (Resolve-Path -LiteralPath ((@($gitRootOutput) -join [Environment]::NewLine).Trim())).Path
|
|
if (-not [string]::Equals(
|
|
$resolvedSourceRoot,
|
|
$gitRoot,
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
)) {
|
|
throw "Public runtime source root must match its Git toplevel"
|
|
}
|
|
|
|
$fullUploadPath = [System.IO.Path]::GetFullPath($UploadRoot)
|
|
$filesystemRoot = [System.IO.Path]::GetPathRoot($fullUploadPath)
|
|
if ([string]::IsNullOrWhiteSpace($filesystemRoot)) {
|
|
throw "USER_UPLOAD_DIR does not have a filesystem root"
|
|
}
|
|
if ([string]::Equals(
|
|
$fullUploadPath.TrimEnd('\', '/'),
|
|
$filesystemRoot.TrimEnd('\', '/'),
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
)) {
|
|
throw "USER_UPLOAD_DIR cannot be a filesystem or UNC share root"
|
|
}
|
|
$fullUploadRoot = Get-PublicRuntimeCanonicalPath -Path $fullUploadPath
|
|
$uploadParent = [System.IO.Directory]::GetParent($fullUploadRoot)
|
|
if ($null -eq $uploadParent) {
|
|
throw "USER_UPLOAD_DIR cannot be a filesystem root"
|
|
}
|
|
if (
|
|
(Test-PublicRuntimePathIsSameOrChild -Candidate $fullUploadRoot -Parent $gitRoot) -or
|
|
(Test-PublicRuntimePathIsSameOrChild -Candidate $gitRoot -Parent $fullUploadRoot)
|
|
) {
|
|
throw "USER_UPLOAD_DIR must be disjoint from the public runtime Git root"
|
|
}
|
|
|
|
# 기존 ancestor와 생성 후 최종 경로를 모두 검사해 junction/symlink를 통한
|
|
# source-tree 또는 다른 저장소로의 우회를 차단한다.
|
|
Assert-PublicRuntimePathHasNoReparsePoint -Path $fullUploadRoot
|
|
if (Test-Path -LiteralPath $fullUploadRoot) {
|
|
if (-not (Test-Path -LiteralPath $fullUploadRoot -PathType Container)) {
|
|
throw "USER_UPLOAD_DIR is not a directory: $fullUploadRoot"
|
|
}
|
|
} elseif ($CreateIfMissing) {
|
|
[System.IO.Directory]::CreateDirectory($fullUploadRoot) | Out-Null
|
|
} else {
|
|
throw "USER_UPLOAD_DIR does not exist: $fullUploadRoot"
|
|
}
|
|
Assert-PublicRuntimePathHasNoReparsePoint -Path $fullUploadRoot
|
|
$resolvedUploadRoot = (Resolve-Path -LiteralPath $fullUploadRoot).Path
|
|
|
|
if ($ProbeWritable) {
|
|
$probePath = Join-Path $resolvedUploadRoot (
|
|
".vignette-write-probe-{0}.tmp" -f [Guid]::NewGuid().ToString("N")
|
|
)
|
|
$stream = $null
|
|
try {
|
|
$stream = [System.IO.File]::Open(
|
|
$probePath,
|
|
[System.IO.FileMode]::CreateNew,
|
|
[System.IO.FileAccess]::Write,
|
|
[System.IO.FileShare]::None
|
|
)
|
|
$stream.WriteByte(0)
|
|
$stream.Flush($true)
|
|
} catch {
|
|
throw "USER_UPLOAD_DIR is not writable: $resolvedUploadRoot ($($_.Exception.Message))"
|
|
} finally {
|
|
if ($null -ne $stream) {
|
|
$stream.Dispose()
|
|
}
|
|
if ([System.IO.File]::Exists($probePath)) {
|
|
[System.IO.File]::Delete($probePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
return $resolvedUploadRoot
|
|
}
|
|
|
|
function Resolve-PublicRuntimePrivateStatePath {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$SourceRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$UploadRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$StatePath,
|
|
[switch]$RequireFile
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($StatePath) -or -not [System.IO.Path]::IsPathRooted($StatePath)) {
|
|
throw "Public upload private state path must be absolute"
|
|
}
|
|
$fullStatePath = Get-PublicRuntimeCanonicalPath -Path $StatePath
|
|
$stateRoot = [System.IO.Path]::GetPathRoot($fullStatePath)
|
|
if ([string]::Equals(
|
|
$fullStatePath.TrimEnd('\', '/'),
|
|
$stateRoot.TrimEnd('\', '/'),
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
)) {
|
|
throw "Public upload private state path cannot be a filesystem root"
|
|
}
|
|
foreach ($publicBoundary in @($SourceRoot, $UploadRoot)) {
|
|
if (
|
|
(Test-PublicRuntimePathIsSameOrChild -Candidate $fullStatePath -Parent $publicBoundary) -or
|
|
(Test-PublicRuntimePathIsSameOrChild -Candidate $publicBoundary -Parent $fullStatePath)
|
|
) {
|
|
throw "Public upload manifest/freeze state must be disjoint from source and public upload roots"
|
|
}
|
|
}
|
|
Assert-PublicRuntimePathHasNoReparsePoint -Path $fullStatePath
|
|
if ($RequireFile) {
|
|
if (-not (Test-Path -LiteralPath $fullStatePath -PathType Leaf)) {
|
|
throw "Public upload private state file is missing: $fullStatePath"
|
|
}
|
|
return (Resolve-Path -LiteralPath $fullStatePath).Path
|
|
}
|
|
$parent = [System.IO.Path]::GetDirectoryName($fullStatePath)
|
|
if ([string]::IsNullOrWhiteSpace($parent) -or -not (Test-Path -LiteralPath $parent -PathType Container)) {
|
|
throw "Public upload private state parent directory is missing: $parent"
|
|
}
|
|
Assert-PublicRuntimePathHasNoReparsePoint -Path $parent
|
|
return $fullStatePath
|
|
}
|
|
|
|
function Test-PublicRuntimeUploadManifest {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$PythonPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ProbePath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$UploadRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ManifestPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExpectedManifestSha256,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExpectedWriteFreezePath
|
|
)
|
|
|
|
foreach ($requiredFile in @($PythonPath, $ProbePath, $ManifestPath)) {
|
|
if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) {
|
|
return [pscustomobject]@{
|
|
Name = "api-upload-manifest"
|
|
Ok = $false
|
|
Detail = "upload manifest prerequisite missing"
|
|
Payload = $null
|
|
}
|
|
}
|
|
}
|
|
$probeArgs = @(
|
|
"-X", "utf8", "-B", $ProbePath,
|
|
"--upload-root", $UploadRoot,
|
|
"--manifest-path", $ManifestPath,
|
|
"--expected-manifest-sha256", $ExpectedManifestSha256,
|
|
"--expected-write-freeze-path", $ExpectedWriteFreezePath
|
|
)
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
try {
|
|
$ErrorActionPreference = "Continue"
|
|
$output = @(& $PythonPath @probeArgs 2>$null)
|
|
$probeExit = $LASTEXITCODE
|
|
} finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
}
|
|
$detail = (@($output) -join "").Trim()
|
|
$payload = $null
|
|
if (-not [string]::IsNullOrWhiteSpace($detail)) {
|
|
try {
|
|
$payload = $detail | ConvertFrom-Json
|
|
} catch {
|
|
$payload = $null
|
|
}
|
|
}
|
|
if ($null -eq $payload) {
|
|
$detail = "upload manifest probe failed without valid JSON"
|
|
}
|
|
return [pscustomobject]@{
|
|
Name = "api-upload-manifest"
|
|
Ok = $probeExit -eq 0 -and $null -ne $payload -and $payload.status -eq "passed"
|
|
Detail = $detail
|
|
Payload = $payload
|
|
}
|
|
}
|
|
|
|
function Test-PublicRuntimeApiUploadRoot {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$PythonPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ProbePath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExpectedUploadRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExpectedApiCwd,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExpectedManifestPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern("^[0-9a-f]{64}$")]
|
|
[string]$ExpectedManifestSha256,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExpectedWriteFreezePath,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern("^[0-9a-f]{64}$")]
|
|
[string]$ExpectedDatabaseTargetSha256,
|
|
[Parameter(Mandatory = $true)]
|
|
[int]$ApiPort
|
|
)
|
|
|
|
foreach ($requiredFile in @($PythonPath, $ProbePath)) {
|
|
if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) {
|
|
return [pscustomobject]@{
|
|
Name = "api-upload-root"
|
|
Ok = $false
|
|
Detail = "upload root process probe prerequisite missing: $requiredFile"
|
|
Payload = $null
|
|
ListenerPid = $null
|
|
}
|
|
}
|
|
}
|
|
|
|
$probeArgs = @(
|
|
"-X", "utf8", "-B", $ProbePath,
|
|
"--expected-root", $ExpectedUploadRoot,
|
|
"--expected-api-cwd", $ExpectedApiCwd,
|
|
"--expected-manifest-path", $ExpectedManifestPath,
|
|
"--expected-manifest-sha256", $ExpectedManifestSha256,
|
|
"--expected-write-freeze-path", $ExpectedWriteFreezePath,
|
|
"--expected-database-target-sha256", $ExpectedDatabaseTargetSha256,
|
|
"--api-port", $ApiPort.ToString()
|
|
)
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
try {
|
|
# Windows PowerShell 5.1이 native stderr를 ErrorRecord로 승격하지 않게 이
|
|
# secret-free probe 경계에서만 Continue로 낮춘다.
|
|
$ErrorActionPreference = "Continue"
|
|
$output = @(& $PythonPath @probeArgs 2>$null)
|
|
$probeExit = $LASTEXITCODE
|
|
} finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
}
|
|
$detail = (@($output) -join "").Trim()
|
|
if ([string]::IsNullOrWhiteSpace($detail)) {
|
|
$detail = "upload root process probe failed without output"
|
|
}
|
|
$payload = $null
|
|
if (-not [string]::IsNullOrWhiteSpace($detail)) {
|
|
try {
|
|
$payload = $detail | ConvertFrom-Json
|
|
} catch {
|
|
$payload = $null
|
|
}
|
|
}
|
|
$listenerPid = $null
|
|
if ($null -ne $payload -and $payload.status -eq "passed") {
|
|
try {
|
|
$candidatePid = [int]$payload.pid
|
|
if ($candidatePid -gt 0) {
|
|
$listenerPid = $candidatePid
|
|
}
|
|
} catch {
|
|
$listenerPid = $null
|
|
}
|
|
}
|
|
return [pscustomobject]@{
|
|
Name = "api-upload-root"
|
|
Ok = (
|
|
$probeExit -eq 0 -and
|
|
$null -ne $payload -and
|
|
$payload.status -eq "passed" -and
|
|
$null -ne $listenerPid
|
|
)
|
|
Detail = $detail
|
|
Payload = $payload
|
|
ListenerPid = $listenerPid
|
|
}
|
|
}
|