diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a15f917..9b7ef2f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -600,7 +600,7 @@ jobs:
# in somebody else's file.
run: |
set -euo pipefail
- watched='internal/format/registry.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod'
+ watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod'
# On a pull request there is no "before" - the field belongs to a push
# - so this asked for something empty and every pull request answered
# "touched". That quietly undid the decision of 2026-08-20, because
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c407217..13c4d1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -58,6 +58,44 @@ because it turns other people's test suites red.
### Added
+- **Files can be broken on purpose.** A target takes `damage`, and the files it
+ produces are ones a reader refuses:
+
+ ```yaml
+ targets:
+ - id: broken-uploads
+ format: png
+ size: 20kb
+ count: 10
+ damage: [zero-head]
+ ```
+
+ or from the command line, repeatable and applied in the order given:
+
+ ```
+ tfg generate --format png --size 20kb --damage zero-head:bytes=16
+ ```
+
+ Until now every file this tool wrote was well formed, so the third question
+ an upload validator asks - "does it open" - was one nothing here could put to
+ it.
+
+ The file still comes out **exactly** the size you asked for. What changes is
+ the content, and the manifest records what was done to it and says the file
+ is expected to be rejected.
+
+ There is one damage in this release, `zero-head`, which overwrites the
+ opening bytes with zeros. It was chosen because all twenty four formats have
+ something that refuses the result - measured, not assumed - and because it
+ does not change the length. `tfg formats` is unchanged and no existing file
+ moves a byte: a run that does not ask for damage goes down the path it always
+ did.
+
+ Two things it refuses rather than doing quietly. A file smaller than the
+ damage is refused before anything is written, naming a size that would work.
+ And a damage that would leave the bytes untouched stops the run, because a
+ whole file described as broken is worse than no file at all.
+
- **HTML files can be a fragment instead of a whole page.** A new setting on
`html`: `structure`, which takes `document` or `fragment`. It defaults to the
whole page these files have always been, so a recipe that says nothing gets
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 9ebdd14..16cdceb 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -16,6 +16,7 @@ import (
"strings"
"syscall"
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
@@ -268,6 +269,70 @@ func (p propertyFlag) Set(v string) error {
return nil
}
+// repeatedFlags are the flags a person may write more than once.
+//
+// One piece rather than two fields on the options struct, because the type was
+// at the crowding band and the answer to that is to move state out. They belong
+// together anyway: both map onto a block of a recipe rather than onto a single
+// line of one.
+type repeatedFlags struct {
+ props propertyFlag
+ damage damageFlag
+}
+
+// damageFlag collects repeated --damage entries, in the order they were given.
+//
+// A list rather than a map, which is the difference from --set beside it:
+// order is part of what a chain of damages means, and the same damage twice
+// with different settings is a legitimate thing to ask for. --set refuses a
+// repeat because one of two values would be lost silently, and here neither is.
+//
+// The settings ride after a colon so one entry stays one argument:
+//
+// --damage zero-head
+// --damage zero-head:bytes=16
+// --damage zero-head:bytes=16,other=2
+type damageFlag struct{ chain damage.Chain }
+
+func (d *damageFlag) String() string { return d.chain.String() }
+
+func (d *damageFlag) Set(v string) error {
+ id, settings, hasSettings := strings.Cut(v, ":")
+ if id == "" {
+ return fmt.Errorf("expected the name of a damage, got %q", v)
+ }
+ values, err := damageSettings(id, settings, hasSettings)
+ if err != nil {
+ return err
+ }
+ d.chain = append(d.chain, damage.Spec{ID: id, Values: values})
+ return nil
+}
+
+// damageSettings reads the name=value pairs after the colon.
+//
+// Its own function rather than a block inside Set, because the shape gates
+// count how deep a reader has to follow and this was the third level.
+func damageSettings(id, settings string, stated bool) (damage.Values, error) {
+ values := damage.Values{}
+ if !stated {
+ return values, nil
+ }
+ for _, pair := range strings.Split(settings, ",") {
+ key, value, found := strings.Cut(pair, "=")
+ if !found || key == "" {
+ return nil, fmt.Errorf("expected name=value after the colon, got %q", pair)
+ }
+ if _, exists := values[key]; exists {
+ // The same reason --set gives: one of the two would be lost and
+ // nobody would know which.
+ return nil, fmt.Errorf("%s is set more than once on %s", key, id)
+ }
+ values[key] = value
+ }
+ return values, nil
+}
+
// args2 rebuilds the command as it would have to be typed to run again.
//
// It goes into the manifest, where its whole job is to be re-runnable, and it
diff --git a/internal/cli/errors.go b/internal/cli/errors.go
index 7c84f06..a0c8eb4 100644
--- a/internal/cli/errors.go
+++ b/internal/cli/errors.go
@@ -12,6 +12,7 @@ import (
"syscall"
"github.com/donislawdev/TestingFilesGenerator/internal/audit"
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
@@ -201,6 +202,21 @@ func classifyRequest(err error) (int, bool) {
if errors.As(err, &unknownPreset) {
return ExitUsage, true
}
+ // A damage this build does not know is a typo in the invocation, the same
+ // class as an unknown preset - a recipe naming one is refused while the
+ // recipe is read, so anything reaching here came off the command line.
+ var unknownDamage *damage.UnknownError
+ if errors.As(err, &unknownDamage) {
+ return ExitUsage, true
+ }
+ // A file smaller than the damage it was given is the same class as a size
+ // below a format's minimum: the request is well formed and nothing here can
+ // deliver it. It gets the same code for that reason rather than by
+ // resemblance.
+ var tooSmall *damage.TooSmallError
+ if errors.As(err, &tooSmall) {
+ return ExitFormat, true
+ }
if code, ok := classifyFormat(err); ok {
return code, true
}
diff --git a/internal/cli/generate.go b/internal/cli/generate.go
index 45abb57..cef196f 100644
--- a/internal/cli/generate.go
+++ b/internal/cli/generate.go
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
@@ -37,7 +38,11 @@ type generateOpts struct {
clean bool
dryRun bool
asJSON bool
- props propertyFlag
+ // The flags a person may write more than once, in one piece rather than
+ // two fields. They are one thing on the screen and one thing in a recipe -
+ // what was stated repeatedly - and grouping them is what moving state out
+ // means when a type is at the crowding band.
+ repeated repeatedFlags
}
func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.Writer)) {
@@ -65,8 +70,19 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.
// Twenty five formats with a dozen properties each would give a surface
// nobody reads in --help, and this maps one to one onto the properties
// block of a recipe, so both surfaces speak the same words.
- g.props = propertyFlag{}
- fs.Var(&g.props, "set", "format property, repeatable: --set width=1920 --set height=1080")
+ g.repeated.props = propertyFlag{}
+ fs.Var(&g.repeated.props, "set", "format property, repeatable: --set width=1920 --set height=1080")
+
+ // Repeatable like --set, and for the same reason, but a list rather than a
+ // map: the order damages are applied in is part of what they mean.
+ // The names come from the registry rather than being typed here. The first
+ // version of this line ended "run tfg damage to see what there is" and
+ // there is no such command - a sentence in shipped help promising
+ // something that does not exist, which is the class this project calls
+ // prose with an expiry date. Built from Names() it cannot say that again.
+ fs.Var(&g.repeated.damage, "damage", "break the files on purpose, repeatable and applied in order: "+
+ "--damage zero-head, or --damage zero-head:bytes=16. This build has: "+
+ strings.Join(damage.Names(), ", "))
usage := func(w io.Writer) {
fmt.Fprint(w, `tfg generate - produce files.
@@ -312,15 +328,14 @@ func targetsFromFlags(g *generateOpts, given map[string]bool, errOut io.Writer)
ID: g.id,
Format: g.formatID,
Sizes: sizes,
- SizeIsRange: g.sizeRange != "",
- SizeMin: rangeLow,
- SizeMax: rangeHigh,
+ Range: engine.SizeRange{Used: g.sizeRange != "", Min: rangeLow, Max: rangeHigh},
BoundaryLimit: boundaryLimit,
NameTmpl: g.name,
Label: !g.clean,
Expected: g.expected,
ExpectedReason: g.expectedReason,
- Properties: g.props,
+ Properties: g.repeated.props,
+ Damage: g.repeated.damage.chain,
}}, ExitOK
}
@@ -599,9 +614,7 @@ func engineTarget(t recipe.Target, label bool) engine.Target {
Sizes: t.Sizes,
Contains: contentsOf(t),
SizeFromContents: t.SizeFromContents,
- SizeIsRange: t.SizeIsRange,
- SizeMin: t.SizeMin,
- SizeMax: t.SizeMax,
+ Range: engine.SizeRange(t.Range),
BoundaryLimit: t.BoundaryLimit,
NameTmpl: t.Name,
Label: label,
@@ -609,6 +622,7 @@ func engineTarget(t recipe.Target, label bool) engine.Target {
ExpectedReason: t.ExpectedReason,
Group: t.Group,
Properties: t.Properties,
+ Damage: t.Damage,
}
}
diff --git a/internal/damage/apply.go b/internal/damage/apply.go
new file mode 100644
index 0000000..9203909
--- /dev/null
+++ b/internal/damage/apply.go
@@ -0,0 +1,244 @@
+package damage
+
+import (
+ "fmt"
+ "io"
+ "sort"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+)
+
+// Defaults are the declared defaults, for the values a caller left out.
+func (d Descriptor) Defaults() Values {
+ out := Values{}
+ for _, p := range d.Parameters {
+ if p.Default != "" {
+ out[p.Name] = p.Default
+ }
+ }
+ return out
+}
+
+// ParameterNames is what this damage takes, in the declared order.
+func (d Descriptor) ParameterNames() []string {
+ out := make([]string, 0, len(d.Parameters))
+ for _, p := range d.Parameters {
+ out = append(out, p.Name)
+ }
+ return out
+}
+
+// CheckEach is every problem with the values stated, in a stable order.
+//
+// All of them rather than the first, because RC7 says a recipe comes back with
+// everything wrong with it. The wording is not written here: Allows and Instead
+// are methods on the declaration, so a damage parameter and a format setting
+// refuse in the same voice without either copying the other's sentences.
+//
+// The refusal type is format.PropertyValueError, whose first field is called
+// Format and here holds the id of a damage. The field name is narrow and the
+// type is right - it is the one refusal in this tree that a form can take
+// apart by name - so it is used as it is and the narrowness is written down
+// rather than fixed by a rename that would touch every format.
+func (d Descriptor) CheckEach(v Values) []error {
+ declared := make(map[string]format.Property, len(d.Parameters))
+ for _, p := range d.Parameters {
+ declared[p.Name] = p
+ }
+
+ stated := make([]string, 0, len(v))
+ for name := range v {
+ stated = append(stated, name)
+ }
+ sort.Strings(stated)
+
+ var bad []error
+ for _, name := range stated {
+ p, known := declared[name]
+ if !known {
+ bad = append(bad, &format.UnknownPropertyError{
+ Format: d.ID, Key: name, Known: d.ParameterNames(),
+ })
+ continue
+ }
+ if wrong := d.checkOne(p, v[name]); wrong != nil {
+ bad = append(bad, wrong)
+ }
+ }
+ return bad
+}
+
+// checkOne is one stated value against one declaration.
+//
+// Lifted out of the loop above rather than nested inside it, because the shape
+// gates count how deep a reader has to follow and three was the line. It is
+// also the whole of what "is this value allowed" means here, which is a better
+// reason to have it than the count.
+//
+// An empty value means "not stated", the same as leaving it out - that is what
+// an unset flag and an empty recipe entry both look like by the time they
+// arrive here.
+func (d Descriptor) checkOne(p format.Property, raw string) error {
+ if raw == "" {
+ return nil
+ }
+ why := p.Allows(raw)
+ if why == "" {
+ return nil
+ }
+ return &format.PropertyValueError{
+ Format: d.ID, Key: p.Name, Value: raw, Reason: why, Remedy: p.Instead(),
+ }
+}
+
+// Check is the first problem with the values stated, for the one-target path
+// from the command line flags where there is no form to lay four of them out
+// on.
+func (d Descriptor) Check(v Values) error {
+ if bad := d.CheckEach(v); len(bad) > 0 {
+ return bad[0]
+ }
+ return nil
+}
+
+// Chain is a list of damages in the order they are applied.
+//
+// A list from the first day rather than a single value, because composition is
+// a requirement rather than an extension, and because the shape has to carry it
+// before anything is written against the contract. Order is significant and
+// recorded: truncating after appending a tail removes what was appended, so the
+// same two entries in the other order are different bytes, and D11 promises
+// those bytes do not move.
+type Chain []Spec
+
+// Floor is the smallest file every damage in the chain can be applied to.
+//
+// The largest of the floors rather than their sum: each damage is applied to
+// the whole file that reaches it, so a chain is bounded by its most demanding
+// member. That is true for length preserving damage, which is all this build
+// has - a length CHANGING damage makes the later members see a different file,
+// and that arithmetic arrives with truncate rather than being guessed at here.
+func (c Chain) Floor() (int64, string, error) {
+ var floor int64
+ var owner string
+ for _, s := range c {
+ d, err := Get(s.ID)
+ if err != nil {
+ return 0, "", err
+ }
+ if f := d.Floor(withDefaults(d, s.Values)); f > floor {
+ floor, owner = f, s.ID
+ }
+ }
+ return floor, owner, nil
+}
+
+// Open builds the chain as one writer, with the first damage of the list
+// outermost so that the bytes meet them in the order written.
+//
+// Returns the streams as well, because whether each one changed anything is a
+// question asked per damage rather than once at the end - two damages can
+// cancel out, and a chain whose second member was idle would otherwise pass.
+func (c Chain) Open(out io.Writer) (io.Writer, []Stream, error) {
+ w := out
+ streams := make([]Stream, 0, len(c))
+ // Built back to front so that the first entry ends up outermost.
+ for i := len(c) - 1; i >= 0; i-- {
+ d, err := Get(c[i].ID)
+ if err != nil {
+ return nil, nil, err
+ }
+ s, err := d.Open(withDefaults(d, c[i].Values), w)
+ if err != nil {
+ return nil, nil, err
+ }
+ streams = append(streams, s)
+ w = s
+ }
+ // Back into the order of the list, so a caller reporting on stream i is
+ // reporting on entry i.
+ for l, r := 0, len(streams)-1; l < r; l, r = l+1, r-1 {
+ streams[l], streams[r] = streams[r], streams[l]
+ }
+ return w, streams, nil
+}
+
+// Idle is the first damage of the chain that changed nothing, by name, or an
+// empty string when every one of them moved a byte.
+func (c Chain) Idle(streams []Stream) string {
+ for i, s := range streams {
+ if i < len(c) && !s.Touched() {
+ return c[i].ID
+ }
+ }
+ return ""
+}
+
+// withDefaults fills in what the caller left out, so a damage never has to ask
+// twice whether a value was stated.
+func withDefaults(d Descriptor, v Values) Values {
+ out := d.Defaults()
+ for name, value := range v {
+ if value != "" {
+ out[name] = value
+ }
+ }
+ return out
+}
+
+// String is the chain as a person writes it, for a message and for the record
+// of the command a run was made with.
+func (c Chain) String() string {
+ out := make([]string, 0, len(c))
+ for _, s := range c {
+ out = append(out, s.String())
+ }
+ return fmt.Sprint(out)
+}
+
+// Resolved is the settings this entry was applied with, defaults filled in.
+//
+// Filled in rather than left as written, because the manifest answers "what
+// was done to this file" and a reader of it should not have to know what the
+// default was in the build that wrote it. Nil when the damage takes no
+// settings, so the key is absent rather than empty.
+func (s Spec) Resolved() map[string]string {
+ d, err := Get(s.ID)
+ if err != nil {
+ // A damage the registry does not know cannot reach here through either
+ // surface - the recipe refuses it and the flags refuse it. Returning
+ // what was stated beats returning nothing, because a record of an
+ // impossible state should still say what it saw.
+ if len(s.Values) == 0 {
+ return nil
+ }
+ return s.Values
+ }
+ out := withDefaults(d, s.Values)
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+// String is one entry as a person writes it on the command line.
+func (s Spec) String() string {
+ if len(s.Values) == 0 {
+ return s.ID
+ }
+ names := make([]string, 0, len(s.Values))
+ for name := range s.Values {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ out := s.ID
+ for i, name := range names {
+ sep := ","
+ if i == 0 {
+ sep = ":"
+ }
+ out += sep + name + "=" + s.Values[name]
+ }
+ return out
+}
diff --git a/internal/damage/damage.go b/internal/damage/damage.go
new file mode 100644
index 0000000..0c6641a
--- /dev/null
+++ b/internal/damage/damage.go
@@ -0,0 +1,186 @@
+// Package damage is what a file can be broken with, and the registry holding
+// the kinds this build knows.
+//
+// The tool answers questions about SIZE and about NAME. It does not answer the
+// question about bad CONTENT, and an upload validator asks three things: the
+// size, the type, and whether the file opens. The third is unreachable here,
+// because every file this tool writes is well formed by definition - the
+// oracles see to that. Damage is the axis that makes the third question
+// askable.
+//
+// Why it is an axis of the TARGET rather than a property of a format: a
+// property is declared per format, so "corrupt" as a property would be a
+// hundred declarations of one thing, a hundred registry tests, and a hundred
+// places to forget. As an axis it works for a format added tomorrow without
+// that format being touched. Measured on 2026-09-08 across 24 formats: five
+// format agnostic damages produce 86 testable pairs with zero lines of code
+// per format. docs/CORRUPTION-ARCHITECTURE-2026-09-08.md carries the numbers.
+//
+// What this package does NOT do is decide whether a damaged file is worth
+// writing. That is the witness rule - a pair with no judge able to refuse the
+// result is a pair this tool does not offer - and it belongs with the oracles,
+// because it is a measurement rather than a declaration.
+package damage
+
+import (
+ "fmt"
+ "io"
+ "sort"
+ "sync"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+)
+
+// Values are the parameter values of one damage, as a recipe or a flag wrote
+// them.
+//
+// Strings rather than a typed struct, and the same shape a format's properties
+// arrive in, because it is the same question asked of a different thing: the
+// declaration says what a value may be, and one piece of code answers for all
+// of them.
+type Values map[string]string
+
+// Stream is one damage sitting in the write path, between the generator and
+// the file.
+type Stream interface {
+ io.Writer
+
+ // Touched says whether at least one byte leaving here differs from the
+ // byte that came in. Read after the last write.
+ //
+ // A damage that changed nothing is a failed run rather than a file: the
+ // manifest would say expected: reject about a file every reader accepts,
+ // which is the tool lying exactly where its whole value is. So somebody
+ // has to answer this question, and the cheap answer is not the obvious
+ // one.
+ //
+ // Measured in internal/engine/parallel.go: the write path computes ONE
+ // checksum, of the bytes going to the file. Comparing "before" against
+ // "after" would therefore cost a SECOND sha256 of every file. A damage
+ // knows for free instead - zero-head has to read the bytes before it
+ // overwrites them, so the answer is a handful of byte compares. That is
+ // why this sits on the damage rather than on the engine.
+ Touched() bool
+}
+
+// Descriptor is one kind of damage.
+type Descriptor struct {
+ // ID is the name a recipe writes. A public name under untouchable rule 10.
+ ID string
+
+ // Detail is the one sentence a person reads, beside the field in a window
+ // and under the name on the command line.
+ //
+ // It says what the damage DOES to the bytes, not what it is for. A name
+ // and a sentence about intent lie about half the formats already at
+ // twenty four: zeroing the first eight bytes has a witness in txt, md and
+ // log, which have no signature at all - the witness is the structural
+ // check refusing eight zero bytes in text, not a killed magic number.
+ Detail string
+
+ // Parameters are declared the way a format declares its settings.
+ //
+ // The type is format.Property on purpose rather than a second mechanism.
+ // It already carries the name, the kind, a range or a closed set, a
+ // default and a sentence, and with it come validation, one voice of
+ // refusal, exit code 4 and a field drawn by the window with nothing added
+ // here. preset.Preset.Parameters is the same type for the same reason.
+ Parameters []format.Property
+
+ // Floor is the smallest file this damage can be given, for the values
+ // stated.
+ //
+ // It exists because a file can be smaller than its own damage. Measured on
+ // 2026-09-09: txt declares a minimum of 0 B and a zero byte file really is
+ // produced, exit 0 - so zero-head on it would change nothing at all. That
+ // has to be a refusal in the PLAN rather than a failure part way through,
+ // because an invalid recipe writes no files at all.
+ //
+ // It is also what the witness matrix is measured at, which is the whole
+ // reason one number does two jobs: a witness measured at 20 kB does not
+ // answer for a file of 300 B, and that was measured too.
+ Floor func(Values) int64
+
+ // Open builds the transformation for one file, writing on to out.
+ Open func(Values, io.Writer) (Stream, error)
+}
+
+// Spec is one entry of a damage list: which damage, and what was said to it.
+type Spec struct {
+ ID string
+ Values Values
+}
+
+var (
+ mu sync.RWMutex
+ registry = map[string]Descriptor{}
+)
+
+// Register adds a kind of damage. It panics on a duplicate or an incomplete
+// declaration, because both are programming mistakes rather than conditions to
+// handle at runtime - the same bargain the format registry makes.
+func Register(d Descriptor) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ if d.ID == "" {
+ panic("damage: registered a descriptor with no id")
+ }
+ if _, exists := registry[d.ID]; exists {
+ panic(fmt.Sprintf("damage: %q is registered twice", d.ID))
+ }
+ if d.Open == nil {
+ panic(fmt.Sprintf("damage: %q has nothing to apply", d.ID))
+ }
+ if d.Floor == nil {
+ panic(fmt.Sprintf("damage: %q does not say how small a file it can break", d.ID))
+ }
+ if d.Detail == "" {
+ panic(fmt.Sprintf("damage: %q has no sentence describing it", d.ID))
+ }
+ for i := range d.Parameters {
+ format.SortChoices(d.Parameters[i].Choices)
+ }
+ registry[d.ID] = d
+}
+
+// Get is one kind of damage by the name a recipe writes.
+func Get(id string) (Descriptor, error) {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ d, ok := registry[id]
+ if !ok {
+ return Descriptor{}, &UnknownError{ID: id, Known: names()}
+ }
+ return d, nil
+}
+
+// All is every kind of damage this build knows, in a stable order.
+func All() []Descriptor {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ out := make([]Descriptor, 0, len(registry))
+ for _, id := range names() {
+ out = append(out, registry[id])
+ }
+ return out
+}
+
+// Names is every kind of damage by name, in a stable order.
+func Names() []string {
+ mu.RLock()
+ defer mu.RUnlock()
+ return names()
+}
+
+// names is the sorted ids. The caller holds the lock.
+func names() []string {
+ out := make([]string, 0, len(registry))
+ for id := range registry {
+ out = append(out, id)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/internal/damage/refusals.go b/internal/damage/refusals.go
new file mode 100644
index 0000000..9f9c732
--- /dev/null
+++ b/internal/damage/refusals.go
@@ -0,0 +1,148 @@
+package damage
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
+)
+
+// The refusals this package produces, each in the four parts D6 asks for: what
+// happened, why, what is allowed, and what to do instead. Kept apart from
+// Error() so a report can lay them out without printing the same list twice,
+// which is the shape internal/format/refusals.go settled and internal/cli
+// reads through What/Why/Instead.
+
+// UnknownError is a damage this build does not know.
+type UnknownError struct {
+ ID string
+ Known []string
+}
+
+// What happened, without the list of names.
+func (e *UnknownError) What() string {
+ return fmt.Sprintf("there is no damage called %q", e.ID)
+}
+
+// Why this is refused rather than skipped.
+//
+// Silently ignoring it would produce an intact file whose manifest says it is
+// broken, which is untouchable rule 6 read backwards: the silence is not a
+// missing file but a missing hole in one.
+func (e *UnknownError) Why() string {
+ return "a damage has to be one this build knows, and one it does not would leave the file intact while the manifest called it broken"
+}
+
+// Instead names what there is, from the registry rather than from a list.
+func (e *UnknownError) Instead() string {
+ if len(e.Known) == 0 {
+ return "remove the damage line"
+ }
+ return "use one of: " + strings.Join(e.Known, ", ")
+}
+
+func (e *UnknownError) Error() string {
+ if len(e.Known) == 0 {
+ return e.What()
+ }
+ return e.What() + ". This build has: " + strings.Join(e.Known, ", ")
+}
+
+// TooSmallError is a file smaller than the damage it was given.
+//
+// It is a refusal in the plan rather than a failure part way through, because
+// the fault is in the recipe and is visible before the first byte. Measured on
+// 2026-09-09: txt declares a minimum of 0 B and a zero byte file really is
+// written, so this is reachable rather than theoretical.
+type TooSmallError struct {
+ Damage string
+ Requested int64
+ Floor int64
+}
+
+// AboutSetting puts this on the size box rather than at the foot of the form,
+// because the size is the half a person can change without giving up the
+// damage.
+func (e *TooSmallError) AboutSetting() string { return "size" }
+
+func (e *TooSmallError) What() string {
+ return fmt.Sprintf("%s needs at least %s and the file is %s",
+ e.Damage, core.ExactBytes(e.Floor), core.ExactBytes(e.Requested))
+}
+
+func (e *TooSmallError) Why() string {
+ return "a file smaller than the damage would come out unchanged, and an unchanged file described as broken is the one thing this tool must not write"
+}
+
+func (e *TooSmallError) Instead() string {
+ return fmt.Sprintf("Ask for %s or more, or take the damage off this target.",
+ core.ExactBytes(e.Floor))
+}
+
+func (e *TooSmallError) Error() string {
+ return e.What() + ". " + e.Instead()
+}
+
+// NoChangeError is a damage that ran and moved nothing.
+//
+// Not a warning. The file would be well formed, every reader would accept it,
+// and the manifest would say expected: reject about it - the tool lying in the
+// exact place its value lives. It ends the run.
+//
+// It is per damage rather than per file on purpose. Damages compose, and two
+// of them can cancel out - a second zeroing of a band already zeroed does
+// nothing - so the question has to be asked after EACH step rather than once
+// at the end, which would pass a list whose second entry was idle.
+type NoChangeError struct {
+ Damage string
+ File string
+}
+
+func (e *NoChangeError) What() string {
+ return fmt.Sprintf("%s changed nothing in %s", e.Damage, e.File)
+}
+
+func (e *NoChangeError) Why() string {
+ return "the file would be accepted by every reader while the manifest called it broken, so the run stops rather than writing it"
+}
+
+func (e *NoChangeError) Instead() string {
+ return "give the damage a larger file, different settings, or take it off this target"
+}
+
+func (e *NoChangeError) Error() string {
+ return e.What() + ". " + e.Why()
+}
+
+// ExpectationConflictError is a target that damages a file and expects it to
+// be accepted.
+//
+// Only accept. reject is what damage implies and is the default, while
+// sanitize and unspecified are both sensible questions to ask about a broken
+// file - a system under test may well be expected to repair it, or the answer
+// may be the point of the test. Owner's call on 2026-09-09: refuse the one
+// that cannot be true, and leave the two that can.
+type ExpectationConflictError struct {
+ Outcome string
+}
+
+// AboutSetting puts this on the expectation rather than on the damage, because
+// the damage is what the target is FOR and the expectation is the line that
+// disagrees with it.
+func (e *ExpectationConflictError) AboutSetting() string { return "expected" }
+
+func (e *ExpectationConflictError) What() string {
+ return fmt.Sprintf("this target damages the file and expects %q", e.Outcome)
+}
+
+func (e *ExpectationConflictError) Why() string {
+ return "a damaged file is one a judge was measured to refuse, so expecting it to be accepted is an expectation nothing could meet"
+}
+
+func (e *ExpectationConflictError) Instead() string {
+ return "leave expected out and get reject, or write sanitize if the system under test is meant to repair the file, or unspecified if that is the question"
+}
+
+func (e *ExpectationConflictError) Error() string {
+ return e.What() + ". " + e.Instead()
+}
diff --git a/internal/damage/zerohead.go b/internal/damage/zerohead.go
new file mode 100644
index 0000000..82918c6
--- /dev/null
+++ b/internal/damage/zerohead.go
@@ -0,0 +1,141 @@
+package damage
+
+import (
+ "io"
+ "strconv"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+)
+
+// Setting names. Public names under untouchable rule 10, so they are spelled
+// once.
+const (
+ ZeroHead = "zero-head"
+
+ SettingBytes = "bytes"
+)
+
+const (
+ // smallestHead is the fewest bytes worth zeroing, and it is a MEASUREMENT
+ // rather than a round number.
+ //
+ // Measured 2026-09-09 across all twenty four formats, on a 20 kB file:
+ // zeroing 1 byte has 20 witnesses, 2 bytes has 20, and 4, 8, 16 and 64
+ // bytes all have 24. So below four the tool would offer a damage that four
+ // formats produce and nothing can refuse - a file with no witness, which
+ // is the one thing this axis must not write.
+ //
+ // This is the same rule the column ceiling followed: the bound belongs to
+ // somebody else's reader, not to what our code can do. Our code can zero
+ // one byte perfectly well.
+ smallestHead = 4
+
+ // largestHead is where the parameter stops.
+ //
+ // Not a measured refusal - 64 bytes had 24 witnesses and nothing suggests
+ // a ceiling above it behaves differently. It is here because a range with
+ // no upper end draws a field a person can put anything in, and because a
+ // head larger than the file is already refused by the floor. 4096 is the
+ // smallest round number comfortably above every signature and header this
+ // tool writes.
+ largestHead = 4096
+
+ defaultHead = 8
+)
+
+func init() {
+ Register(Descriptor{
+ ID: ZeroHead,
+ // What it does to the bytes, not what it is for. An intent-shaped
+ // sentence would be wrong about a third of the formats already: txt,
+ // md and log have no signature at all, and their witness is the
+ // structural check refusing zero bytes inside text.
+ Detail: "Overwrites the first bytes of the file with zeros, leaving its length alone. " +
+ "Most readers look there first, so this is the damage almost anything notices.",
+ Parameters: []format.Property{
+ {
+ Name: SettingBytes, Kind: format.PropertyInt,
+ Min: smallestHead, Max: largestHead, Unit: "bytes",
+ Default: strconv.Itoa(defaultHead),
+ Detail: "How many bytes at the start are zeroed. " +
+ "Below four, some formats come out with damage no reader complains about.",
+ },
+ },
+ Floor: func(v Values) int64 { return int64(headBytes(v)) },
+ Open: func(v Values, out io.Writer) (Stream, error) {
+ return &zeroHead{out: out, left: headBytes(v)}, nil
+ },
+ })
+}
+
+// headBytes is how many bytes this run zeroes.
+//
+// The value has been past the declaration by the time it arrives, so a bad one
+// cannot reach here through either surface. It falls back rather than failing
+// for the same reason textenc.Parse keeps its branches: this is callable
+// directly, a guard is such a caller, and code that trusts its input is one
+// registry change away from writing a file nobody ordered.
+func headBytes(v Values) int {
+ raw, ok := v[SettingBytes]
+ if !ok || raw == "" {
+ return defaultHead
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < smallestHead || n > largestHead {
+ return defaultHead
+ }
+ return n
+}
+
+// zeroHead zeroes the opening bytes of a stream as it goes.
+//
+// Streaming rather than buffering, which keeps the regression line "a
+// generator does not hold the whole file in memory" untouched: it holds one
+// scratch buffer the size of the head, never the file.
+type zeroHead struct {
+ out io.Writer
+ // left is how many bytes at the front are still to be zeroed.
+ left int
+ // touched records whether any byte we zeroed was not already zero. See
+ // Stream.Touched for why the damage answers this rather than the engine.
+ touched bool
+ // buf is where the altered bytes go. The slice a caller hands to Write
+ // belongs to the caller, so writing zeros into it in place would be
+ // changing somebody else's memory - the same rule the archive encrypter
+ // had to learn.
+ buf []byte
+}
+
+func (z *zeroHead) Write(p []byte) (int, error) {
+ if z.left == 0 {
+ return z.out.Write(p)
+ }
+
+ n := z.left
+ if n > len(p) {
+ n = len(p)
+ }
+ for _, b := range p[:n] {
+ if b != 0 {
+ z.touched = true
+ break
+ }
+ }
+
+ z.buf = append(z.buf[:0], p...)
+ for i := 0; i < n; i++ {
+ z.buf[i] = 0
+ }
+ z.left -= n
+
+ written, err := z.out.Write(z.buf)
+ if err != nil {
+ return written, err
+ }
+ // The caller is told about ITS bytes, not about ours. They are the same
+ // count here because this damage preserves length, and saying len(p)
+ // rather than written is what keeps that true for a caller that checks.
+ return len(p), nil
+}
+
+func (z *zeroHead) Touched() bool { return z.touched }
diff --git a/internal/engine/damage.go b/internal/engine/damage.go
new file mode 100644
index 0000000..6b3ea96
--- /dev/null
+++ b/internal/engine/damage.go
@@ -0,0 +1,63 @@
+// Damage as the engine sees it: what a target may be refused for before
+// anything is written, and what the manifest records afterwards.
+//
+// In its own file rather than in engine.go, and that is a measurement: engine.go
+// stood at 431 lines of code against a ceiling of 433, so two more functions
+// there would have pushed a file over a gate that exists to stop exactly this.
+// Cutting a file does not make a type smaller, but these two were never part of
+// what engine.go is about - they are one subject, which is the better reason.
+package engine
+
+import (
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
+ "github.com/donislawdev/TestingFilesGenerator/internal/manifest"
+)
+
+// checkDamageFloor refuses a file smaller than the damage it was given.
+//
+// Here rather than at the moment of writing, and that is the point: a file
+// smaller than its damage comes out unchanged, and an unchanged file the
+// manifest calls broken is the one thing this axis must not produce. The fault
+// is in the recipe and is visible before the first byte, so it is a refusal
+// during planning - which is what makes "an invalid recipe writes no files"
+// true here as everywhere else.
+//
+// Reachable rather than theoretical: txt declares a minimum of 0 B and a zero
+// byte file really is written, measured 2026-09-09.
+//
+// A container whose size comes from its contents is left alone. Its entries in
+// Sizes carry only the count and their value is not read, so comparing them
+// against anything would be comparing against a number nobody set. The damage
+// still applies to the finished archive, and the check that it moved a byte
+// still runs - what is missing is only the early refusal.
+func checkDamageFloor(t *Target) error {
+ if len(t.Damage) == 0 || t.SizeFromContents {
+ return nil
+ }
+ floor, owner, err := t.Damage.Floor()
+ if err != nil {
+ return err
+ }
+ for _, size := range t.Sizes {
+ if size < floor {
+ return &damage.TooSmallError{Damage: owner, Requested: size, Floor: floor}
+ }
+ }
+ return nil
+}
+
+// damageFor records what was broken about this file, with the settings
+// resolved rather than as written.
+//
+// Nil for a file nothing damaged, so the key is absent rather than empty and
+// every manifest written before this existed is unchanged.
+func damageFor(f PlannedFile) []manifest.Damage {
+ if !f.Damaged() {
+ return nil
+ }
+ out := make([]manifest.Damage, 0, len(f.Target.Damage))
+ for _, s := range f.Target.Damage {
+ out = append(out, manifest.Damage{Type: s.ID, Settings: s.Resolved()})
+ }
+ return out
+}
diff --git a/internal/engine/drawsizes.go b/internal/engine/drawsizes.go
index b366f85..9632765 100644
--- a/internal/engine/drawsizes.go
+++ b/internal/engine/drawsizes.go
@@ -119,31 +119,31 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error {
// UTF-16, the band above a PNG's encoded picture - is a different thing:
// nobody can be expected to enumerate those, and snapping inside the range
// is the answer.
- floor, err := firstWritable(desc, first, 0, t.SizeMax)
+ floor, err := firstWritable(desc, first, 0, t.Range.Max)
if err != nil {
// The floor is above the whole range, so nothing in it is writable.
return err
}
- if t.SizeMin < floor {
+ if t.Range.Min < floor {
// Asked again at the low end so the format words its own refusal, with
// the number the person actually wrote.
- first.Bytes = t.SizeMin
+ first.Bytes = t.Range.Min
if _, err := planWithoutCrashing(desc, first); err != nil {
return err
}
}
- span := uint64(t.SizeMax - t.SizeMin)
+ span := uint64(t.Range.Max - t.Range.Min)
t.SizeMoved = make([]bool, len(t.Sizes))
for i := range t.Sizes {
- want := t.SizeMin
+ want := t.Range.Min
if span != 0 {
// Per index, never from a running stream. Raising a count then
// leaves the sizes of the earlier files alone, which is rule 2 and
// the reason core.SizeSeed takes an index at all.
r := core.NewRand(core.SizeSeed(targetSeed, i))
- want = t.SizeMin + int64(r.Uint64N(span+1))
+ want = t.Range.Min + int64(r.Uint64N(span+1))
}
req := format.Request{
@@ -153,13 +153,13 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error {
Properties: t.Properties,
}
- got, err := firstWritable(desc, req, want, t.SizeMax)
- if err != nil && want > t.SizeMin {
+ got, err := firstWritable(desc, req, want, t.Range.Max)
+ if err != nil && want > t.Range.Min {
// Nothing writable from the draw upwards. The bottom of the range
// can still hold something - a draw landing on the last odd number
// of a range has nowhere above it and plenty below - so the range is
// only empty once THAT fails too.
- got, err = firstWritable(desc, req, t.SizeMin, t.SizeMax)
+ got, err = firstWritable(desc, req, t.Range.Min, t.Range.Max)
}
if err != nil {
return err
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 9ac0b24..9604571 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
"github.com/donislawdev/TestingFilesGenerator/internal/version"
@@ -40,17 +41,20 @@ type Target struct {
// from Contains. The entries in Sizes then only carry the count, and their
// value is not read.
SizeFromContents bool
- // SizeIsRange says the sizes are drawn from SizeMin to SizeMax rather than
- // stated, and Sizes arrives carrying only the count.
+ // Range says the sizes are drawn rather than stated, and Sizes then arrives
+ // carrying only the count.
+ //
+ // One field rather than three, because the type stood at the crowding band
+ // and the answer to that is to move state out rather than raise a number.
+ // They were one statement to begin with: a minimum without the flag beside
+ // it says nothing, and neither does a maximum.
//
// The draw happens here rather than in the recipe package because this is
// the first place that knows the seed the run will actually use - the
// --seed flag overrides the recipe, and a size drawn before that would
// belong to a different run than the manifest describes. It still happens
// during planning, so AR10 holds and a dry run reports exact numbers.
- SizeIsRange bool
- SizeMin int64
- SizeMax int64
+ Range SizeRange
// SizeMoved marks the files whose drawn size was not one the format can
// write, so the nearest writable one was used instead. Empty for a target
// that is not a range.
@@ -77,6 +81,21 @@ type Target struct {
// manifest, so a test can assert about a whole class at once.
Group string
Properties map[string]string
+ // Damage is what to break about these files, in the order to break it.
+ //
+ // Empty for every target that does not ask, and that emptiness is what
+ // keeps D11 whole: a run with no damage goes down the same write path it
+ // always did, byte for byte.
+ Damage damage.Chain
+}
+
+// SizeRange is a size drawn per file rather than stated.
+//
+// Used is what says a range was asked for at all. Min and Max mean nothing
+// without it, which is why they travel together.
+type SizeRange struct {
+ Used bool
+ Min, Max int64
}
// Uniform is n files of the same size, which is what most targets ask for.
@@ -192,6 +211,11 @@ type PlannedFile struct {
Plan format.Plan
}
+// Damaged says whether this file is broken on purpose.
+func (f PlannedFile) Damaged() bool {
+ return f.Target != nil && len(f.Target.Damage) > 0
+}
+
// settleTarget checks everything that has to be true about one target before
// any of its files are planned, and settles the sizes of a range.
//
@@ -245,11 +269,18 @@ func settleTarget(t *Target, opt Options, seen map[string]bool) (format.Descript
// every generator already refuses and a guard walks sizes below the minimum
// for every registered format. It was removed rather than kept as defence
// nobody can verify.
- if t.SizeIsRange {
+ if t.Range.Used {
if err := drawSizes(t, desc, core.TargetSeed(opt.Seed, t.ID)); err != nil {
return format.Descriptor{}, err
}
}
+
+ // After the draw, because a range arrives here carrying only a count and a
+ // damage floor has to be judged against the sizes that will really be
+ // written.
+ if err := checkDamageFloor(t); err != nil {
+ return format.Descriptor{}, err
+ }
return desc, nil
}
@@ -627,6 +658,7 @@ func entryFor(f PlannedFile, sha string, materialized bool, failure error) manif
Determinism: string(f.Plan.Determinism),
Properties: f.Plan.Properties,
LabelEmbedded: label,
+ Damage: damageFor(f),
Notes: notes,
Expected: expectationFor(f),
Group: f.Target.Group,
@@ -648,6 +680,24 @@ func entryFor(f PlannedFile, sha string, materialized bool, failure error) manif
func expectationFor(f PlannedFile) manifest.Expected {
switch f.Target.Expected {
case "":
+ // A damaged file is one a judge was measured to refuse, so reject is
+ // what it means rather than a guess - which is why this is allowed to
+ // state it with certainty where an ordinary file gets unspecified.
+ // Untouchable rule 5 forbids inventing an expectation, and this one is
+ // not invented: the witness rule says a damage with nothing able to
+ // refuse its result is a damage this tool does not offer.
+ //
+ // Only where nothing was declared. A recipe that states an expectation
+ // reaches the manifest unchanged, and the one statement that cannot be
+ // true beside damage - accept - is refused while reading the recipe
+ // rather than quietly replaced here.
+ if f.Damaged() {
+ return manifest.Expected{
+ Outcome: manifest.OutcomeReject,
+ Reason: "content_malformed",
+ Confidence: "certain",
+ }
+ }
return manifest.Expected{
Outcome: manifest.OutcomeUnspecified,
Detail: "No expectation was declared for this file.",
diff --git a/internal/engine/errors.go b/internal/engine/errors.go
index 7c57231..a53da95 100644
--- a/internal/engine/errors.go
+++ b/internal/engine/errors.go
@@ -169,7 +169,7 @@ func atTarget(position int, t *Target, err error) error {
// is in hand. Without it a recipe written with "size-range" is refused at
// "targets[1].size", which is a box that is not on the screen and not in
// the file. Measured on 2026-09-06.
- if setting == core.SettingSize && t != nil && t.SizeIsRange {
+ if setting == core.SettingSize && t != nil && t.Range.Used {
setting = core.SettingSizeRange
}
return &addressedError{err: err, at: core.TargetAddress(position, setting)}
diff --git a/internal/engine/parallel.go b/internal/engine/parallel.go
index edbb867..2adf17a 100644
--- a/internal/engine/parallel.go
+++ b/internal/engine/parallel.go
@@ -17,6 +17,7 @@ import (
"sync/atomic"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
)
// This file is the only place in internal/engine that runs anything beside
@@ -339,10 +340,42 @@ func writeOne(ctx context.Context, f PlannedFile, outDir string, p *fileProgress
counter.report = p.advance
}
- writeErr := writeWithoutCrashing(ctx, f, counter)
+ // Damage sits between the generator and the counter, and the order is the
+ // design rather than a convenience.
+ //
+ // generator -> DAMAGE -> counter -> MultiWriter( buffered -> file, h )
+ //
+ // Three things fall out of it and none had to be built. The checksum
+ // describes the bytes that reached the disk, which is what stops the
+ // manifest describing a file other than the one beside it. The size check
+ // below counts the FINAL bytes, so a damage that changed the length
+ // against the plan is stopped by a guard that already existed. And
+ // progress counts what will really be there.
+ sink := io.Writer(counter)
+ var streams []damage.Stream
+ if f.Damaged() {
+ damaged, opened, err := f.Target.Damage.Open(counter)
+ if err != nil {
+ _ = fh.Close()
+ _ = os.Remove(tmp)
+ return "", err
+ }
+ sink, streams = damaged, opened
+ }
+
+ writeErr := writeWithoutCrashing(ctx, f, sink)
if writeErr == nil {
writeErr = buffered.Flush()
}
+ if writeErr == nil {
+ // Asked per damage rather than once at the end, because two damages
+ // can cancel each other out and a chain whose second member was idle
+ // would otherwise pass. A file nothing changed is a file every reader
+ // accepts while the manifest calls it broken.
+ if idle := f.Target.Damage.Idle(streams); idle != "" {
+ writeErr = &damage.NoChangeError{Damage: idle, File: f.Name}
+ }
+ }
closeErr := fh.Close()
if writeErr != nil {
diff --git a/internal/guard/concurrency_test.go b/internal/guard/concurrency_test.go
index 7fa5b3a..cbbda54 100644
--- a/internal/guard/concurrency_test.go
+++ b/internal/guard/concurrency_test.go
@@ -28,6 +28,12 @@ var mayBeConcurrent = map[string]string{
// The registry is read by every generator and written once at startup, so
// it carries the one lock in the tree.
"internal/format/registry.go": "the format registry is written at init and read by everything after",
+ // The same shape one axis over, and the same reason: written once when the
+ // package starts and read by planning, by the recipe reader, by both
+ // surfaces and by the guards. It holds the second lock in the tree because
+ // it is the second registry, not because anything here runs beside
+ // anything else.
+ "internal/damage/damage.go": "the damage registry is written at init and read by everything after",
// Signals arrive on a channel by definition, and the handler has to run
// beside the work it interrupts.
"cmd/tfg/main.go": "the interrupt handler has to run beside the work it stops",
diff --git a/internal/guard/damagebytes_test.go b/internal/guard/damagebytes_test.go
new file mode 100644
index 0000000..7ef2275
--- /dev/null
+++ b/internal/guard/damagebytes_test.go
@@ -0,0 +1,184 @@
+package guard
+
+import (
+ "bytes"
+ "strconv"
+ "testing"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
+)
+
+// The byte layer of damage, asked directly.
+//
+// These are the narrowest tests in the feature and they are here rather than
+// left to the end to end ones on purpose: a damage that writes the wrong bytes
+// still produces a file an oracle refuses, so the guard that asks "is it
+// refused" would stay green while the damage did something other than what it
+// says. What it DOES has to be pinned separately from what it ACHIEVES.
+
+// zero-head zeroes exactly the bytes it says and leaves the rest alone.
+//
+// The stream is written in pieces of three, because that is the shape the real
+// caller has - a generator writes in whatever chunks it likes, and a damage
+// that only works when the head arrives in one write is a damage that works in
+// a test and not in the product.
+func TestZeroHeadZeroesTheBytesItSaysAndNoOthers(t *testing.T) {
+ for _, head := range []int{4, 8, 16} {
+ t.Run(strconv.Itoa(head), func(t *testing.T) {
+ const size = 64
+ source := make([]byte, size)
+ for i := range source {
+ source[i] = byte(i + 1)
+ }
+
+ var out bytes.Buffer
+ chain := damage.Chain{{
+ ID: damage.ZeroHead,
+ Values: damage.Values{damage.SettingBytes: strconv.Itoa(head)},
+ }}
+ w, streams, err := chain.Open(&out)
+ if err != nil {
+ t.Fatalf("opening the chain: %v", err)
+ }
+
+ for i := 0; i < len(source); i += 3 {
+ end := min(i+3, len(source))
+ if n, err := w.Write(source[i:end]); err != nil || n != end-i {
+ t.Fatalf("writing bytes %d to %d gave %d, %v", i, end, n, err)
+ }
+ }
+
+ got := out.Bytes()
+ if len(got) != size {
+ t.Fatalf("zero-head changed the length: %d B in, %d B out", size, len(got))
+ }
+ for i := 0; i < head; i++ {
+ if got[i] != 0 {
+ t.Errorf("byte %d is %#x and should have been zeroed", i, got[i])
+ }
+ }
+ for i := head; i < size; i++ {
+ if got[i] != source[i] {
+ t.Errorf("byte %d is %#x and the source had %#x - past the head nothing may move",
+ i, got[i], source[i])
+ }
+ }
+ if !streams[0].Touched() {
+ t.Error("every one of those bytes was non zero and the damage says it touched nothing")
+ }
+ })
+ }
+}
+
+// A damage that changed nothing says so.
+//
+// This is the half the whole feature stands on. A file whose head is already
+// zero comes out identical to a good one, every reader accepts it, and the
+// manifest would call it broken - the tool lying in the one place its value
+// lives. The engine refuses the run on this answer, so the answer has to be
+// right in both directions, and the case above is the other direction.
+func TestADamageThatMovedNothingSaysSo(t *testing.T) {
+ source := make([]byte, 32) // all zero already
+
+ var out bytes.Buffer
+ chain := damage.Chain{{ID: damage.ZeroHead}}
+ w, streams, err := chain.Open(&out)
+ if err != nil {
+ t.Fatalf("opening the chain: %v", err)
+ }
+ if _, err := w.Write(source); err != nil {
+ t.Fatalf("writing: %v", err)
+ }
+
+ if streams[0].Touched() {
+ t.Error("the head was already zero and the damage claims it changed something")
+ }
+ if idle := chain.Idle(streams); idle != damage.ZeroHead {
+ t.Errorf("the chain names %q as the idle damage rather than %q", idle, damage.ZeroHead)
+ }
+ if !bytes.Equal(out.Bytes(), source) {
+ t.Error("nothing was supposed to change and the bytes moved")
+ }
+}
+
+// The floor is the smallest file the damage can be given, and it follows the
+// parameter rather than being a constant.
+//
+// It has two jobs and that is why one number does both: it is what the plan
+// refuses below, and it is the size the witness matrix is measured at. A floor
+// that ignored the parameter would answer for one of those and not the other.
+func TestTheFloorFollowsThePairOfSettings(t *testing.T) {
+ for _, c := range []struct {
+ head string
+ want int64
+ }{
+ {"", 8}, // the declared default
+ {"4", 4},
+ {"64", 64},
+ } {
+ chain := damage.Chain{{
+ ID: damage.ZeroHead,
+ Values: damage.Values{damage.SettingBytes: c.head},
+ }}
+ got, owner, err := chain.Floor()
+ if err != nil {
+ t.Fatalf("asking the floor for head %q: %v", c.head, err)
+ }
+ if got != c.want {
+ t.Errorf("head %q has a floor of %d B and should be %d B", c.head, got, c.want)
+ }
+ if owner != damage.ZeroHead {
+ t.Errorf("the floor is credited to %q rather than to the damage that set it", owner)
+ }
+ }
+}
+
+// A chain applies its damages in the order it lists them.
+//
+// Order is part of the contract because two damages can produce different
+// bytes in different orders, and D11 promises those bytes do not move. Asked
+// with two zero-heads of different lengths, where the wrong order is visible
+// in the result: the longer one first leaves sixteen zeros, the shorter one
+// first leaves the same sixteen only if the second really ran after it.
+func TestAChainAppliesItsDamagesInTheOrderItListsThem(t *testing.T) {
+ source := make([]byte, 32)
+ for i := range source {
+ source[i] = 0xFF
+ }
+
+ var out bytes.Buffer
+ chain := damage.Chain{
+ {ID: damage.ZeroHead, Values: damage.Values{damage.SettingBytes: "4"}},
+ {ID: damage.ZeroHead, Values: damage.Values{damage.SettingBytes: "16"}},
+ }
+ w, streams, err := chain.Open(&out)
+ if err != nil {
+ t.Fatalf("opening the chain: %v", err)
+ }
+ if _, err := w.Write(source); err != nil {
+ t.Fatalf("writing: %v", err)
+ }
+
+ got := out.Bytes()
+ for i := 0; i < 16; i++ {
+ if got[i] != 0 {
+ t.Fatalf("byte %d is %#x - the second damage did not run after the first", i, got[i])
+ }
+ }
+ if got[16] != 0xFF {
+ t.Errorf("byte 16 is %#x and nothing should have reached it", got[16])
+ }
+
+ // The second one meets a head whose first four bytes are already zero and
+ // twelve that are not, so it still moved something. The first met all
+ // ones. Both have to say they were busy, because the idle check runs per
+ // damage rather than once at the end.
+ for i, s := range streams {
+ if !s.Touched() {
+ t.Errorf("damage %d says it changed nothing and it had bytes to change", i)
+ }
+ }
+ if idle := chain.Idle(streams); idle != "" {
+ t.Errorf("the chain names %q as idle and neither damage was", idle)
+ }
+}
diff --git a/internal/guard/damagerefused_test.go b/internal/guard/damagerefused_test.go
new file mode 100644
index 0000000..02382b4
--- /dev/null
+++ b/internal/guard/damagerefused_test.go
@@ -0,0 +1,370 @@
+package guard
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
+ "github.com/donislawdev/TestingFilesGenerator/internal/engine"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+ _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
+ "github.com/donislawdev/TestingFilesGenerator/internal/manifest"
+ "github.com/donislawdev/TestingFilesGenerator/internal/oracle"
+ "github.com/donislawdev/TestingFilesGenerator/internal/recipe"
+)
+
+// A damaged file has to be REFUSED, which is the reverse of everything else in
+// this package.
+//
+// Every other oracle guard here asks "does a judge accept what we wrote". This
+// one asks the opposite, and it is the whole reason the damage axis is worth
+// having: a broken file no reader complains about is a file nobody can write a
+// test against, so it is a file this tool has no business producing.
+//
+// The formats are the text ones plus png, because those are the ones whose
+// judges run without an external tool being installed. That is a limit of what
+// can be asked on any machine rather than of the damage: the full sweep across
+// all twenty four lives in tools/probes/corruptwitness, which is where the
+// witness matrix is measured.
+func TestADamagedFileIsRefusedByAJudge(t *testing.T) {
+ dir := t.TempDir()
+
+ for _, id := range []string{"txt", "md", "json", "xml", "csv", "html", "png"} {
+ t.Run(id, func(t *testing.T) {
+ good, broken := writeGoodAndBroken(t, dir, id)
+
+ // The control first. Without it this test passes for a build whose
+ // judge refuses everything, which would be the loudest possible
+ // way of proving nothing.
+ if res := oracle.Strict(id, good); res.Available && res.Err != nil {
+ t.Fatalf("the undamaged file was refused, so this row says nothing about damage: %v\n%s",
+ res.Err, res.Output)
+ }
+
+ res := oracle.Strict(id, broken)
+ if !res.Available {
+ t.Skipf("no structural check for %s on this machine", id)
+ }
+ if res.Err == nil {
+ t.Errorf("the damaged file was ACCEPTED. A broken file every reader takes is one "+
+ "nobody can write a test against, and the manifest calls it expected: reject.\n%s",
+ res.Output)
+ }
+ })
+ }
+}
+
+// The manifest of a damaged run says what was broken and expects a rejection.
+//
+// Read from the file on disk rather than from the plan, because the whole
+// point of the record is what a consumer finds in it.
+func TestTheManifestOfADamagedRunSaysSoAndExpectsRejection(t *testing.T) {
+ dir := t.TempDir()
+ man := runDamaged(t, dir, "png", damage.Chain{{
+ ID: damage.ZeroHead, Values: damage.Values{damage.SettingBytes: "16"},
+ }})
+
+ if len(man.Files) != 1 {
+ t.Fatalf("expected one file in the manifest, got %d", len(man.Files))
+ }
+ f := man.Files[0]
+
+ if len(f.Damage) != 1 {
+ t.Fatalf("the manifest records %d damage(s) and one was applied", len(f.Damage))
+ }
+ if f.Damage[0].Type != damage.ZeroHead {
+ t.Errorf("the manifest names %q rather than the damage that was applied", f.Damage[0].Type)
+ }
+ // Resolved rather than as written, so a consumer does not have to know
+ // what the default was in the build that wrote it.
+ if got := f.Damage[0].Settings[damage.SettingBytes]; got != "16" {
+ t.Errorf("the manifest records bytes=%q rather than what was asked for", got)
+ }
+ if f.Expected.Outcome != manifest.OutcomeReject {
+ t.Errorf("a damaged file is expected to be %q rather than %q",
+ manifest.OutcomeReject, f.Expected.Outcome)
+ }
+ // Stated with certainty rather than as a guess, and allowed to be: the
+ // witness rule says a damage nothing can refuse is not offered, so this is
+ // measured rather than invented. Untouchable rule 5 is about the second.
+ if f.Expected.Confidence != "certain" {
+ t.Errorf("the expectation is %q confident and it is measured, not guessed", f.Expected.Confidence)
+ }
+}
+
+// An undamaged run is untouched by any of this.
+//
+// D11 in the narrowest place it can be asked: the bytes of a file nobody
+// damaged, and the manifest entry beside it, both have to be what they were
+// before the axis existed. The damage key is ABSENT rather than empty, so a
+// manifest written by an older build is still the same document.
+func TestARunWithNoDamageIsUnchanged(t *testing.T) {
+ dir := t.TempDir()
+ man := runDamaged(t, dir, "png", nil)
+
+ f := man.Files[0]
+ if f.Damage != nil {
+ t.Errorf("a run with no damage recorded %v", f.Damage)
+ }
+ if f.Expected.Outcome != manifest.OutcomeUnspecified {
+ t.Errorf("a file nobody damaged expects %q rather than %q",
+ manifest.OutcomeUnspecified, f.Expected.Outcome)
+ }
+
+ body, err := os.ReadFile(filepath.Join(dir, f.Name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The signature, which is the first thing zero-head would have taken.
+ if len(body) < 8 || body[0] != 0x89 || string(body[1:4]) != "PNG" {
+ t.Errorf("the file does not open with the PNG signature: % x", body[:min(8, len(body))])
+ }
+}
+
+// A damage that would change nothing stops the file rather than writing it.
+//
+// Reachable rather than theoretical, and that is measured: ico, avif and jxl
+// all begin with zero bytes, so zeroing one or two of them moves nothing. Here
+// it is asked of a file made of zeros, because the guard has to press the
+// answer rather than depend on which formats happen to start that way.
+func TestADamageThatWouldChangeNothingStopsTheFile(t *testing.T) {
+ source := make([]byte, 64)
+ chain := damage.Chain{{ID: damage.ZeroHead}}
+
+ var sink discard
+ w, streams, err := chain.Open(&sink)
+ if err != nil {
+ t.Fatalf("opening the chain: %v", err)
+ }
+ if _, err := w.Write(source); err != nil {
+ t.Fatalf("writing: %v", err)
+ }
+ if idle := chain.Idle(streams); idle == "" {
+ t.Fatal("every byte was already zero and the chain reports nothing idle")
+ }
+}
+
+// discard swallows bytes. The chain has to write somewhere and what it wrote
+// is not the question here - whether it moved anything is.
+type discard struct{}
+
+func (discard) Write(p []byte) (int, error) { return len(p), nil }
+
+// writeGoodAndBroken produces one file of this format twice, once whole and
+// once damaged, and returns both paths.
+func writeGoodAndBroken(t *testing.T, dir, id string) (good, broken string) {
+ t.Helper()
+
+ d, err := format.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ size := d.SmallestAccepted(format.Request{}) + 4096
+
+ goodDir := filepath.Join(dir, id+"-good")
+ brokenDir := filepath.Join(dir, id+"-broken")
+ goodMan := runOne(t, goodDir, id, size, nil)
+ brokenMan := runOne(t, brokenDir, id, size, damage.Chain{{ID: damage.ZeroHead}})
+
+ return filepath.Join(goodDir, goodMan.Files[0].Name),
+ filepath.Join(brokenDir, brokenMan.Files[0].Name)
+}
+
+// runDamaged is one png of a comfortable size, with the chain given.
+func runDamaged(t *testing.T, dir, id string, chain damage.Chain) *manifest.Manifest {
+ t.Helper()
+ return runOne(t, dir, id, 20<<10, chain)
+}
+
+// runOne writes one file through the whole engine and hands back the manifest
+// it wrote, read from disk.
+func runOne(t *testing.T, dir, id string, size int64, chain damage.Chain) *manifest.Manifest {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ target := engine.Target{
+ ID: "d",
+ Format: id,
+ Sizes: engine.Uniform(1, size),
+ Damage: chain,
+ }
+ opt := engine.Options{OutDir: dir, Seed: 5, ManifestName: engine.DefaultManifestName}
+
+ planned, err := engine.Plan([]engine.Target{target}, opt)
+ if err != nil {
+ t.Fatalf("planning %s: %v", id, err)
+ }
+ res, err := engine.Run(t.Context(), planned, opt)
+ if err != nil {
+ t.Fatalf("running %s: %v", id, err)
+ }
+ for _, f := range res.Manifest.Files {
+ if f.Failed {
+ t.Fatalf("%s: the run reported a failure: %s", id, f.Error)
+ }
+ }
+ return res.Manifest
+}
+
+// A file smaller than its damage is refused BEFORE anything is written.
+//
+// In the plan rather than at the moment of writing, because the fault is in
+// the recipe and is visible before the first byte - which is what makes "an
+// invalid recipe writes no files" true here as everywhere else.
+//
+// Reachable rather than theoretical: txt declares a minimum of 0 B and a zero
+// byte file really is produced, so a person can ask for one and damage it.
+func TestAFileSmallerThanItsDamageIsRefusedBeforeAnythingIsWritten(t *testing.T) {
+ dir := t.TempDir()
+ target := engine.Target{
+ ID: "tiny",
+ Format: "txt",
+ Sizes: engine.Uniform(1, 4),
+ Damage: damage.Chain{{ID: damage.ZeroHead}}, // eight bytes by default
+ }
+ opt := engine.Options{OutDir: dir, ManifestName: engine.DefaultManifestName}
+
+ _, err := engine.Plan([]engine.Target{target}, opt)
+ if err == nil {
+ t.Fatal("a 4 B file was planned with a damage that needs 8 B")
+ }
+
+ var small *damage.TooSmallError
+ if !errors.As(err, &small) {
+ t.Fatalf("refused with %T, which nothing can take apart: %v", err, err)
+ }
+ if small.Floor != 8 || small.Requested != 4 {
+ t.Errorf("the refusal says floor %d and requested %d", small.Floor, small.Requested)
+ }
+ // The four parts D6 asks for, and the one that matters most: it names a
+ // size that would work rather than only saying no.
+ if !strings.Contains(small.Instead(), "8 B") {
+ t.Errorf("the refusal does not name a size that would work: %q", small.Instead())
+ }
+
+ // Nothing on disk. The whole reason this is a planning refusal.
+ left, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(left) != 0 {
+ t.Errorf("the run was refused and left %d file(s) behind", len(left))
+ }
+}
+
+// A size the damage CAN take is planned, which is the control.
+//
+// Without it the test above passes for a build that refuses every damaged
+// target, and that would be a floor that has become a wall.
+func TestASizeTheDamageCanTakeIsPlanned(t *testing.T) {
+ target := engine.Target{
+ ID: "fine",
+ Format: "txt",
+ Sizes: engine.Uniform(1, 8),
+ Damage: damage.Chain{{ID: damage.ZeroHead}},
+ }
+ opt := engine.Options{OutDir: t.TempDir(), ManifestName: engine.DefaultManifestName}
+
+ if _, err := engine.Plan([]engine.Target{target}, opt); err != nil {
+ t.Fatalf("8 B is exactly what the damage needs and it was refused: %v", err)
+ }
+}
+
+// Damage beside an expectation of accept is refused, and the other three
+// outcomes are not.
+//
+// Only accept, and the narrowness is the decision. reject is what damage
+// implies, and sanitize and unspecified are both sensible questions about a
+// broken file - a system under test may be expected to repair it, or that may
+// be the point of the test. Refusing more than the one that cannot be true
+// would take away cases somebody has a right to build.
+//
+// The half that matters is the second loop. Without it this passes for a build
+// that refuses damage beside ANY expectation, which is the wall version of the
+// same rule and would be invisible from the first loop alone.
+func TestDamageBesideAnExpectationOfAcceptIsRefused(t *testing.T) {
+ body := func(outcome string) []byte {
+ return []byte(`version: 1
+targets:
+ - id: broken
+ format: png
+ size: 20kb
+ damage: [zero-head]
+ expected: ` + outcome + `
+`)
+ }
+
+ if _, err := recipe.Parse(body("accept"), "r.yaml"); err == nil {
+ t.Error("a target that breaks the file and expects it to be accepted was accepted")
+ } else if !strings.Contains(err.Error(), "accept") {
+ t.Errorf("the refusal does not say which expectation it is about: %v", err)
+ }
+
+ for _, outcome := range []string{"reject", "sanitize", "unspecified"} {
+ if _, err := recipe.Parse(body(outcome), "r.yaml"); err != nil {
+ t.Errorf("expected %s beside damage is a legitimate question and was refused: %v",
+ outcome, err)
+ }
+ }
+}
+
+// A damage the build does not know is refused while the recipe is read, and
+// the refusal names what there is.
+//
+// While the recipe is read rather than when the file is written, because an
+// invalid recipe produces no files at all - and the names come from the
+// registry, so a second damage appears in that sentence on the day it is
+// registered.
+func TestADamageNobodyRegisteredIsRefusedWithTheNamesThereAre(t *testing.T) {
+ src := []byte(`version: 1
+targets:
+ - id: broken
+ format: png
+ size: 20kb
+ damage: [shred-it]
+`)
+
+ _, err := recipe.Parse(src, "r.yaml")
+ if err == nil {
+ t.Fatal("a damage nobody registered was accepted")
+ }
+ for _, want := range []string{"shred-it", damage.ZeroHead} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("the refusal does not mention %q: %v", want, err)
+ }
+ }
+}
+
+// A damage setting outside its declaration is refused in the registry's own
+// words.
+//
+// The wording is not written in the damage package and must not be: bytes is
+// declared as a number with a range, and format.Property.Allows builds the
+// sentence - so a damage parameter and a format setting refuse alike, and
+// neither copies the other. Four is the floor and it is measured: below it,
+// four of the twenty four formats come out with damage no reader complains
+// about.
+func TestADamageSettingOutsideItsDeclarationIsRefused(t *testing.T) {
+ src := []byte(`version: 1
+targets:
+ - id: broken
+ format: png
+ size: 20kb
+ damage:
+ - type: zero-head
+ bytes: 2
+`)
+
+ _, err := recipe.Parse(src, "r.yaml")
+ if err == nil {
+ t.Fatal("zeroing two bytes was accepted and four formats have no witness for it")
+ }
+ if !strings.Contains(err.Error(), "bytes") {
+ t.Errorf("the refusal does not name the setting: %v", err)
+ }
+}
diff --git a/internal/guard/damagewindow_test.go b/internal/guard/damagewindow_test.go
new file mode 100644
index 0000000..8cd9653
--- /dev/null
+++ b/internal/guard/damagewindow_test.go
@@ -0,0 +1,165 @@
+package guard
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
+ "github.com/donislawdev/TestingFilesGenerator/internal/engine"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
+ "github.com/donislawdev/TestingFilesGenerator/internal/gui/window"
+ "github.com/donislawdev/TestingFilesGenerator/internal/manifest"
+)
+
+// The window offers every damage the registry holds.
+//
+// Asked of the registry rather than of a list written here, so a second damage
+// appears in this menu on the day it is registered - the same bargain the
+// format menu makes, and the reason D1 is answered by declaring rather than by
+// remembering.
+func TestTheWindowOffersEveryDamageThereIs(t *testing.T) {
+ _, content := screen(t)
+
+ control := controlUnder(content, text.FieldDamage())
+ picker, ok := control.(*parts.Chooser)
+ if !ok {
+ t.Fatalf("the damage field is %T rather than a list to choose from", control)
+ }
+
+ offered := append([]string{}, picker.Options...)
+ want := append([]string{text.DamageNone()}, damage.Names()...)
+ sort.Strings(offered)
+ sort.Strings(want)
+ if strings.Join(offered, ",") != strings.Join(want, ",") {
+ t.Errorf("the window offers %v and the registry has %v", offered, want)
+ }
+
+ // It opens on none, because damage is the exception rather than the
+ // ordinary run - and because a menu cannot be empty, so "not damaged" has
+ // to be one of its values rather than the absence of a choice.
+ if picker.Selected != text.DamageNone() {
+ t.Errorf("the window opens with %q chosen rather than %q", picker.Selected, text.DamageNone())
+ }
+}
+
+// Choosing a damage draws the fields it declares, and choosing none takes them
+// away again.
+//
+// The second half is the one worth having. A field left behind under a menu
+// set back to none is a value that reaches the engine from a control nobody
+// can see, which is how a preset parameter once travelled without a widget.
+func TestTheWindowDrawsAFieldForEveryDamageParameter(t *testing.T) {
+ _, content := screen(t)
+ picker := controlUnder(content, text.FieldDamage()).(*parts.Chooser)
+
+ checked := 0
+ for _, d := range damage.All() {
+ picker.SetSelected(d.ID)
+ for _, p := range d.Parameters {
+ control := controlUnder(content, text.SettingLabel(p.Name))
+ if control == nil {
+ t.Errorf("%s declares %q and the window draws no field for it", d.ID, p.Name)
+ continue
+ }
+ if bad := wrongKindOfControl(p, control); bad != "" {
+ t.Errorf("%s.%s is %s", d.ID, p.Name, bad)
+ }
+ checked++
+ }
+ }
+ if checked == 0 {
+ t.Fatal("no damage declares a parameter, so this proved nothing")
+ }
+
+ picker.SetSelected(text.DamageNone())
+ for _, d := range damage.All() {
+ for _, p := range d.Parameters {
+ if controlUnder(content, text.SettingLabel(p.Name)) != nil {
+ t.Errorf("%q is still on the screen with no damage chosen", p.Name)
+ }
+ }
+ }
+ t.Logf("%d damage parameter(s) drawn from the registry", checked)
+}
+
+// A run started from the window is damaged, and the bytes say so.
+//
+// Pressed rather than settled, because settle is the window's own and a guard
+// that called it would prove the screen agrees with itself. Read from the file
+// rather than from the screen: a window that draws the menu and drops the
+// choice on the way to the engine looks exactly like one that works, which is
+// the defect that once let the window produce PDFs while the command line
+// produced anything.
+func TestARunFromTheWindowIsReallyDamaged(t *testing.T) {
+ dir := t.TempDir()
+
+ host := newFakeHost(t)
+ gen := window.NewGenerate(host)
+ content := gen.Object()
+ t.Cleanup(func() { join(host) })
+
+ fields := gen.Fields()
+ chooserIn(t, fields, "format").SetSelected("png")
+ setBox(t, fields, "size", "20kb")
+
+ chooser, ok := controlUnder(content, text.FieldDamage()).(*parts.Chooser)
+ if !ok {
+ t.Fatalf("the damage field is not a list to choose from")
+ }
+ chooser.SetSelected(damage.ZeroHead)
+ setBox(t, fields, damage.SettingBytes, "16")
+
+ entryUnder(t, content, text.FieldOutputDir()).SetText(dir)
+ press(t, content, text.ButtonGenerate())
+ join(host)
+
+ made, err := filepath.Glob(filepath.Join(dir, "*.png"))
+ if err != nil || len(made) != 1 {
+ t.Fatalf("the window wrote %v (err %v) and this guard needs exactly one file", made, err)
+ }
+ body, err := os.ReadFile(made[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Sixteen, because that is what was typed into the parameter. Eight would
+ // pass for a window that drew the field and sent the default.
+ for i := 0; i < 16; i++ {
+ if body[i] != 0 {
+ t.Fatalf("byte %d is %#x, so the choice or its setting never reached the engine", i, body[i])
+ }
+ }
+ if int64(len(body)) != 20<<10 {
+ t.Errorf("the file is %d B and the size asked for was %d B", len(body), 20<<10)
+ }
+
+ f := damageManifest(t, filepath.Join(dir, engine.DefaultManifestName)).Files[0]
+ if len(f.Damage) != 1 || f.Damage[0].Type != damage.ZeroHead {
+ t.Fatalf("the manifest records %v rather than the damage that was chosen", f.Damage)
+ }
+ if got := f.Damage[0].Settings[damage.SettingBytes]; got != "16" {
+ t.Errorf("the manifest records bytes=%q rather than what was typed", got)
+ }
+}
+
+// damageManifest reads a manifest as the manifest package spells it.
+//
+// Its own reader rather than the manifestShape the recipe guards share: that
+// one is a narrow view written for what those guards ask, and adding a field
+// to it for one caller would make every other guard carry it.
+func damageManifest(t *testing.T, path string) manifest.Manifest {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading the manifest: %v", err)
+ }
+ var m manifest.Manifest
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatalf("parsing the manifest: %v", err)
+ }
+ return m
+}
diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go
index 58cbd83..2232cf5 100644
--- a/internal/guard/layers_test.go
+++ b/internal/guard/layers_test.go
@@ -69,6 +69,18 @@ var layer = map[string]int{
"internal/preset": 2,
"internal/manifest": 2,
+ // What a file can be broken with. Layer 2 rather than 1, beside the other
+ // pieces of the contract, because it imports internal/format cleanly
+ // downwards for format.Property - exactly as internal/preset does, and for
+ // the same reason: a damage parameter IS a format property, so it gets the
+ // validation, the one voice of refusal and the window's field for free.
+ //
+ // Layer 1 beside the formats was the other candidate and was turned down:
+ // damage transforms bytes, which makes it tempting, but it is not a
+ // generator and down there it would need a sideways edge to
+ // internal/format for the property type.
+ "internal/damage": 2,
+
"internal/engine": 3,
"internal/audit": 3,
@@ -146,6 +158,14 @@ var sameLayerAllowed = map[string][]string{
"internal/format/wav": {"internal/format", "internal/format/imagelabel"},
"internal/preset": {"internal/recipe"},
+ // A recipe checks the damages it names against the registry that holds
+ // them, the same way it checks a format against the format registry. It
+ // has to happen here rather than in the engine, because an invalid recipe
+ // writes no files at all and comes back with every problem it has - a name
+ // checked later would be a run that started before its recipe was known to
+ // be good.
+ "internal/recipe": {"internal/damage"},
+
// The window is composed of parts and the parts know nothing about
// windows. That direction is what lets a part be rendered on its own,
// which is where the golden images sit - an image of a whole screen
diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go
index c38a3e4..6de453d 100644
--- a/internal/guard/parity_test.go
+++ b/internal/guard/parity_test.go
@@ -65,6 +65,16 @@ var reachableFromTheWindow = []string{
// menu.
"preset:size-boundaries.format",
+ // What to break about the files, drawn from the damage registry rather
+ // than listed in the window, with the parameters of whatever is chosen
+ // drawn by the same call that draws a format's settings.
+ //
+ // Pressed rather than looked at: TestARunFromTheWindowIsReallyDamaged sets
+ // the menu and a parameter, presses Generate and reads the BYTES back, so
+ // a window that drew the control and dropped the choice on the way to the
+ // engine would redden here rather than passing as a drawn box.
+ "recipe:targets.damage",
+
// Every format the registry holds, taken from the registry itself rather
// than listed in the window. TestTheWindowOffersEveryFormatTheRegistryHas.
"format:bmp",
@@ -219,15 +229,21 @@ var reachableFromTheWindow = []string{
// parity, written down rather than estimated.
//
// Some entries are here for a second reason - the engine refuses them too, so
-// neither surface has them. extends, with, policy, engine, targets.mutations,
-// targets.fill, defaults.fill and output.split_threshold are all answered today
-// with "not in this build yet".
+// neither surface has them. extends, with, policy, engine, targets.fill,
+// defaults.fill and output.split_threshold are all answered today with "not in
+// this build yet".
+//
+// That is seven, and it was eight until 2026-09-09: targets.mutations was
+// refused with a message pointing at a module that will never exist, and it is
+// now targets.damage, which both surfaces reach. It left this list rather than
+// moving down it, which is what this list is for - the distance to parity is
+// only allowed to shrink.
//
-// That is eight, and this sentence said seven until 2026-08-18: it had left out
-// output.split_threshold, which recipe.go has refused all along. Counted from
-// the code that does the refusing rather than from this list, which is the only
-// way it could have been found - a comment has no guard, and the number here
-// looked as settled as the ones a test prints.
+// The sentence said seven once before, until 2026-08-18, and was wrong: it had
+// left out output.split_threshold, which recipe.go has refused all along.
+// Counted from the code that does the refusing rather than from this list,
+// which is the only way it could have been found - a comment has no guard, and
+// the number here looked as settled as the ones a test prints.
//
// They stay on this list because a key nobody has built is still a
// key the window cannot produce, and separating the two reasons would be a
@@ -241,7 +257,6 @@ var notYetReachable = []string{
"recipe:output.split_threshold",
"recipe:policy",
"recipe:targets.fill",
- "recipe:targets.mutations",
"recipe:version",
"recipe:with",
}
diff --git a/internal/guard/recipeshapes_test.go b/internal/guard/recipeshapes_test.go
index b8871d8..d08911d 100644
--- a/internal/guard/recipeshapes_test.go
+++ b/internal/guard/recipeshapes_test.go
@@ -109,7 +109,11 @@ var shapeCases = map[string]string{
"expected": skipShapeCase,
// properties is a mapping of names to values.
"properties": skipShapeCase,
- "mutations": skipShapeCase,
+ // damage takes a list, so a mapping really is a wrong shape for it - it
+ // gets a case rather than a skip. The entries inside it may be a word or a
+ // mapping, which is a different question and is asked by the guards in
+ // damagerefused_test.go.
+ "damage": "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n damage: {a: b}\noutput:\n dir: ./o\n",
"version": "version: {a: b}\ntargets:\n - id: a\n format: txt\n size: 1kb\noutput:\n dir: ./o\n",
"seed": "version: 1\nseed: {a: b}\ntargets:\n - id: a\n format: txt\n size: 1kb\noutput:\n dir: ./o\n",
diff --git a/internal/guard/settingslot_test.go b/internal/guard/settingslot_test.go
index 86f8c68..68967c1 100644
--- a/internal/guard/settingslot_test.go
+++ b/internal/guard/settingslot_test.go
@@ -185,8 +185,10 @@ func TestEveryNameARefusalCanBeGivenTakesTheArticleThisRuleGivesIt(t *testing.T)
"entries": "an", "bit_depth": "a", "sample_rate": "a", "channels": "a",
"paragraphs": "a", "rows": "a", "columns": "a", "slides": "a",
"depth": "a", "colours": "a", "records": "a", "lines": "a",
+ "damage": "a", "bytes": "a",
// Labels, which is what a window shows.
"Batch name": "a", "How many files": "a", "File names": "a", "Size": "a",
+ "Damage": "a",
"Format": "a", "Seed": "a", "Output directory": "an", "Kind of case": "a",
"Around a limit": "an", "Size range": "a", "Expected outcome": "an",
"Limit to test": "a", "One size": "a", "A range": "a",
diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png
index 3fc18f9..2de30dd 100644
Binary files a/internal/guard/testdata/screens/generate-chosen-by-key.png and b/internal/guard/testdata/screens/generate-chosen-by-key.png differ
diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.xml b/internal/guard/testdata/screens/generate-chosen-by-key.xml
index f5d397b..585d4e7 100644
--- a/internal/guard/testdata/screens/generate-chosen-by-key.xml
+++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png
index 134d822..afc3048 100644
Binary files a/internal/guard/testdata/screens/generate-chosen.png and b/internal/guard/testdata/screens/generate-chosen.png differ
diff --git a/internal/guard/testdata/screens/generate-chosen.xml b/internal/guard/testdata/screens/generate-chosen.xml
index 1c13a8a..dd75651 100644
--- a/internal/guard/testdata/screens/generate-chosen.xml
+++ b/internal/guard/testdata/screens/generate-chosen.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png
index e3ba87d..2a47fc5 100644
Binary files a/internal/guard/testdata/screens/generate-empty.png and b/internal/guard/testdata/screens/generate-empty.png differ
diff --git a/internal/guard/testdata/screens/generate-empty.xml b/internal/guard/testdata/screens/generate-empty.xml
index e5e6eff..e2f5dda 100644
--- a/internal/guard/testdata/screens/generate-empty.xml
+++ b/internal/guard/testdata/screens/generate-empty.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -242,10 +242,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png
index 2cf1535..6a8801c 100644
Binary files a/internal/guard/testdata/screens/generate-focused.png and b/internal/guard/testdata/screens/generate-focused.png differ
diff --git a/internal/guard/testdata/screens/generate-focused.xml b/internal/guard/testdata/screens/generate-focused.xml
index d6d91bd..5952793 100644
--- a/internal/guard/testdata/screens/generate-focused.xml
+++ b/internal/guard/testdata/screens/generate-focused.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -235,10 +235,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png
index 87def7f..0cec8e2 100644
Binary files a/internal/guard/testdata/screens/generate-hovered.png and b/internal/guard/testdata/screens/generate-hovered.png differ
diff --git a/internal/guard/testdata/screens/generate-hovered.xml b/internal/guard/testdata/screens/generate-hovered.xml
index 37db1fd..214fadf 100644
--- a/internal/guard/testdata/screens/generate-hovered.xml
+++ b/internal/guard/testdata/screens/generate-hovered.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png
index 9f15be1..472a394 100644
Binary files a/internal/guard/testdata/screens/generate-menu-hovered.png and b/internal/guard/testdata/screens/generate-menu-hovered.png differ
diff --git a/internal/guard/testdata/screens/generate-menu-hovered.xml b/internal/guard/testdata/screens/generate-menu-hovered.xml
index 62f74cc..f53e725 100644
--- a/internal/guard/testdata/screens/generate-menu-hovered.xml
+++ b/internal/guard/testdata/screens/generate-menu-hovered.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png
index 2201cb3..9236c1d 100644
Binary files a/internal/guard/testdata/screens/generate-menu-keyed.png and b/internal/guard/testdata/screens/generate-menu-keyed.png differ
diff --git a/internal/guard/testdata/screens/generate-menu-keyed.xml b/internal/guard/testdata/screens/generate-menu-keyed.xml
index bc55d39..eba6e8d 100644
--- a/internal/guard/testdata/screens/generate-menu-keyed.xml
+++ b/internal/guard/testdata/screens/generate-menu-keyed.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png
index 63b4541..f5303b3 100644
Binary files a/internal/guard/testdata/screens/generate-menu.png and b/internal/guard/testdata/screens/generate-menu.png differ
diff --git a/internal/guard/testdata/screens/generate-menu.xml b/internal/guard/testdata/screens/generate-menu.xml
index 9c83d42..c98df7a 100644
--- a/internal/guard/testdata/screens/generate-menu.xml
+++ b/internal/guard/testdata/screens/generate-menu.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png
index d7b7a7b..f30ff93 100644
Binary files a/internal/guard/testdata/screens/generate-refused-both.png and b/internal/guard/testdata/screens/generate-refused-both.png differ
diff --git a/internal/guard/testdata/screens/generate-refused-both.xml b/internal/guard/testdata/screens/generate-refused-both.xml
index 9e3e85a..ac83992 100644
--- a/internal/guard/testdata/screens/generate-refused-both.xml
+++ b/internal/guard/testdata/screens/generate-refused-both.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -249,10 +249,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png
index 93627aa..a1bf699 100644
Binary files a/internal/guard/testdata/screens/generate-refused-setting.png and b/internal/guard/testdata/screens/generate-refused-setting.png differ
diff --git a/internal/guard/testdata/screens/generate-refused-setting.xml b/internal/guard/testdata/screens/generate-refused-setting.xml
index 0f1beb6..87e10d5 100644
--- a/internal/guard/testdata/screens/generate-refused-setting.xml
+++ b/internal/guard/testdata/screens/generate-refused-setting.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -316,10 +316,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png
index 6f70fdf..58b8f02 100644
Binary files a/internal/guard/testdata/screens/generate-refused.png and b/internal/guard/testdata/screens/generate-refused.png differ
diff --git a/internal/guard/testdata/screens/generate-refused.xml b/internal/guard/testdata/screens/generate-refused.xml
index 5e9830f..1fb6536 100644
--- a/internal/guard/testdata/screens/generate-refused.xml
+++ b/internal/guard/testdata/screens/generate-refused.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -244,10 +244,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png
index 1152d9f..d61f9dc 100644
Binary files a/internal/guard/testdata/screens/generate-switch-by-key.png and b/internal/guard/testdata/screens/generate-switch-by-key.png differ
diff --git a/internal/guard/testdata/screens/generate-switch-by-key.xml b/internal/guard/testdata/screens/generate-switch-by-key.xml
index 33119bf..fc02678 100644
--- a/internal/guard/testdata/screens/generate-switch-by-key.xml
+++ b/internal/guard/testdata/screens/generate-switch-by-key.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png
index 3ea5863..1891819 100644
Binary files a/internal/guard/testdata/screens/generate-typed.png and b/internal/guard/testdata/screens/generate-typed.png differ
diff --git a/internal/guard/testdata/screens/generate-typed.xml b/internal/guard/testdata/screens/generate-typed.xml
index 59b4e68..f8027e0 100644
--- a/internal/guard/testdata/screens/generate-typed.xml
+++ b/internal/guard/testdata/screens/generate-typed.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -241,10 +241,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png
index bb498b4..711f651 100644
Binary files a/internal/guard/testdata/screens/generate-unchecked.png and b/internal/guard/testdata/screens/generate-unchecked.png differ
diff --git a/internal/guard/testdata/screens/generate-unchecked.xml b/internal/guard/testdata/screens/generate-unchecked.xml
index 2e77aad..94d0e35 100644
--- a/internal/guard/testdata/screens/generate-unchecked.xml
+++ b/internal/guard/testdata/screens/generate-unchecked.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png
index f5dea61..2775e20 100644
Binary files a/internal/guard/testdata/screens/generate.png and b/internal/guard/testdata/screens/generate.png differ
diff --git a/internal/guard/testdata/screens/generate.xml b/internal/guard/testdata/screens/generate.xml
index 385d183..7720c77 100644
--- a/internal/guard/testdata/screens/generate.xml
+++ b/internal/guard/testdata/screens/generate.xml
@@ -35,10 +35,10 @@
-
-
-
-
+
+
+
+
File configuration
@@ -234,10 +234,43 @@
+
+
+
+
+
+ Damage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json
index c154a75..b470af1 100644
--- a/internal/gui/text/locale/en.json
+++ b/internal/gui/text/locale/en.json
@@ -55,10 +55,22 @@
"description": "Shown where an archive says what it holds.",
"other": "Files inside each archive"
},
+ "DamageNone": {
+ "description": "Shown in the window.",
+ "other": "none"
+ },
+ "DamageSettingsFor": {
+ "description": "Shown in the window. Carries one value, {{.Damage}}, which has to stay spelled exactly that way.",
+ "other": "Settings for {{.Damage}}"
+ },
"DetailBoundary": {
"description": "The longer explanation behind the button beside a field name.",
"other": "Give the limit your system declares, as 10mb. Units count in 1024s, and the run prints the number it used."
},
+ "DetailDamage": {
+ "description": "The longer explanation behind the button beside a field name.",
+ "other": "The files come out the size you asked for and no reader will accept them, which is what a validator has to reject. The manifest records what was broken and says the file is expected to be rejected."
+ },
"DetailDonate": {
"description": "The longer explanation behind the button beside a field name.",
"other": "Opens the support page in your browser. The tool is free and stays free - this pays for the time that goes into it."
@@ -119,6 +131,10 @@
"description": "The name above a box somebody fills in.",
"other": "How many files"
},
+ "FieldDamage": {
+ "description": "The name above a box somebody fills in.",
+ "other": "Damage"
+ },
"FieldExpected": {
"description": "The name above a box somebody fills in.",
"other": "Expected outcome"
@@ -192,6 +208,10 @@
"description": "The line under a field name, saying what the field does.",
"other": "Three files: one byte under the limit, one on it, one over."
},
+ "HintDamage": {
+ "description": "The line under a field name, saying what the field does.",
+ "other": "Break the files on purpose."
+ },
"HintExpected": {
"description": "The line under a field name, saying what the field does.",
"other": "What the system under test should do with these files."
diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go
index 97e66e4..8f5078d 100644
--- a/internal/gui/text/screens.go
+++ b/internal/gui/text/screens.go
@@ -224,6 +224,31 @@ func PlaceholderLeftEmpty(declared string) string {
// PlaceholderNotStated stays. It is a different thing: a menu with no declared
// default, where nothing chosen really does mean nothing stated, and the
// manifest really does record it as unspecified.
+// The damage field. A file broken on purpose is the one thing this tool makes
+// that is not well formed, so the words around it say what will happen rather
+// than what it is called.
+func FieldDamage() string { return say("FieldDamage", "Damage") }
+
+func HintDamage() string {
+ return say("HintDamage", "Break the files on purpose.")
+}
+
+func DetailDamage() string {
+ return say("DetailDamage", "The files come out the size you asked for and no reader will accept them, which is what a validator has to reject. The manifest records what was broken and says the file is expected to be rejected.")
+}
+
+// DamageNone is the entry that means no damage, and it is what the menu opens
+// on. A menu cannot be empty, so "not damaged" has to be one of its values.
+func DamageNone() string { return say("DamageNone", "none") }
+
+// DamageSettingsFor heads the block of fields a chosen damage declares.
+//
+// A function rather than a constant with the name glued on, for the reason
+// SettingsFor gives below.
+func DamageSettingsFor(damageID string) string {
+ return sayf("DamageSettingsFor", "Settings for {{.Damage}}", map[string]any{"Damage": damageID})
+}
+
// SettingsFor heads the block of fields a chosen format declares.
//
// A function rather than a constant with the id glued on: languages do not
diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go
index b5708f7..312388d 100644
--- a/internal/gui/window/generate.go
+++ b/internal/gui/window/generate.go
@@ -7,8 +7,10 @@ import (
"fyne.io/fyne/v2/container"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
+ "github.com/donislawdev/TestingFilesGenerator/internal/recipe"
// The formats register themselves when this package is pulled in, and
// without it the registry the menu is built from is empty.
//
@@ -119,6 +121,14 @@ type Generate struct {
// the settings of a PNG are not the settings of a WAV.
props []parts.PropertyField
propBox *fyne.Container
+
+ // What to break about the files, in one piece rather than five fields.
+ //
+ // Grouped because the type ceiling is a real limit and the answer to it is
+ // to move state out rather than raise the number - and because these five
+ // are one thing: a menu, the fields whatever it chose declares, the box
+ // they sit in and whether that box is folded away.
+ damage damagePanel
// settings is the fold those fields are put in, and settingsFolded is
// whether it is away. The flag lives on the screen rather than in the fold
// because the fold is built again on every change of format - one that
@@ -261,6 +271,16 @@ func (g *Generate) buildFields() {
g.formatPick.SetSelected(ids[0])
}
+ // Every damage this build registered, asked of the registry rather than
+ // listed here - so a second one appears in this menu on the day it is
+ // registered. DamageNone leads, because a menu cannot be empty and "not
+ // damaged" is what this screen opens on: damage is the exception rather
+ // than the ordinary run.
+ g.damage.box = parts.FieldColumn()
+ g.damage.pick = parts.NewChooser(append([]string{text.DamageNone()}, damage.Names()...),
+ g.onDamageChosen)
+ g.damage.pick.SetSelected(text.DamageNone())
+
g.size = entry("10mb", "")
g.count = entry("1", "")
g.id = entry("files", "")
@@ -328,6 +348,12 @@ func (g *Generate) settingsSection() []fyne.CanvasObject {
// The settings the chosen format declares land here, under the ones
// every format has.
g.propBox,
+ // Damage sits under them rather than beside the format, because it
+ // is a question about the finished file rather than about which
+ // file to make.
+ add(recipe.KeyDamage, text.FieldDamage(), text.HintDamage(),
+ g.tips.Say(text.DetailDamage()), g.damage.pick),
+ g.damage.box,
),
parts.Section(text.SectionOutput(),
add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), g.tips.Say(text.DetailOutputDir()),
@@ -358,6 +384,12 @@ func (g *Generate) onFormatChosen(id string) {
// put, under widgets no longer on the screen.
g.fields.KeepFirst(g.fixed)
+ // The damage parameters sit after the same mark, so they were just taken
+ // away too. Deferred rather than called at the end because this function
+ // has three ways out, and the one that returns early on an unknown format
+ // would otherwise leave the screen holding a menu with no fields under it.
+ defer g.rebuildDamageFields()
+
d, err := format.Get(id)
if err != nil {
// The registry filled this menu, so this cannot happen from a press. It
@@ -387,6 +419,98 @@ func (g *Generate) onFormatChosen(id string) {
g.propBox.Refresh()
}
+// onDamageChosen redraws the fields the chosen damage declares.
+//
+// A press only. The rebuild that follows a change of format goes through
+// rebuildDamageFields directly, because the format did not change what was
+// chosen here - it took the widgets away and they have to come back the same.
+
+// damagePanel is the damage half of the generate screen.
+//
+// The menu is built with the fields that are always here, because the chosen
+// format must not take it away. Its PARAMETERS are rebuilt whenever the format
+// is - they sit after the same mark, so a change of format takes them with it,
+// and rebuilding both from one place keeps one rule instead of two.
+type damagePanel struct {
+ pick *parts.Chooser
+ props []parts.PropertyField
+ box *fyne.Container
+ fold *parts.Folding
+ // folded is whether the parameters are away, kept here rather than in the
+ // fold because the fold is built again on every rebuild and one that
+ // remembered nothing would spring open unasked.
+ folded bool
+}
+
+func (g *Generate) onDamageChosen(string) {
+ if !g.ready {
+ return
+ }
+ g.rebuildDamageFields()
+}
+
+// rebuildDamageFields draws the parameters of whatever damage is chosen.
+//
+// Nothing when the choice is none, which is what this screen opens on. The
+// fields come from parts.DeclaredFields, the same call the format settings and
+// the preset parameters go through - so a damage that gains a parameter gains
+// its field with no window code at all, which is what makes the registry
+// answer D1 rather than this screen answering it.
+func (g *Generate) rebuildDamageFields() {
+ g.damage.box.RemoveAll()
+ g.damage.props = nil
+ g.damage.fold = nil
+
+ id := g.damage.pick.Selected
+ if id == "" || id == text.DamageNone() {
+ g.damage.box.Refresh()
+ return
+ }
+
+ d, err := damage.Get(id)
+ if err != nil {
+ // The registry filled this menu, so a press cannot get here. A build
+ // where the two have come apart can, and saying so beats a screen with
+ // a damage chosen and no reason given.
+ g.refuse(err)
+ g.damage.box.Refresh()
+ return
+ }
+
+ fields, objects := parts.DeclaredFields(d.Parameters, g.fields, g.tips)
+ g.damage.props = fields
+ if len(objects) == 0 {
+ g.damage.box.Refresh()
+ return
+ }
+
+ g.damage.fold = parts.NewInnerFolding(text.DamageSettingsFor(d.ID), objects...)
+ g.damage.fold.OnChange = func(open bool) { g.damage.folded = !open }
+ g.damage.fold.Set(!g.damage.folded)
+ g.damage.box.Add(g.damage.fold.Object())
+ g.damage.box.Refresh()
+}
+
+// chosenDamage is what the screen asks the engine to break, or nothing.
+//
+// One entry rather than a list, and that is a limit of this screen rather than
+// of the engine: the chain carries as many as a recipe names, and this menu
+// offers one. The batch screen is where a list belongs, next to the other
+// things a recipe says and a single target does not.
+func (g *Generate) chosenDamage() damage.Chain {
+ id := g.damage.pick.Selected
+ if id == "" || id == text.DamageNone() {
+ return nil
+ }
+ values := damage.Values{}
+ for _, f := range g.damage.props {
+ if v := f.Value(); v != "" {
+ values[f.Name] = v
+ }
+ }
+ return damage.Chain{{ID: id, Values: values}}
+}
+
// settingsSaid is what the folded settings section says about itself.
//
// Only what was stated, and it matters here more than anywhere: this section
@@ -508,6 +632,7 @@ func (g *Generate) settle() ([]engine.Target, engine.Options, error) {
NameTmpl: g.name.Text,
Label: g.label.Checked,
Properties: g.properties(),
+ Damage: g.chosenDamage(),
}}, engine.Options{
OutDir: g.outDir.Text,
Seed: seed,
diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go
index 7ee7dc2..1fa05dc 100644
--- a/internal/gui/window/preset.go
+++ b/internal/gui/window/preset.go
@@ -324,9 +324,7 @@ func engineTarget(t recipe.Target) engine.Target {
Sizes: t.Sizes,
Contains: contentsOf(t),
SizeFromContents: t.SizeFromContents,
- SizeIsRange: t.SizeIsRange,
- SizeMin: t.SizeMin,
- SizeMax: t.SizeMax,
+ Range: engine.SizeRange(t.Range),
BoundaryLimit: t.BoundaryLimit,
NameTmpl: t.Name,
Label: t.Label,
diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go
index 370ff70..4748d35 100644
--- a/internal/manifest/manifest.go
+++ b/internal/manifest/manifest.go
@@ -234,6 +234,23 @@ type File struct {
LabelEmbedded bool `json:"label_embedded"`
+ // Damage is what was deliberately broken about this file, in the order it
+ // was applied.
+ //
+ // Left out entirely for a file nothing damaged, rather than written as an
+ // empty list, so a consumer can tell "not damaged" from "damaged with
+ // nothing" - and so every manifest written before this existed stays byte
+ // for byte what it was.
+ //
+ // The order is part of it. Two damages applied the other way round are
+ // different bytes, and D11 promises those bytes do not move, so a record
+ // that lost the order would describe a file that cannot be rebuilt from it.
+ //
+ // Adding the field does not move manifest_version, the same way the
+ // toolchain field did not: a reader of schema 1.0 that does not know this
+ // key ignores it, and no key it does know has changed meaning.
+ Damage []Damage `json:"damage,omitempty"`
+
// Notes are the things that must not be swallowed - a label that did not
// fit, a fidelity level lowered on the fly, a file that failed.
Notes []Note `json:"notes,omitempty"`
@@ -244,6 +261,17 @@ type File struct {
Error string `json:"error,omitempty"`
}
+// Damage is one thing broken about a file, with the settings it was given.
+//
+// The settings are recorded as they were resolved rather than as they were
+// written, defaults included, because a consumer asking "what was done to this
+// file" should not have to know what the default was in the build that wrote
+// it.
+type Damage struct {
+ Type string `json:"type"`
+ Settings map[string]string `json:"settings,omitempty"`
+}
+
// Hashes identify the bytes.
type Hashes struct {
SHA256 string `json:"sha256"`
diff --git a/internal/recipe/compose.go b/internal/recipe/compose.go
index 71f8de2..4f2aa2e 100644
--- a/internal/recipe/compose.go
+++ b/internal/recipe/compose.go
@@ -340,7 +340,7 @@ const (
KeyGroup = "group"
KeyLabel = "label"
KeyFill = "fill"
- KeyMutations = "mutations"
+ KeyDamage = "damage"
KeyProperties = core.KeyProperties
KeyExpected = "expected"
KeyExpectedReason = "expected.reason"
diff --git a/internal/recipe/damage.go b/internal/recipe/damage.go
new file mode 100644
index 0000000..0d20cf6
--- /dev/null
+++ b/internal/recipe/damage.go
@@ -0,0 +1,188 @@
+package recipe
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/core"
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
+ "github.com/donislawdev/TestingFilesGenerator/internal/format"
+)
+
+// KeyDamageType is the key inside a damage entry that names which damage it is.
+const KeyDamageType = "type"
+
+// damages reads what a target said it wanted broken.
+//
+// A list rather than one value, from the first day. Composition is a
+// requirement rather than an extension, and the shape has to carry it before
+// anything is written against the contract - a single value promoted to a list
+// later would be a change to a public name under untouchable rule 10.
+//
+// An entry is a word or a mapping, which is how expected already reads. The
+// word is the common case and stays short, and the mapping is there the moment
+// a damage takes settings.
+func damages(p *problems, where spot, raw []any) damage.Chain {
+ if raw == nil {
+ return nil
+ }
+ if len(raw) == 0 {
+ // Not silently nothing. Somebody wrote the key, so they are expecting
+ // broken files, and a run that quietly produced whole ones would be
+ // the silence untouchable rule 6 forbids.
+ p.add(where.of(KeyDamage), fmt.Sprintf("%s says damage and names none", where),
+ "a target that damages nothing produces the same files as one that does not mention it",
+ "name a damage, or remove the line")
+ return nil
+ }
+
+ chain := make(damage.Chain, 0, len(raw))
+ for i, entry := range raw {
+ if spec, ok := oneDamage(p, where.entry(KeyDamage, i), entry); ok {
+ chain = append(chain, spec)
+ }
+ }
+ return chain
+}
+
+// oneDamage reads a single entry of the list.
+func oneDamage(p *problems, at spot, entry any) (damage.Spec, bool) {
+ switch x := entry.(type) {
+ case string:
+ return checkedDamage(p, at, x, damage.Values{})
+ case map[string]any:
+ return mappedDamage(p, at, x)
+ default:
+ p.add(at.of(KeyDamageType), fmt.Sprintf("%s is neither a name nor a set of settings", at),
+ "a damage is written as its name, or as a mapping with type and the settings it takes",
+ "write the name on its own, or a mapping starting with type")
+ return damage.Spec{}, false
+ }
+}
+
+// mappedDamage reads the long form: a type and the settings for it.
+func mappedDamage(p *problems, at spot, x map[string]any) (damage.Spec, bool) {
+ rawType, stated := x[KeyDamageType]
+ if !stated {
+ p.add(at.of(KeyDamageType), fmt.Sprintf("%s does not say which damage it is", at),
+ "a damage written as a mapping names itself with type",
+ "add type, with the name of the damage")
+ return damage.Spec{}, false
+ }
+ id, isScalar := scalarText(rawType)
+ if !isScalar {
+ p.add(at.of(KeyDamageType), fmt.Sprintf("%s names a damage that is not a word", at),
+ "the type of a damage is the name this build knows it by",
+ "write the name as a word")
+ return damage.Spec{}, false
+ }
+
+ values := damage.Values{}
+ for _, key := range sortedKeysOf(x) {
+ if key == KeyDamageType {
+ continue
+ }
+ text, single := scalarText(x[key])
+ if !single {
+ p.add(at.of(key), fmt.Sprintf("%s: %s is a list or a block", at, key),
+ "a damage setting takes one value, the way a format property does",
+ "give it one value, for example bytes: 16")
+ continue
+ }
+ values[key] = text
+ }
+ return checkedDamage(p, at, id, values)
+}
+
+// checkedDamage refuses a damage this build does not know, and a setting its
+// declaration does not allow.
+//
+// The wording comes from the declaration rather than from here, which is the
+// whole reason a damage parameter is a format.Property: a mistyped setting on
+// a damage and a mistyped setting on a format are refused in one voice, and
+// neither copies the other's sentences. Same shape as askTheFormat, one axis
+// over.
+func checkedDamage(p *problems, at spot, id string, values damage.Values) (damage.Spec, bool) {
+ d, err := damage.Get(id)
+ if err != nil {
+ var unknown *damage.UnknownError
+ if errors.As(err, &unknown) {
+ p.add(at.of(KeyDamageType), fmt.Sprintf("%s: %s", at, unknown.What()),
+ unknown.Why(), unknown.Instead())
+ } else {
+ p.add(at.of(KeyDamageType), fmt.Sprintf("%s: %s", at, err.Error()), "", "")
+ }
+ return damage.Spec{}, false
+ }
+
+ ok := true
+ for _, bad := range d.CheckEach(values) {
+ reportDamageSetting(p, at, bad)
+ ok = false
+ }
+ return damage.Spec{ID: id, Values: values}, ok
+}
+
+// reportDamageSetting puts one refusal on the box it belongs to.
+//
+// The same three branches askTheFormat keeps, and for the reason written there:
+// every problem the registry returns names its key today, and a fourth kind
+// added without one has to arrive unaddressed rather than vanish.
+func reportDamageSetting(p *problems, at spot, bad error) {
+ var about interface{ AboutSetting() string }
+ if !errors.As(bad, &about) {
+ p.add(at.of(KeyDamage), bad.Error(), "", "")
+ return
+ }
+ where := at.of(about.AboutSetting())
+
+ var value *format.PropertyValueError
+ if errors.As(bad, &value) {
+ p.add(where, fmt.Sprintf("%s: %s cannot be %q", at, value.Key, value.Value),
+ core.InTheWordsOf(value.Reason, value.Key), value.Remedy)
+ return
+ }
+ var unknown *format.UnknownPropertyError
+ if errors.As(bad, &unknown) {
+ p.add(where, fmt.Sprintf("%s: %s", at, unknown.What()), unknown.Why(), unknown.Instead())
+ return
+ }
+ p.add(where, fmt.Sprintf("%s: %s", at, bad.Error()), "", "")
+}
+
+// refuseImpossibleExpectation stops a target that breaks a file and expects it
+// to be accepted.
+//
+// Only accept, and the narrowness is the decision rather than caution. reject
+// is what damage implies and is what a target gets when it says nothing.
+// sanitize and unspecified are both sensible questions about a broken file - a
+// system under test may be expected to repair it, or that may be exactly what
+// the test is asking - so neither is touched.
+//
+// The alternative was letting one side win quietly, and both ways of doing
+// that are worse. The recipe winning puts accept in the manifest about a file
+// a judge was measured to refuse, which is the tool lying in the one place its
+// value lives. Damage winning overwrites what somebody wrote, and the
+// regression surface says an expectation stated in a recipe reaches the
+// manifest unchanged. Owner's call on 2026-09-09.
+func refuseImpossibleExpectation(p *problems, where spot, t Target) {
+ if len(t.Damage) == 0 || t.Expected != outcomeAccept {
+ return
+ }
+ refusal := &damage.ExpectationConflictError{Outcome: t.Expected}
+ p.add(where.of(refusal.AboutSetting()),
+ fmt.Sprintf("%s: %s", where, refusal.What()),
+ refusal.Why(), refusal.Instead())
+}
+
+// sortedKeysOf puts the settings of one entry in a stable order, so the same
+// recipe always reports the same one first.
+func sortedKeysOf(x map[string]any) []string {
+ out := make([]string, 0, len(x))
+ for key := range x {
+ out = append(out, key)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/internal/recipe/recipe.go b/internal/recipe/recipe.go
index cff7179..45c6954 100644
--- a/internal/recipe/recipe.go
+++ b/internal/recipe/recipe.go
@@ -9,6 +9,8 @@ import (
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
+
+ "github.com/donislawdev/TestingFilesGenerator/internal/damage"
)
// SchemaVersion is the recipe schema this build understands. It is versioned
@@ -77,22 +79,40 @@ type Target struct {
// the preset it came from.
Group string
Properties map[string]string
+ // Damage is what to break about these files, in the order to break it.
+ //
+ // Empty for every target that does not ask, which is every target written
+ // before this existed - and that is what keeps D11 whole: a run with no
+ // damage goes through the same writer it always did.
+ Damage damage.Chain
// Contains is what a container holds, one entry per group.
Contains []Content
// SizeFromContents is set when contains was given without a size, so the
// container works the size out from what it holds.
SizeFromContents bool
- // SizeMin and SizeMax hold the range when the target asked for one, and
- // SizeIsRange says it did. The sizes themselves are not settled here.
+ // Range holds what a target asked for when it asked for one. The sizes
+ // themselves are not settled here.
+ //
+ // One field rather than three, for the reason engine.Target gives: the
+ // type stood at the crowding band, and these three were one statement all
+ // along - a minimum without the flag beside it says nothing.
//
// They cannot be. A range is drawn from the seed, and the --seed flag
// overrides the recipe after this package has finished reading it, so a
// size drawn at validation time would belong to a different run than the
// one the manifest describes. The engine draws them, which is still before
// anything reaches the disk, so AR10 holds and --dry-run stays exact.
- SizeIsRange bool
- SizeMin int64
- SizeMax int64
+ Range SizeRange
+}
+
+// SizeRange is a size drawn per file rather than stated.
+//
+// Its own type here as well as in the engine rather than one shared between
+// them, because this package stays a description of a recipe and the engine
+// stays what runs one - the same reason Content exists twice.
+type SizeRange struct {
+ Used bool
+ Min, Max int64
}
// Content is one group of files inside a container.
diff --git a/internal/recipe/target.go b/internal/recipe/target.go
index d26a1e3..21ac53e 100644
--- a/internal/recipe/target.go
+++ b/internal/recipe/target.go
@@ -39,8 +39,23 @@ type rawTarget struct {
Boundary *scalar `yaml:"boundary"`
SizeRange *scalar `yaml:"size-range"`
Contains []map[string]scalar `yaml:"contains"`
- Mutations []map[string]any `yaml:"mutations"`
- Fill *scalar `yaml:"fill"`
+
+ // Damage is what to break about the files, in the order to break it.
+ //
+ // []any rather than a list of mappings, because an entry is a word or a
+ // mapping - the shape expected already has, for the same reason: the
+ // common case is one name and it should stay one word.
+ //
+ // It replaced a reserved key called mutations, which this build refused
+ // with a message pointing at a module that will never exist. The rename
+ // was free only because the key was always refused and the manifest field
+ // beside it was always empty, so nobody was standing on either. In this
+ // project a mutation is a change to the CODE that proves a guard works,
+ // with its own runner and its own chapter, and one word for two things is
+ // what GLOSSARY.md exists to prevent.
+ Damage []any `yaml:"damage"`
+
+ Fill *scalar `yaml:"fill"`
}
// DefaultCount is how many files a target produces when it does not say.
@@ -109,7 +124,9 @@ func (rt rawTarget) validate(p *problems, index int, def Defaults) Target {
t.Label = *rt.Label
}
+ t.Damage = damages(p, where, rt.Damage)
t.Expected, t.ExpectedReason = expectation(p, where, rt.Expected)
+ refuseImpossibleExpectation(p, where, t)
if group, ok := oneValue(p, where.of("group"), where.String()+" {setting}", "group: invoices", rt.Group); ok {
t.Group = group
}
@@ -119,10 +136,6 @@ func (rt rawTarget) validate(p *problems, index int, def Defaults) Target {
// refuseSections names the parts of a target this build cannot honour.
func (rt rawTarget) refuseSections(p *problems, where spot) {
- if rt.Mutations != nil {
- p.notYetIn(where, "mutations", "damaged files arrive with the Chaos Lab",
- "remove the section")
- }
if rt.Fill != nil {
p.notYetIn(where, "fill", "the fill mode is not settable yet",
"remove the line - content is generated from the seed")
@@ -209,7 +222,7 @@ func (rt rawTarget) resolveSize(p *problems, where spot, count int, t *Target) {
if !ok {
break
}
- t.SizeIsRange, t.SizeMin, t.SizeMax = true, low, high
+ t.Range.Used, t.Range.Min, t.Range.Max = true, low, high
// The values stay zero here and the engine replaces them. What this
// list carries at this point is the number of files, exactly as it
// does for contains.
@@ -309,7 +322,15 @@ func KnownReason(r string) bool { return reasons[r] }
// command line twice, and the window was about to be the fifth copy. That is
// exactly the argument written above Reasons - two copies of a closed list is
// how the surfaces drift, which is D1 one level down.
-var outcomes = []string{"accept", "reject", "sanitize", "unspecified"}
+var outcomes = []string{outcomeAccept, "reject", "sanitize", "unspecified"}
+
+// outcomeAccept is the one outcome a damaged file cannot have.
+//
+// Named rather than typed twice, because the refusal in damage.go is about this
+// exact word. Below the list rather than above it: the paragraph over the list
+// documents the LIST, and a declaration slipped in between would quietly take
+// that paragraph for itself and leave the list with none.
+const outcomeAccept = "accept"
// Outcomes is the closed list, for the surfaces that have to offer it.
//