scripts/scan-project-safety.ps1
Rama main · réplica de lectura del repositorio original.
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Workspace,
[switch]$Json
)
$ErrorActionPreference = 'Stop'
$results = [System.Collections.Generic.List[object]]::new()
function Add-Result {
param(
[ValidateSet('PASS','WARN','FAIL')][string]$Level,
[string]$Check,
[string]$Detail
)
$results.Add([pscustomobject]@{
level = $Level
check = $Check
detail = $Detail
})
}
function Invoke-GitRead {
param([string]$Directory, [string[]]$Arguments)
$output = (& git -C $Directory @Arguments 2>&1) -join "`n"
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output.Trim() }
}
function Get-SafeTree {
param([string]$Root)
$pending = [System.Collections.Generic.Stack[string]]::new()
$pending.Push($Root)
while ($pending.Count -gt 0) {
$directory = $pending.Pop()
foreach ($entry in Get-ChildItem -LiteralPath $directory -Force) {
$entry
if ($entry.PSIsContainer -and
-not ($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and
$entry.Name -ne '.git') {
$pending.Push($entry.FullName)
}
}
}
}
$workspacePath = [System.IO.Path]::GetFullPath($Workspace)
if (-not (Test-Path -LiteralPath $workspacePath -PathType Container)) {
Add-Result FAIL workspace "Workspace does not exist: $workspacePath"
} else {
Add-Result PASS workspace $workspacePath
}
$attachment = Join-Path $workspacePath '.ferryman'
$communications = Join-Path $attachment 'ferryman'
$mainGit = Join-Path $workspacePath '.git'
if (Test-Path -LiteralPath $mainGit) {
$mainRemote = Invoke-GitRead $workspacePath @('config','--get','remote.origin.url')
Add-Result PASS main_remote $(if ($mainRemote.Output) { $mainRemote.Output } else { '(none)' })
# Probe a child path so a directory-only rule such as /.ferryman/ is
# recognized even before the attachment directory exists.
$ignored = Invoke-GitRead $workspacePath @('check-ignore','--no-index','-q','--','.ferryman/probe')
if ($ignored.ExitCode -eq 0) {
Add-Result PASS main_ignore '/.ferryman/ is ignored by the main project'
} else {
Add-Result FAIL main_ignore '/.ferryman/ is not ignored by the main project'
}
} else {
Add-Result WARN main_git 'Workspace is not a Git checkout'
}
if (-not (Test-Path -LiteralPath $attachment -PathType Container)) {
Add-Result WARN attachment 'No .ferryman attachment exists'
$safeEntries = @()
} else {
Add-Result PASS attachment $attachment
$safeEntries = @(Get-SafeTree $attachment)
$reparsePoints = @($safeEntries |
Where-Object { $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint })
if ($reparsePoints.Count -eq 0) {
Add-Result PASS reparse_points 'No reparse points or symlinks under .ferryman'
} else {
Add-Result FAIL reparse_points (($reparsePoints.FullName | Sort-Object) -join '; ')
}
}
if (Test-Path -LiteralPath (Join-Path $attachment 'token') -PathType Leaf) {
Add-Result PASS outer_token 'Outer token exists; contents were not read'
} else {
Add-Result WARN outer_token 'Outer token is absent; hub registration may be deferred'
}
if (-not (Test-Path -LiteralPath (Join-Path $communications '.git'))) {
Add-Result WARN inner_git 'Portable communications Git checkout is absent'
} else {
$innerRemote = Invoke-GitRead $communications @('config','--get','remote.origin.url')
$scanOwner = $env:FERRYMAN_CHANNEL_GIT_OWNER
$scanSuffix = if ($env:FERRYMAN_CHANNEL_GIT_SUFFIX) { $env:FERRYMAN_CHANNEL_GIT_SUFFIX } else { '-ferryman' }
$expectedPattern = '^https://github\.com/' + [regex]::Escape($scanOwner) + '/[A-Za-z0-9._-]+' + [regex]::Escape($scanSuffix) + '(?:\.git)?$'
if (-not $innerRemote.Output) {
# No upstream at all is the Syncthing-only channel, not a failure.
Add-Result PASS inner_remote '(none; Syncthing-only)'
} elseif (-not $scanOwner) {
Add-Result FAIL inner_remote "$($innerRemote.Output) (set FERRYMAN_CHANNEL_GIT_OWNER to pin the expected owner)"
} elseif ($innerRemote.ExitCode -eq 0 -and $innerRemote.Output -match $expectedPattern) {
Add-Result PASS inner_remote $innerRemote.Output
} else {
Add-Result FAIL inner_remote $innerRemote.Output
}
$innerStatus = Invoke-GitRead $communications @('status','--porcelain','--untracked-files=all')
if ($innerStatus.ExitCode -ne 0) {
Add-Result FAIL inner_status $innerStatus.Output
} elseif ($innerStatus.Output) {
Add-Result WARN inner_status $innerStatus.Output
} else {
Add-Result PASS inner_status 'Portable repository is clean'
}
}
foreach ($forbidden in @('token','runtime')) {
$path = Join-Path $communications $forbidden
if (Test-Path -LiteralPath $path) {
Add-Result FAIL "inner_$forbidden" "Forbidden portable path exists: $path"
} else {
Add-Result PASS "inner_$forbidden" "No portable $forbidden path"
}
}
if (Test-Path -LiteralPath $communications -PathType Container) {
$suspicious = @($safeEntries |
Where-Object {
-not $_.PSIsContainer -and
$_.FullName.StartsWith($communications, [System.StringComparison]::OrdinalIgnoreCase) -and
$_.Name -match '(^\.env(?:\.|$)|token|secret|credential|password|\.sqlite3?$|\.db$|\.lock$)'
})
if ($suspicious.Count -eq 0) {
Add-Result PASS portable_names 'No suspicious portable filenames'
} else {
Add-Result FAIL portable_names (($suspicious.FullName | Sort-Object) -join '; ')
}
}
$standard = Join-Path $communications 'STANDARD.toml'
if (Test-Path -LiteralPath $standard -PathType Leaf) {
$revisionLine = Get-Content -LiteralPath $standard |
Where-Object { $_ -match '^revision\s*=\s*\d+\s*$' } |
Select-Object -First 1
$revision = if ($revisionLine -match '(\d+)') { [int]$Matches[1] } else { 0 }
if ($revision -eq 2) {
Add-Result PASS standard_revision 'Ferryman project standard revision 2'
} elseif ($revision -gt 2) {
Add-Result FAIL standard_revision "Project revision $revision is newer than this Ferryman checkout"
} else {
Add-Result WARN standard_revision "Project revision $revision needs update to revision 2"
}
} else {
Add-Result WARN standard_revision 'STANDARD.toml is missing; project needs a standard update'
}
if ($Json) {
$results | ConvertTo-Json -Depth 4
} else {
$results | Format-Table -AutoSize
}
if ($results.Where({ $_.level -eq 'FAIL' }).Count -gt 0) {
exit 2
}