diff --git a/.github/scripts/sign_release.py b/.github/scripts/sign_release.py index 71d2cc4..6f333c2 100644 --- a/.github/scripts/sign_release.py +++ b/.github/scripts/sign_release.py @@ -114,6 +114,22 @@ def powershell(script): capture_output=True, text=True) if out.returncode != 0: raise SystemExit("sign_release: powershell failed:\n%s" % out.stderr.strip()) + # PowerShell errors are NON TERMINATING by default, so a script can print + # a page of complaints and still exit zero. Reading stderr only on a + # non-zero code therefore threw away the one sentence that said what went + # wrong, and left the caller looking at empty output with no reason for it. + # + # It cost an hour on 2026-09-09 signing v0.3.0: the certificate lookup came + # back empty and the script blamed a missing card, while the card was in + # the reader and readable. The complaint was there the whole time and + # nothing printed it. O200. + # + # A note rather than a failure, because a warning is not a refusal and the + # caller may have asked something that legitimately produces one. + if out.stderr.strip(): + print(" powershell also said:") + for line in out.stderr.strip().splitlines(): + print(" %s" % line) return out.stdout @@ -170,17 +186,41 @@ def signing_thumbprint(pin): the only selector it takes, and the repository pins SHA-256 because that is the digest worth pinning. Resolving one to the other here means the two can never drift apart in a configuration file. + + THE STORE IS OPENED THROUGH .NET RATHER THAN THROUGH THE Cert: DRIVE, and + that is a measurement rather than a preference. The drive is provided by + Microsoft.PowerShell.Security, which Windows PowerShell 5.1 only loads when + PSModulePath points at its own module directory - and a 5.1 launched from + inside pwsh 7 is handed pwsh's PSModulePath instead. Measured 2026-09-09 on + this machine, from a python started under pwsh: + + Get-ChildItem Cert:\\CurrentUser\\My -> 0, plus + "Cannot find drive. A drive with the name 'Cert' does not exist." + X509Store('My','CurrentUser') -> 11 + + Both ended with code ZERO, because a PowerShell error is non terminating - + so the script saw empty output and reported a missing card while the card + was in the reader. It cost an hour signing v0.3.0 and the release went out + through Git Bash as a workaround. X509Store is in the runtime rather than + in a module, so it does not depend on which shell started which. O200. """ script = ( "$out = @(); " - "Get-ChildItem Cert:\\CurrentUser\\My, Cert:\\LocalMachine\\My " - "-ErrorAction SilentlyContinue | Where-Object { " - " $_.Extensions.EnhancedKeyUsages.Value -contains '%s' } | ForEach-Object { " - " $h = [System.Security.Cryptography.SHA256]::Create().ComputeHash($_.RawData); " - " $out += [pscustomobject]@{ " - " sha256 = (($h | ForEach-Object { $_.ToString('x2') }) -join ''); " - " thumb = $_.Thumbprint; subject = $_.Subject; " - " notAfter = $_.NotAfter.ToString('s') } " + "foreach ($where in 'CurrentUser', 'LocalMachine') { " + " $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('My', $where); " + " try { $store.Open('ReadOnly') } catch { continue }; " + " foreach ($c in $store.Certificates) { " + " $eku = @(); " + " foreach ($x in $c.Extensions) { " + " if ($x -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { " + " foreach ($u in $x.EnhancedKeyUsages) { $eku += $u.Value } } }; " + " if ($eku -notcontains '%s') { continue }; " + " $h = [System.Security.Cryptography.SHA256]::Create().ComputeHash($c.RawData); " + " $out += [pscustomobject]@{ " + " sha256 = (($h | ForEach-Object { $_.ToString('x2') }) -join ''); " + " thumb = $c.Thumbprint; subject = $c.Subject; " + " notAfter = $c.NotAfter.ToString('s') } }; " + " $store.Close() " "}; $out | ConvertTo-Json -Compress" % CODE_SIGNING_OID ) entries = json.loads(powershell(script).strip() or "[]") diff --git a/CHANGELOG.md b/CHANGELOG.md index a4244e3..2cb0148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,33 @@ because it turns other people's test suites red. ## [Unreleased] +### Fixed + +- **Asking for damaged files and declaring they will be accepted is now refused + on the command line too.** A damaged file is one a reader was measured to + refuse, so `--expected accept` beside `--damage` asks for something nothing + can deliver. + + A recipe saying the same thing has always been refused. The command line was + not: it wrote the files and recorded in the manifest that a deliberately + broken file should be accepted, which is the one place this tool must not say + something untrue. `tfg generate --damage zero-head --expected accept` now + ends with exit code `2` and writes nothing, and a recipe still ends with `3` + and names the target the problem is in. + + Only `accept` is refused. `reject` is what damage already means, and + `sanitize` and `unspecified` are both real questions to ask about a broken + file - a system under test may be meant to repair it, or that may be the + point of the test - so all three still work, as does `--expected accept` on + files that are not damaged. + +- **The documentation website lists every command the tool has.** `tfg damage` + arrived in 0.3.0 and the page describing the commands still showed the other + nine, because that list was written out by hand. The page now takes the list + from the program itself, so a command added later cannot go missing from it, + and the site has a section explaining how to produce a file that is broken on + purpose. + ## [0.3.0] - 2026-09-09 ### Breaking diff --git a/README.md b/README.md index f85e991..ab4882e 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ tfg generate --format txt --size 1mb settings come from the flags | `--out
, so the alignment is content: a number in the template would be a
+// fourth copy of "the longest command is ten characters" and would go stale
+// the day an eleventh arrives.
+type Command struct {
+ Name string
+ Pad string
+ Summary string
+}
+
// Download says which architectures a system actually gets.
//
// Both lists are here because they differ, and a page that flattened them into
@@ -101,6 +121,12 @@ type Facts struct {
Presets []string
Downloads []Download
+ // Commands is what tfg --help prints, in the order it prints it, read out
+ // of that help rather than out of a list beside it. Taking it from the
+ // help is the point: it is the text a visitor is comparing the page
+ // against, so agreeing with anything else would prove the wrong thing.
+ Commands []string
+
// Year is fixed rather than taken from the clock. A footer that rendered
// the current year would make the committed pages differ from freshly
// rendered ones every first of January, and the guard would go red for a
@@ -172,19 +198,22 @@ type Page struct {
// Dir is the path prefix. It is empty for the language served at the root,
// which is the one search engines are pointed at by x-default.
//
-// Endings and Terms are the two places where a word has to exist for every
-// value the program can produce. Endings is keyed by the exit code written out
-// in decimal, Terms by the kind or unit exactly as the registry spells it.
+// Endings, Terms, Presets and Commands are the places where a word has to
+// exist for every value the program can produce, and a missing one is an error
+// rather than a gap left in English. Endings is keyed by the exit code written
+// out in decimal, Terms by the kind or unit exactly as the registry spells it,
+// Presets by the identifier, and Commands by the name tfg --help prints.
type Language struct {
- Code string `json:"code"`
- Name string `json:"name"`
- Dir string `json:"dir"`
- Words map[string]string `json:"words"`
- Endings map[string]string `json:"endings"`
- Terms map[string]string `json:"terms"`
- Presets map[string]string `json:"presets"`
- Pages []Page `json:"pages"`
- Faq []QA `json:"faq"`
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Dir string `json:"dir"`
+ Words map[string]string `json:"words"`
+ Endings map[string]string `json:"endings"`
+ Terms map[string]string `json:"terms"`
+ Presets map[string]string `json:"presets"`
+ Commands map[string]string `json:"commands"`
+ Pages []Page `json:"pages"`
+ Faq []QA `json:"faq"`
}
// Site is everything needed to render.
diff --git a/internal/site/view.go b/internal/site/view.go
index 8f07dfc..0b0215f 100644
--- a/internal/site/view.go
+++ b/internal/site/view.go
@@ -69,6 +69,7 @@ func (l Language) expand(f Facts) (Language, error) {
out.Endings = everyValue(l.Endings)
out.Terms = everyValue(l.Terms)
out.Presets = everyValue(l.Presets)
+ out.Commands = everyValue(l.Commands)
out.Pages = make([]Page, len(l.Pages))
for i, p := range l.Pages {
@@ -150,6 +151,35 @@ func (v view) PresetList() ([]Preset, error) {
return out, nil
}
+// CommandList is every command tfg --help prints, in that order, summarised in
+// the language being rendered.
+//
+// The padding is worked out here rather than in the template. The block is
+// inside a where spaces are content, and the width follows the longest
+// name - so an eleventh command longer than the tenth widens the column by
+// itself instead of leaving a template to be remembered.
+func (v view) CommandList() ([]Command, error) {
+ widest := 0
+ for _, name := range v.Facts.Commands {
+ if len(name) > widest {
+ widest = len(name)
+ }
+ }
+ out := make([]Command, 0, len(v.Facts.Commands))
+ for _, name := range v.Facts.Commands {
+ summary, ok := v.Lang.Commands[name]
+ if !ok {
+ return nil, fmt.Errorf("the command %q has no summary written in %s", name, v.Lang.Code)
+ }
+ out = append(out, Command{
+ Name: name,
+ Pad: strings.Repeat(" ", widest-len(name)+2),
+ Summary: summary,
+ })
+ }
+ return out, nil
+}
+
// AllowedOf says what one setting accepts, in the language being rendered.
//
// The numbers come from the registry and the words from the language file. A
diff --git a/web/content/en/docs.html b/web/content/en/docs.html
index fb03ad6..ee60a4c 100644
--- a/web/content/en/docs.html
+++ b/web/content/en/docs.html
@@ -8,15 +8,7 @@ Documentation
What commands are there?
Each one does a single thing:
- tfg generate produce files, from a recipe or from flags
-tfg validate check a recipe and write nothing
-tfg verify check a directory against a manifest
-tfg cleanup remove the files a manifest lists
-tfg recipe fmt print a recipe in its settled shape
-tfg preset build a set of files from a named test question
-tfg formats list the formats this build supports
-tfg version print the tool version
-tfg license print the licence and what it means for generated files
+ {{ template "commandList" . }}
@@ -41,6 +33,7 @@ How do I generate a single file of an exact size?
--out <dir>directory to write into
--seed <n>run seed. The same seed gives the same bytes
--set <k>=<v>a format setting, repeatable
+ --damage <name>break the files on purpose, repeatable and applied in order. Run tfg damage for the list
--expected <outcome>accept, reject, sanitize or unspecified
--dry-runcount and show, write nothing at all
--jsonwrite the manifest to standard output
@@ -49,6 +42,50 @@ How do I generate a single file of an exact size?
+
+ How do I make a file that is broken on purpose?
+
+ Every other file this tool writes is correct by construction, which answers two of the three
+ questions an upload validator asks. --damage answers the third one - does the file
+ open at all. The file is produced normally and then broken, so it still has the size you asked
+ for.
+
+ tfg generate --format png --size 2mb --damage zero-head --out ./out
+tfg generate --format png --size 2mb --damage zero-head:bytes=16 --out ./out
+
+ Settings go after a colon. The flag repeats, and the order you write them in is the order they
+ are applied. tfg damage lists what this build can do and what each one takes.
+
+ In a recipe the key is a list, of names or of settings:
+ targets:
+ - id: broken
+ format: png
+ count: 5
+ size: 2mb
+ damage:
+ - zero-head
+ - type: zero-head
+ bytes: 16
+
+ A damaged file gets expected: reject in the manifest, with the damage recorded
+ beside it. Two things are refused before anything is written, because each would otherwise
+ put a file on disk that the manifest describes wrongly:
+
+
+ - a file smaller than the damage needs, because it would come out unchanged
+ -
+
expected: accept beside a damage, because nothing could meet it. Write
+ sanitize if the system under test is meant to repair the file, or
+ unspecified if that is the question you are asking
+
+
+
+ A third one cannot be known in advance. If a damage runs and moves no byte, that file is
+ dropped rather than written - the run carries on, says which file it was, and ends with the
+ partial exit code.
+
+
+
What does a recipe look like?
diff --git a/web/content/en/site.json b/web/content/en/site.json
index 6b6ff3e..351aa25 100644
--- a/web/content/en/site.json
+++ b/web/content/en/site.json
@@ -97,6 +97,18 @@
"presets": {
"size-boundaries": "Is a size limit enforced exactly where it is declared?"
},
+ "commands": {
+ "generate": "produce files, from a recipe or from flags",
+ "validate": "check a recipe and write nothing",
+ "verify": "check a directory against a manifest",
+ "cleanup": "remove the files a manifest lists",
+ "recipe fmt": "print a recipe in its settled shape",
+ "preset": "build a set of files from a named test question",
+ "formats": "list the formats this build supports",
+ "damage": "list the ways this build can break a file on purpose",
+ "version": "print the tool version",
+ "license": "print the licence and what it means for generated files"
+ },
"terms": {
"oracleNone": "not applicable",
"int": "any whole number",
diff --git a/web/content/pl/docs.html b/web/content/pl/docs.html
index c114957..27363ef 100644
--- a/web/content/pl/docs.html
+++ b/web/content/pl/docs.html
@@ -8,15 +8,7 @@
Dokumentacja
Jakie s膮 komendy?
Ka偶da robi jedn膮 rzecz:
- tfg generate tworzy pliki, z przepisu albo z flag
-tfg validate sprawdza przepis i nic nie zapisuje
-tfg verify sprawdza katalog wzgl臋dem manifestu
-tfg cleanup usuwa pliki wypisane w manife艣cie
-tfg recipe fmt wypisuje przepis w postaci uporz膮dkowanej
-tfg preset buduje zestaw plik贸w z nazwanego pytania testowego
-tfg formats wypisuje formaty, kt贸re ta wersja obs艂uguje
-tfg version wypisuje wersj臋 narz臋dzia
-tfg license wypisuje licencj臋 i to, co znaczy dla wygenerowanych plik贸w
+ {{ template "commandList" . }}
@@ -41,6 +33,7 @@ Jak wygenerowa膰 jeden plik o dok艂adnym rozmiarze?
--out <katalog>katalog, do kt贸rego trafiaj膮 pliki
--seed <n>ziarno przebiegu. To samo ziarno daje te same bajty
--set <k>=<v>ustawienie formatu, mo偶na powtarza膰
+ --damage <nazwa>celowo psuje pliki, mo偶na powtarza膰, stosowane po kolei. List臋 wypisuje tfg damage
--expected <wynik>accept, reject, sanitize albo unspecified
--dry-runpolicz i poka偶, nie zapisuj niczego
--jsonwypisz manifest na standardowe wyj艣cie
@@ -49,6 +42,51 @@ Jak wygenerowa膰 jeden plik o dok艂adnym rozmiarze?
+
+ Jak zrobi膰 plik celowo zepsuty?
+
+ Ka偶dy inny plik, kt贸ry to narz臋dzie zapisuje, jest poprawny z definicji, co odpowiada na dwa
+ z trzech pyta艅 walidatora uploadu. --damage odpowiada na trzecie - czy plik w og贸le
+ si臋 otwiera. Plik powstaje normalnie i dopiero potem zostaje zepsuty, wi臋c dalej ma zam贸wiony
+ rozmiar.
+
+ tfg generate --format png --size 2mb --damage zero-head --out ./out
+tfg generate --format png --size 2mb --damage zero-head:bytes=16 --out ./out
+
+ Ustawienia id膮 po dwukropku. Flag臋 mo偶na powtarza膰, a kolejno艣膰 zapisu jest kolejno艣ci膮
+ stosowania. tfg damage wypisuje, co ta wersja umie i co ka偶de uszkodzenie
+ przyjmuje.
+
+ W przepisie klucz jest list膮, nazw albo ustawie艅:
+ targets:
+ - id: broken
+ format: png
+ count: 5
+ size: 2mb
+ damage:
+ - zero-head
+ - type: zero-head
+ bytes: 16
+
+ Uszkodzony plik dostaje w manife艣cie expected: reject, a obok niego zapisane
+ uszkodzenie. Dwie rzeczy s膮 odmawiane, zanim cokolwiek powstanie, bo ka偶da zostawi艂aby na
+ dysku plik, kt贸ry manifest opisuje nieprawdziwie:
+
+
+ - plik mniejszy ni偶 potrzebuje uszkodzenie, bo wyszed艂by nietkni臋ty
+ -
+
expected: accept obok uszkodzenia, bo nic nie mog艂oby tego spe艂ni膰. Napisz
+ sanitize, je艣li system pod testem ma plik naprawi膰, albo
+ unspecified, je艣li w艂a艣nie o to pytasz
+
+
+
+ Trzeciej rzeczy nie da si臋 wiedzie膰 z g贸ry. Je艣li uszkodzenie przebiegnie i nie ruszy ani
+ jednego bajtu, taki plik zostaje odrzucony zamiast zapisany - przebieg idzie dalej, m贸wi,
+ kt贸rego pliku to dotyczy艂o, i ko艅czy si臋 kodem cz臋艣ciowego wyniku.
+
+
+
Jak wygl膮da przepis?
diff --git a/web/content/pl/site.json b/web/content/pl/site.json
index 23aeefa..8328b64 100644
--- a/web/content/pl/site.json
+++ b/web/content/pl/site.json
@@ -97,6 +97,18 @@
"presets": {
"size-boundaries": "Czy limit rozmiaru dzia艂a dok艂adnie tam, gdzie jest zadeklarowany?"
},
+ "commands": {
+ "generate": "tworzy pliki, z przepisu albo z flag",
+ "validate": "sprawdza przepis i nic nie zapisuje",
+ "verify": "sprawdza katalog wzgl臋dem manifestu",
+ "cleanup": "usuwa pliki wypisane w manife艣cie",
+ "recipe fmt": "wypisuje przepis w postaci uporz膮dkowanej",
+ "preset": "buduje zestaw plik贸w z nazwanego pytania testowego",
+ "formats": "wypisuje formaty, kt贸re ta wersja obs艂uguje",
+ "damage": "wypisuje sposoby, kt贸rymi ta wersja umie celowo zepsu膰 plik",
+ "version": "wypisuje wersj臋 narz臋dzia",
+ "license": "wypisuje licencj臋 i to, co znaczy dla wygenerowanych plik贸w"
+ },
"terms": {
"oracleNone": "nie dotyczy",
"int": "dowolna liczba ca艂kowita",
diff --git a/web/public/docs/index.html b/web/public/docs/index.html
index 4a36072..b54fd41 100644
--- a/web/public/docs/index.html
+++ b/web/public/docs/index.html
@@ -91,6 +91,7 @@
What commands are there?
tfg recipe fmt print a recipe in its settled shape
tfg preset build a set of files from a named test question
tfg formats list the formats this build supports
+tfg damage list the ways this build can break a file on purpose
tfg version print the tool version
tfg license print the licence and what it means for generated files
@@ -117,6 +118,7 @@ How do I generate a single file of an exact size?
--out <dir>directory to write into
--seed <n>run seed. The same seed gives the same bytes
--set <k>=<v>a format setting, repeatable
+ --damage <name>break the files on purpose, repeatable and applied in order. Run tfg damage for the list
--expected <outcome>accept, reject, sanitize or unspecified
--dry-runcount and show, write nothing at all
--jsonwrite the manifest to standard output
@@ -125,6 +127,50 @@ How do I generate a single file of an exact size?
+
+ How do I make a file that is broken on purpose?
+
+ Every other file this tool writes is correct by construction, which answers two of the three
+ questions an upload validator asks. --damage answers the third one - does the file
+ open at all. The file is produced normally and then broken, so it still has the size you asked
+ for.
+
+ tfg generate --format png --size 2mb --damage zero-head --out ./out
+tfg generate --format png --size 2mb --damage zero-head:bytes=16 --out ./out
+
+ Settings go after a colon. The flag repeats, and the order you write them in is the order they
+ are applied. tfg damage lists what this build can do and what each one takes.
+
+ In a recipe the key is a list, of names or of settings:
+ targets:
+ - id: broken
+ format: png
+ count: 5
+ size: 2mb
+ damage:
+ - zero-head
+ - type: zero-head
+ bytes: 16
+
+ A damaged file gets expected: reject in the manifest, with the damage recorded
+ beside it. Two things are refused before anything is written, because each would otherwise
+ put a file on disk that the manifest describes wrongly:
+
+
+ - a file smaller than the damage needs, because it would come out unchanged
+ -
+
expected: accept beside a damage, because nothing could meet it. Write
+ sanitize if the system under test is meant to repair the file, or
+ unspecified if that is the question you are asking
+
+
+
+ A third one cannot be known in advance. If a damage runs and moves no byte, that file is
+ dropped rather than written - the run carries on, says which file it was, and ends with the
+ partial exit code.
+
+
+
What does a recipe look like?
diff --git a/web/public/pl/dokumentacja/index.html b/web/public/pl/dokumentacja/index.html
index 21de9ad..84446c2 100644
--- a/web/public/pl/dokumentacja/index.html
+++ b/web/public/pl/dokumentacja/index.html
@@ -91,6 +91,7 @@
Jakie s膮 komendy?
tfg recipe fmt wypisuje przepis w postaci uporz膮dkowanej
tfg preset buduje zestaw plik贸w z nazwanego pytania testowego
tfg formats wypisuje formaty, kt贸re ta wersja obs艂uguje
+tfg damage wypisuje sposoby, kt贸rymi ta wersja umie celowo zepsu膰 plik
tfg version wypisuje wersj臋 narz臋dzia
tfg license wypisuje licencj臋 i to, co znaczy dla wygenerowanych plik贸w
@@ -117,6 +118,7 @@ --out <katalog>--seed <n>--set <k>=<v>--damage <nazwa>tfg damage--expected <wynik>accept, reject, sanitize albo unspecified--dry-run--json
+ Ka偶dy inny plik, kt贸ry to narz臋dzie zapisuje, jest poprawny z definicji, co odpowiada na dwa
+ z trzech pyta艅 walidatora uploadu. --damage odpowiada na trzecie - czy plik w og贸le
+ si臋 otwiera. Plik powstaje normalnie i dopiero potem zostaje zepsuty, wi臋c dalej ma zam贸wiony
+ rozmiar.
+
tfg generate --format png --size 2mb --damage zero-head --out ./out
+tfg generate --format png --size 2mb --damage zero-head:bytes=16 --out ./out
+
+ Ustawienia id膮 po dwukropku. Flag臋 mo偶na powtarza膰, a kolejno艣膰 zapisu jest kolejno艣ci膮
+ stosowania. tfg damage wypisuje, co ta wersja umie i co ka偶de uszkodzenie
+ przyjmuje.
+
W przepisie klucz jest list膮, nazw albo ustawie艅:
+targets:
+ - id: broken
+ format: png
+ count: 5
+ size: 2mb
+ damage:
+ - zero-head
+ - type: zero-head
+ bytes: 16
+
+ Uszkodzony plik dostaje w manife艣cie expected: reject, a obok niego zapisane
+ uszkodzenie. Dwie rzeczy s膮 odmawiane, zanim cokolwiek powstanie, bo ka偶da zostawi艂aby na
+ dysku plik, kt贸ry manifest opisuje nieprawdziwie:
+
expected: accept obok uszkodzenia, bo nic nie mog艂oby tego spe艂ni膰. Napisz
+ sanitize, je艣li system pod testem ma plik naprawi膰, albo
+ unspecified, je艣li w艂a艣nie o to pytasz
+ + Trzeciej rzeczy nie da si臋 wiedzie膰 z g贸ry. Je艣li uszkodzenie przebiegnie i nie ruszy ani + jednego bajtu, taki plik zostaje odrzucony zamiast zapisany - przebieg idzie dalej, m贸wi, + kt贸rego pliku to dotyczy艂o, i ko艅czy si臋 kodem cz臋艣ciowego wyniku. +
+diff --git a/web/templates/partials.html b/web/templates/partials.html index c85d558..6a504a2 100644 --- a/web/templates/partials.html +++ b/web/templates/partials.html @@ -195,6 +195,11 @@ {{- end -}} +{{- define "commandList" -}} +
{{ range $i, $c := .CommandList }}{{ if $i }}
+{{ end }}tfg {{ $c.Name }}{{ $c.Pad }}{{ $c.Summary }}{{ end }}
+{{- end -}}
+
{{- define "downloadCta" -}}