아바타 저장소 승격 계약을 완성

This commit is contained in:
Yun Chan 2026-08-29 23:58:33 +09:00
parent ac9b702688
commit ccdcfcd2f5
36 changed files with 14734 additions and 222 deletions

View file

@ -0,0 +1,382 @@
# 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
}
}