# 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 } }