feat(zchecksums): SHA-256 manifest so a download can be verified before it's run

CHECKSUMS.txt lists a SHA-256 for every top-level .ps1 and .cmd - the files a
user actually executes. zchecksums verifies them; zchecksums -Update
regenerates after an intentional edit.

The manifest is sha256sum format, so 'sha256sum -c CHECKSUMS.txt' works on
Linux/macOS/WSL as well as the PowerShell path on Windows. Hashes are identical
on every platform because .gitattributes pins .ps1/.cmd to CRLF everywhere -
that pin is now load-bearing, so it is commented as such.

Beyond changed and missing files it also reports a script that is on disk but
NOT in the manifest, so something added outside a commit still gets noticed.
Exits non-zero on any of the three.

Honest about its limits, in the header and the README: the manifest lives in
the same repo as the code, so it is an integrity check rather than a signature.
It catches a truncated clone, a forgotten local edit, or an unlisted file - not
a compromised repo.

CHECKSUMS.txt is pinned to LF: sha256sum treats a trailing CR as part of the
filename and would report every entry as missing on Linux.

tests/Checksums.Tests.ps1 keeps it from rotting - a stale manifest is worse
than none, since it either cries wolf until people ignore it or quietly stops
covering a new script. The tests assert the format, LF endings, sort order,
full coverage of on-disk scripts, current hashes, and that zchecksums itself
exits 1 on a tampered file (proved by appending a byte and restoring it).
This commit is contained in:
KellyMichels 2026-07-28 12:28:53 -05:00
parent c20e82e209
commit 9d13b8ce72
7 changed files with 342 additions and 1 deletions

8
.gitattributes vendored
View File

@ -1,3 +1,9 @@
# PowerShell/cmd are happiest with CRLF on Windows.
# PowerShell/cmd are happiest with CRLF on Windows. The pin matters beyond
# convenience: it makes these files byte-identical on every platform, which is
# what lets CHECKSUMS.txt hold one hash per file rather than one per OS.
*.ps1 text eol=crlf
*.cmd text eol=crlf
# The checksum manifest must stay LF: `sha256sum -c` treats a trailing CR as
# part of the filename and reports every entry as missing.
CHECKSUMS.txt text eol=lf

View File

@ -22,6 +22,16 @@ Notable changes to the Evomedia.net Token Savers.
credentials and RSA signing keys.
### Added
- **`zchecksums` + `CHECKSUMS.txt`** — a SHA-256 manifest covering every `.ps1`
and `.cmd`, so a download can be verified before anything is run. `zchecksums`
checks them; `zchecksums -Update` regenerates after an intentional edit. The
manifest is `sha256sum` format, so `sha256sum -c CHECKSUMS.txt` works on
Linux/macOS/WSL too, and the hashes match on every platform because
`.gitattributes` pins these files to CRLF everywhere. Flags changed files,
missing files, **and scripts present on disk but absent from the manifest**.
It's an integrity check, not a signature — the manifest sits in the same repo
as the code, so it catches corruption and accidental drift, not a compromised
repo. A Pester test fails if the manifest ever goes stale.
- **Test suite (Pester)** — the toolkit now has automated coverage of its own
pure logic: `Get-ArchiveExcludes` (including the deploy-vs-backup rule that
keeps `.env`/`uploads` out of deploys but *in* backups), config and project

38
CHECKSUMS.txt Normal file
View File

@ -0,0 +1,38 @@
b3452601fafaf1799be9286c41bc9badd95aad7fe45093d3239c968f4ee16e75 setup_backup_schedule.ps1
dd39a7be920e5e9b2e99aafcc598491413b89bc902c2efd99ac9464d18f24ee1 token-count.ps1
d05ef5941a97911781d638a0a209b10affe777b1021f89071c5414471bf6b097 zbackup.cmd
90f05dc2db328d6a8797c6d3dfc0b9cb9e1c068de7180826928bae94deda2e77 zbackup.ps1
1fa2a6587f4d3a0b3cc0a2b2c265e1b520c7b7caf21e6cd9e110794e803375fa zbackup_and_sync.ps1
dc6f31069607a7f60cd45399a27a88abe01695879f0b2d9272537d63b0a71d99 zbackup_ec2.cmd
5a88b72c1e15036346e9fe8e48ba83acb6465acdb35b7d94239f03263e095ccc zbackup_ec2.ps1
c6f8da4b90d5531f245b7c55f6b4acb7e69edcc1fa2c966bc52aa8bc4f23417f zchecksums.cmd
94a96f879db64ac5f2e9f1e991c9a4a506fade5d538e3c0c7c4588124595659e zchecksums.ps1
b2c9a2f0205768e7b301c152b1f4cf042962125d4e982812befc2e536c0f7c6b zdeploy.cmd
30bc24780b2c6772f57232eee077b3295892afbb8f8298091b11e815d8a07b23 zdeploy.ps1
9b2702ac57115c2cd6df1f0ad33b0f8b4fd73a54dcd99475b9cacb1070a235d5 zec2.cmd
69696d75c1430ef0afe773880b962f08316c5ced4a118fe8abd71171c7e9359b zec2.ps1
2fbd24b329f90a1b6982e3f4b8fc9068fc99e900fe718eb9ff2af7c6a98dfb4f zec2_rotatekeys.cmd
a0016e48c3b4b9b1f7e800315bbef7d2b2ca3267bf51a0ce31e41a28cb74b71b zec2_rotatekeys.ps1
60e7ab58ca786b1a075ac268e4c2f8454bfcff47ed077cd0219f74c8d0eeaf78 zec2online.cmd
0177d8fbb6fba66a82fdde437ed9e7d43f3ef1e2227a418e9783380c549066cf zec2online.ps1
77060d8e30cc43ce9114c58e9f2f2f76d647e3237a3357299ca1820131128636 ZHelpers.ps1
79074a4dbd4d9fa0388cdac6ce0543a72d2b09c4779750d453c7566e500868ab zkill.cmd
d9ca58bf5baf8be8eb90fb8d414f55cd33deee35ea063dc4df7d9c9d4ed0e72c zkill.ps1
d419dd7d407ac19b0ca83a54b622399c2343d63bfe53c1ff05e7b5b15c0c7a0f ZKiller.ps1
ba9b2204945b19b912420290361191661a80887ecfd5d2c31d32b0686382e9a0 ZKillOnly.ps1
462ccd1d8a8600a76885a8a62a11ba64e3123bc273815ee1c7197d2bca48d7bf zrepair.cmd
e16f4971d58ccaa9a9b0de1dedc0eb0acc126d195628831e186f2855e7895d0d zrepair.ps1
eaa25fce9a0564df24378bedfed0815eb24c4faf9a16fdd490dafe22bcf81f4b zrestart.cmd
c1909e96e158a24f17a52053f3faeacbcbd366011bbcb1bd8a4c83d381f0355b zrestart.ps1
ec2161b28bd9ab89fe7212a153fb948a29de6ef987b7d794ee3f8c605fd60cc6 zrestartd.cmd
48debde34be2910de9c084b155894e502f0b9447314564a6c12ffd64973c4d91 zsetup.cmd
5ec860041f07487b3322d47a874daad93dc67b1dd009204201956f3137c34205 zsetup.ps1
b5d61fc4105fefb738653f5dfd3440d6c9126c63925e42a2d0ba2adc26afdbbc zsetup_mail.ps1
08b943c5eb9d085274a56179f7bcb56740aefd0cfb166d45cfc65d0e6cf8ec31 zstart.cmd
e54c85132f99c6a435091758e9bbda3399a1a6833bf6ab9962362b8de62fb46b zstart.ps1
d416fc6b781e3f130de79791c54d10b74402de0eca8582b0efcb91e4252b0d30 zstart_docker.cmd
77d498086653961c4b6febffaad889652224d46a47473273aa40acfca4916ba7 zstart_docker.ps1
0edc7c975dfbe3cb9cd6c3c63e3e43b3b49e92a89e317283acd31a29d04d3239 zstartd.cmd
09aa986839f3a969c06ca1300365b2eb5ae0da49b27275aa1cfb9c2cf00517bf zstop.ps1
7102ec4d459c663aeb571fa2527c069ab0d82a038e4d1ed115d107506226aa14 zsync.cmd
4e40a178eda57e986bd050f95d98ea15aa1197a9edde8df64a468c4b2948d89f zsync.ps1

View File

@ -131,6 +131,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more
| `zbackup_ec2 [<key> ...]` | Pull DB dumps + server-side data files down from the server |
| `zsync [<key>]` | Copy new backups offsite (or build + mirror a vite dist) |
| `zstart_docker` | Run a local docker compose stack from `scriptsRoot\docker\` |
| `zchecksums [-Update]` | Verify every script against `CHECKSUMS.txt` (SHA-256) |
### Local development
@ -385,6 +386,32 @@ Give the project a `verify` block instead, and `zdeploy` checks the app **from t
2. That's it: `zstart`, `zkill`, `zrestart`, `zbackup`, `zdeploy`, `zec2`, `zec2online`, `zrepair`, `zstop` all accept the new key immediately.
3. A project whose deploy doesn't fit the python/vite/nextjs/edge/docker patterns needs its own `Invoke-<Kind>Deploy` function in `zdeploy.ps1` — copy an existing handler; they're all variations on zip → upload → compose up → verify.
## Verifying what you downloaded
`CHECKSUMS.txt` holds a SHA-256 for every `.ps1` and `.cmd` in the repo. Check them before running anything:
```powershell
zchecksums
```
Or with the standard tool on Linux/macOS/WSL — the manifest is `sha256sum` format:
```bash
sha256sum -c CHECKSUMS.txt
```
The hashes are identical on every platform: `.gitattributes` pins `.ps1`/`.cmd` to CRLF everywhere, so a file is byte-for-byte the same whether you cloned on Windows or Linux.
`zchecksums` flags three things — a file whose contents changed, a listed file that's gone, and a script on disk that **isn't** in the manifest (so something added quietly still gets noticed). It exits non-zero on any of them.
If you edit a script yourself, regenerate and commit the manifest with it:
```powershell
zchecksums -Update
```
**What this does and doesn't prove.** `CHECKSUMS.txt` lives in the same repo as the scripts, so anyone who could alter a script could alter the manifest too. It's an integrity check, not a signature: it reliably catches a truncated clone, a local edit you forgot about, or a file added outside a commit. It does *not* prove the code came from this project — for that you'd need a signature or a hash published outside this repo.
## Tests
The toolkit has its own [Pester](https://pester.dev) suite covering the pure logic — the exclude lists, config lookups, and version-label formatting that the deploy and backup paths depend on:

111
tests/Checksums.Tests.ps1 Normal file
View File

@ -0,0 +1,111 @@
# Evomedia.net Token Savers - https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels - dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
# Checksums.Tests.ps1 - keeps CHECKSUMS.txt honest.
#
# Invoke-Pester .\tests
#
# A checksum manifest is worse than useless once it drifts: it either cries wolf
# on every run until people stop reading it, or it quietly stops covering a new
# script. These tests fail the moment the manifest and the scripts disagree, so
# "forgot to run zchecksums -Update" surfaces here rather than in a user's
# verification run.
BeforeAll {
$script:RepoRoot = Split-Path -Parent $PSScriptRoot
$script:Manifest = Join-Path $script:RepoRoot "CHECKSUMS.txt"
# Same set zchecksums.ps1 covers: top-level executables.
$script:Covered = @(
Get-ChildItem -LiteralPath $script:RepoRoot -File |
Where-Object { $_.Extension -in @('.ps1', '.cmd') } |
Sort-Object Name
)
$script:Listed = [ordered]@{}
if (Test-Path -LiteralPath $script:Manifest) {
foreach ($line in [IO.File]::ReadAllLines($script:Manifest)) {
if ($line -match '^([0-9a-fA-F]{64})\s+(.+)$') {
$script:Listed[$Matches[2].Trim()] = $Matches[1].ToLowerInvariant()
}
}
}
}
Describe "CHECKSUMS.txt" {
It "exists" {
Test-Path -LiteralPath $script:Manifest | Should -BeTrue
}
# NOTE: no angle brackets in It names - Pester treats them as -ForEach data
# placeholders and tries to evaluate the contents as an expression.
It "is sha256sum-compatible: 64 hex chars, two spaces, then the filename" {
# Anything else and `sha256sum -c CHECKSUMS.txt` warns or bails, which
# is half the point of publishing it.
foreach ($line in [IO.File]::ReadAllLines($script:Manifest)) {
if (-not $line.Trim()) { continue }
$line | Should -Match '^[0-9a-f]{64} \S.*$'
}
}
It "uses LF line endings" {
# A trailing CR becomes part of the filename for sha256sum, so every
# entry would report as missing on Linux/macOS.
([IO.File]::ReadAllText($script:Manifest)) | Should -Not -Match "`r"
}
It "is sorted by filename" {
$names = @($script:Listed.Keys)
($names -join ',') | Should -Be (($names | Sort-Object) -join ',')
}
}
Describe "manifest matches what is on disk" {
It "lists every top-level .ps1 / .cmd (nothing silently uncovered)" {
$missingFromManifest = @($script:Covered.Name | Where-Object { -not $script:Listed.Contains($_) })
$missingFromManifest -join ', ' | Should -BeNullOrEmpty -Because "these scripts are not in CHECKSUMS.txt - run 'zchecksums -Update'"
}
It "lists nothing that no longer exists" {
$onDisk = @($script:Covered.Name)
$stale = @($script:Listed.Keys | Where-Object { $onDisk -notcontains $_ })
$stale -join ', ' | Should -BeNullOrEmpty -Because "these entries point at files that are gone - run 'zchecksums -Update'"
}
It "records the current hash of <_>" -ForEach @(
(Get-ChildItem -LiteralPath (Split-Path -Parent $PSScriptRoot) -File |
Where-Object { $_.Extension -in @('.ps1', '.cmd') } |
Sort-Object Name | Select-Object -ExpandProperty Name)
) {
$name = $_
$script:Listed.Contains($name) | Should -BeTrue -Because "$name is missing from CHECKSUMS.txt"
$actual = (Get-FileHash -LiteralPath (Join-Path $script:RepoRoot $name) -Algorithm SHA256).Hash.ToLowerInvariant()
$actual | Should -Be $script:Listed[$name] -Because "$name changed since CHECKSUMS.txt was written - run 'zchecksums -Update'"
}
}
Describe "zchecksums.ps1" {
It "verifies clean and exits 0 against the committed manifest" {
$out = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:RepoRoot "zchecksums.ps1") -Quiet 2>&1
$LASTEXITCODE | Should -Be 0 -Because ($out -join "`n")
}
It "exits non-zero when a covered file has been tampered with" {
# Proves the check actually detects a modified script rather than always
# passing. Appends a byte to a real script, verifies, then restores it.
$victim = Join-Path $script:RepoRoot "zchecksums.cmd"
$original = [IO.File]::ReadAllBytes($victim)
try {
[IO.File]::AppendAllText($victim, "REM tampered`r`n")
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:RepoRoot "zchecksums.ps1") -Quiet *> $null
$LASTEXITCODE | Should -Be 1
}
finally {
[IO.File]::WriteAllBytes($victim, $original)
}
# Restored, so a normal verify passes again.
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:RepoRoot "zchecksums.ps1") -Quiet *> $null
$LASTEXITCODE | Should -Be 0
}
}

6
zchecksums.cmd Normal file
View File

@ -0,0 +1,6 @@
REM Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
REM Created by Kelly Michels · dev@evomedia.net
REM Licensed under the MIT License. See LICENSE.
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0zchecksums.ps1" %*

143
zchecksums.ps1 Normal file
View File

@ -0,0 +1,143 @@
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels · dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
# zchecksums.ps1 - verify (or regenerate) SHA-256 checksums for the scripts.
#
# Usage:
# zchecksums verify every script against CHECKSUMS.txt
# zchecksums -Update regenerate CHECKSUMS.txt after changing a script
# zchecksums -Quiet verify, print only the summary line
#
# Exit codes: 0 = everything matches, 1 = a mismatch, missing or unlisted file.
#
# WHAT THIS DOES AND DOES NOT PROTECT AGAINST
# -------------------------------------------
# CHECKSUMS.txt lives in the same repo as the scripts, so anyone able to modify
# a script can also modify the manifest. This is an integrity check, not a
# signature. It reliably catches:
#
# * a truncated or corrupted download / clone
# * a file edited locally that you forgot about
# * a script added or removed without going through a commit
#
# It does NOT prove the code came from this project - only a signature (GPG,
# Sigstore) or a hash published somewhere outside this repo can do that. Compare
# against the copy on GitHub if you need that assurance.
#
# Only *.ps1 and *.cmd are covered: they are what you actually execute, and
# .gitattributes pins them to CRLF on every platform, so their hashes are
# identical on Windows, Linux and macOS. Files without that pin would hash
# differently per platform and are deliberately left out.
#
# RUN -Update ON A CLEAN CHECKOUT
# ------------------------------
# Hash whatever is on disk. That is only the same as what a user clones if the
# working copy matches git's normalised form. An editor that writes LF leaves a
# file git still considers unchanged (it normalises to LF in the index either
# way), so the file sits there with LF while every clone gets CRLF - and the
# manifest generated from it fails for everyone else. If in doubt:
#
# git status --short # must be clean
# git rm -r --cached . ; git reset --hard # or delete the scripts and
# # `git checkout -- .`
#
# then re-run -Update. The surest check is to clone the repo somewhere else and
# run `sha256sum -c CHECKSUMS.txt` there.
[CmdletBinding()]
param(
[switch]$Update,
[switch]$Quiet
)
$ErrorActionPreference = "Stop"
$ManifestName = "CHECKSUMS.txt"
$Manifest = Join-Path $PSScriptRoot $ManifestName
# The covered set: executable scripts, top level only. Sorted for a stable file.
function Get-CoveredFiles {
Get-ChildItem -LiteralPath $PSScriptRoot -File |
Where-Object { $_.Extension -in @('.ps1', '.cmd') } |
Sort-Object Name
}
function Get-Sha256([string]$Path) {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
# sha256sum-compatible: "<hash> <name>", LF endings, so `sha256sum -c` works on
# Linux/macOS as well as this script on Windows.
function Write-Manifest($Files) {
$lines = foreach ($f in $Files) { "{0} {1}" -f (Get-Sha256 $f.FullName), $f.Name }
$text = ($lines -join "`n") + "`n"
[IO.File]::WriteAllText($Manifest, $text, (New-Object Text.UTF8Encoding($false)))
}
function Read-Manifest {
if (-not (Test-Path -LiteralPath $Manifest)) { return $null }
$map = [ordered]@{}
foreach ($line in [IO.File]::ReadAllLines($Manifest)) {
$t = $line.Trim()
if (-not $t -or $t.StartsWith('#')) { continue }
# "<64 hex> <name>" - two spaces is the sha256sum convention, but accept
# any run of whitespace so a hand-edited file still parses.
if ($t -match '^([0-9a-fA-F]{64})\s+(.+)$') {
$map[$Matches[2].Trim()] = $Matches[1].ToLowerInvariant()
}
}
return $map
}
$files = @(Get-CoveredFiles)
if ($Update) {
Write-Manifest $files
Write-Host ""
Write-Host "=== zchecksums (updated) ===" -ForegroundColor Cyan
Write-Host (" Wrote {0} with {1} entries." -f $ManifestName, $files.Count) -ForegroundColor Green
Write-Host " Commit it alongside the script change, or verification will fail." -ForegroundColor DarkGray
Write-Host ""
exit 0
}
$expected = Read-Manifest
if ($null -eq $expected) {
Write-Host "ERROR: $ManifestName not found. Run 'zchecksums -Update' to create it." -ForegroundColor Red
exit 1
}
$ok = 0
$changed = @()
$missing = @()
$unlisted = @()
foreach ($f in $files) {
if (-not $expected.Contains($f.Name)) { $unlisted += $f.Name; continue }
if ((Get-Sha256 $f.FullName) -eq $expected[$f.Name]) { $ok++ } else { $changed += $f.Name }
}
foreach ($name in $expected.Keys) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $name))) { $missing += $name }
}
$bad = $changed.Count + $missing.Count + $unlisted.Count
if (-not $Quiet) {
Write-Host ""
Write-Host "=== zchecksums ===" -ForegroundColor Cyan
foreach ($n in $changed) { Write-Host " CHANGED $n" -ForegroundColor Red }
foreach ($n in $missing) { Write-Host " MISSING $n (listed but not on disk)" -ForegroundColor Red }
foreach ($n in $unlisted) { Write-Host " UNLISTED $n (on disk but not in $ManifestName)" -ForegroundColor Yellow }
}
if ($bad -eq 0) {
Write-Host (" OK - {0} file(s) match {1}." -f $ok, $ManifestName) -ForegroundColor Green
Write-Host ""
exit 0
}
Write-Host (" FAILED - {0} ok, {1} changed, {2} missing, {3} unlisted." -f $ok, $changed.Count, $missing.Count, $unlisted.Count) -ForegroundColor Red
Write-Host " If you changed a script on purpose, run: zchecksums -Update" -ForegroundColor DarkGray
Write-Host ""
exit 1