Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/race-weights
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ unlisted 0.58
99.9 TestEveryBase64TextSizeIsExact
93.8 TestAFilmPictureLargerThanAnyAV1LevelIsRefusedNotBroken
73.6 TestEveryAvifRungStillFitsInsideItsDeclaredCeilings
68.4 TestEveryFormatsMapIsTheOneItsStructureReadsBack
67.9 TestADeclaredDefaultIsTheOneTheFormatUses
65.6 TestADamagedFileIsExactlyTheSizeAskedFor
64.3 TestNoTrackedFileCarriesPrivateContent
63.8 TestNoGeneratorHoldsTheWholeFileInMemory
60.8 TestEveryDamageIsRefusedByTheStructuralCheckInEveryFormat
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ because it turns other people's test suites red.
readers differ - a text cut short is still text - the manifest says the
outcome is `unspecified`, offers both answers in the new `policy_options`
field and says why. `tfg damage <id>` lists those formats.
- **A file cut short says where the cut fell and how much of its content is
left.** In many formats a large file is mostly padding after its content,
so `--damage truncate:missing=90` on a 1 MB PNG still leaves the whole
picture. The `truncate` entry in the manifest's `damage` now carries `cut`:
`in` (`content`, `padding`, `structure` or `end`), `content_bytes` and
`content_kept_bytes`. A cut in the padding also gets the note
`cut_in_padding`. In a compressed tar.gz a cut in the padding is reported as
a cut in the content.

- **tfg is on npm, for tests in Node.**
`npm install --save-dev testing-files-generator` gives Playwright, Vitest
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,13 @@ how many more there are, and the manifest lists every one. The label is still
written before the damage, so the damage can overwrite it, and the manifest does
not promise it is there.

A cut falls on whatever the file holds at that point. A large picture, sound,
document or archive is mostly padding after its content, so `truncate` can leave
the content whole. The manifest says where each cut fell: the `truncate` entry
carries `cut`, with `in` (`content`, `padding`, `structure` or `end`),
`content_bytes` and `content_kept_bytes`. A cut in the padding also gets the note
`cut_in_padding`.

The smallest file a damage can be given follows its settings, so the column is
measured with the defaults. Ask for less and the run is refused before anything is
written, naming a size that would work. A setting outside what the damage takes,
Expand Down Expand Up @@ -988,7 +995,7 @@ Yes. Add `--damage zero-head` and the file comes out at exactly the size you
asked for with its first bytes overwritten by zeros, so a reader turns it away,
and the manifest says your system should reject it. `--damage truncate` cuts the
end off and `--damage append-junk` writes bytes after it, still at the size you
asked for. [`tfg damage`](#tfg-damage) lists what this build can do, what each
asked for, and the manifest says where a cut fell. [`tfg damage`](#tfg-damage) lists what this build can do, what each
damage takes, and where the outcome depends on your system.

```
Expand Down
34 changes: 34 additions & 0 deletions internal/damage/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,40 @@ func (c Chain) Stretches() bool {
return false
}

// Cut is where the damage that cuts the tail cuts what the generator writes,
// for a file of size bytes on disk: its place in the list, and how many of the
// generator's bytes it keeps, which is the offset of the first byte it drops.
//
// The LAST of the damages that cut before anything changes the length in
// another way: each keeps the start of what the one before it kept, so the
// last keeps the least, and that is where the file ends (two truncates of half
// a 1000 B text keep 1000 B, not the 2000 B the first one passes on).
//
// False when no damage cuts the tail, and when one before it changes the
// length - the bytes it keeps would then not be the generator's, and an offset
// in them says nothing about the map of the file the generator wrote. The same
// condition as Stretches, for the same reason.
func (c Chain) Cut(size int64) (int, int64, bool) {
steps, err := c.steps()
if err != nil {
return 0, 0, false
}
outs, _, ok := lengths(steps, size)
if !ok {
return 0, 0, false
}
at, kept, found := 0, int64(0), false
for i, s := range steps {
switch {
case s.d.CutsTail:
at, kept, found = i, outs[i], true
case s.d.Length != nil:
return at, kept, found
}
}
return at, kept, found
}

// Open builds the chain as one writer for a file of size bytes on disk, with
// the first damage of the list outermost so that the bytes meet them in the
// order written. seed is the file's own seed, and each damage is handed one
Expand Down
9 changes: 8 additions & 1 deletion internal/damage/truncate.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,15 @@ func init() {
// anyone says it, and the sentence below names the percent.
Min: fewestMissing, Max: mostMissing,
Default: strconv.Itoa(defaultMissing),
// The last sentence is the limit a person cannot see from the
// outside: in 13 formats of 32 the padding stands after the
// content, so a large file cut at any share keeps its whole
// picture or sound (docs/TRUNCATE-2026-11-11.md). The manifest
// says per file where the cut fell.
Detail: "How much of the whole file is gone, as a percentage. " +
"At least this much is cut - a few bytes more where the format cannot end exactly there.",
"At least this much is cut - a few bytes more where the format cannot end exactly there. " +
"A large file is often mostly padding after its content, so the content can stay whole - " +
"the manifest says what each cut left.",
},
},
Floor: func(v Values) int64 { return smallestKept(missingShare(v)) },
Expand Down
146 changes: 146 additions & 0 deletions internal/engine/cut.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package engine

import (
"io"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
)

// Where a cut fell in the file it was made in, for the manifest
// (damage[].cut, docs/TRUNCATE-2026-11-11.md section 6.9).
//
// Found out while writing rather than planned. The generator marks the map of
// the file as it writes it (format.Mark), and the map is all a cut needs: the
// role of the first byte it drops, and how much of the content lies before it.

// wrote is what writing a file found out that its manifest entry carries.
type wrote struct {
sha string
// cut is where the damage that cut the file fell, nil for a file nothing
// cut.
cut *manifest.Cut
}

// cutMap is the map a file's generator marks while it writes, and the offset
// the cut falls at.
type cutMap struct {
format.MapRecorder
at int64
}

// cutMapper is sink with a map recorder around it, and the recorder - or sink
// and nil when nothing cuts the file, which leaves every other file written
// exactly as before.
func cutMapper(f PlannedFile, sink io.Writer) (io.Writer, *cutMap) {
if !f.Damaged() {
return sink, nil
}
_, at, ok := f.Target.Damage.Cut(f.Plan.Bytes)
if !ok {
return sink, nil
}
m := &cutMap{MapRecorder: format.MapRecorder{W: sink}, at: at}
return m, m
}

// cut is where the cut fell, nil for a file nothing cut. The map is not
// complete: the generator stopped at the cut, so a region it had not ended by
// then has no end it can be given.
func (m *cutMap) cut(f PlannedFile) *manifest.Cut {
if m == nil {
return nil
}
return cutIn(m.Map(wholeOf(f), false), m.at)
}

// wholeOf is how long the file the generator was asked for is. A file cut
// short is planned through the damage, which asks the generator for more than
// reaches the disk.
func wholeOf(f PlannedFile) int64 {
if m, ok := f.Plan.Memo.(damagedMemo); ok {
return m.plan.Bytes
}
return f.Plan.Bytes
}

// cutIn is where a cut at offset at fell in the map of a file.
//
// The role is that of the region the first dropped byte lies in, and a gap the
// map left is counted to the region before it - a map with gaps is a defect the
// guard TestEveryFormatsMapIsTheOneItsStructureReadsBack catches, and here it
// costs a word rather than a file. The content's length is stated only when
// every region of content has a known end: a generator stopped at the cut never
// marks what lies after it, and a total missing those would be a guess.
func cutIn(regions []format.Region, at int64) *manifest.Cut {
c := &manifest.Cut{In: string(format.RoleContent)}
var total int64
known, any := true, false
for _, r := range regions {
if r.At <= at {
c.In = string(r.Role)
}
if r.Role != format.RoleContent {
continue
}
any = true
known = known && r.Length >= 0
total += max(0, r.Length)
c.ContentKeptBytes += max(0, min(at, endOf(r, at))-r.At)
}
if known && any {
c.ContentBytes = &total
}
return c
}

// endOf is where r ends, or at for a region whose end is not known: the last
// one marked, so the cut lies inside it.
func endOf(r format.Region, at int64) int64 {
if r.Length < 0 {
return at
}
return r.At + r.Length
}

// withCutNote is notes with the note a cut that missed the content gets.
func (res *Result) withCutNote(notes []manifest.Note, c *manifest.Cut) []manifest.Note {
if code, said, ok := cutNote(c); ok {
return append(notes, res.note(code, said))
}
return notes
}

// cutNote is the note a cut that missed the content gets, or false.
//
// Only for a cut in the padding, the case that looks like a cut and is not one:
// a picture cut at 90% that every viewer shows whole. Said in two ways, because
// padding before the content takes all of it with any share.
func cutNote(c *manifest.Cut) (string, core.Said, bool) {
if c == nil || c.In != string(format.RolePadding) {
return "", core.Said{}, false
}
if c.ContentKeptBytes == 0 {
return cutInPadding, core.Says("engine.CutBeforeContent",
"The cut fell in the padding before the content, so none of the content is left."), true
}
return cutInPadding, core.Says("engine.CutAfterContent",
"The cut fell in the padding after the content, so the content is whole and only the end of the file is missing."), true
}

// cutInPadding is the code of that note. A public name under untouchable rule
// 10 (owner's call, 2026-11-12).
const cutInPadding = "cut_in_padding"

// forecastCut is the longest cut entry a file can be given, for the record a
// run is told before its first byte (PredictRecord).
//
// The real one is known only after writing, so this is the most it can come
// to: the padding, whose note is the longer of the two, and both numbers as
// long as the whole file, which neither can pass. More than the run writes,
// which is the safe side for the free space it is held against.
func forecastCut(f PlannedFile) *manifest.Cut {
whole := wholeOf(f)
return &manifest.Cut{In: string(format.RolePadding), ContentBytes: &whole, ContentKeptBytes: whole}
}
14 changes: 11 additions & 3 deletions internal/engine/damage.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,21 @@ func damagedExpectation(f PlannedFile) manifest.Expected {
//
// 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 {
//
// cut is where the damage that cut the file fell, and goes to that damage's
// entry alone.
func damageFor(f PlannedFile, cut *manifest.Cut) []manifest.Damage {
if !f.Damaged() {
return nil
}
at, _, cuts := f.Target.Damage.Cut(f.Plan.Bytes)
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()})
for i, s := range f.Target.Damage {
d := manifest.Damage{Type: s.ID, Settings: s.Resolved()}
if cuts && i == at {
d.Cut = cut
}
out = append(out, d)
}
return out
}
26 changes: 7 additions & 19 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error)
// separate one that can drift away from it.
res.Started = true
for _, f := range files {
m.Add(res.entryFor(f, "", false, nil))
m.Add(res.entryFor(f, wrote{}, false, nil))
}
m.Run.Complete = true
return res, nil
Expand Down Expand Up @@ -610,7 +610,7 @@ func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error)
for i, r := range written {
switch {
case r.ok:
m.Add(res.entryFor(files[i], r.sha, true, nil))
m.Add(res.entryFor(files[i], r.wrote, true, nil))
case r.err == nil:
// Never started. A cancelled run leaves these behind and they are
// neither a success nor a failure, so they get no entry - which is
Expand All @@ -625,7 +625,7 @@ func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error)
// One file failing does not end the run. Nine thousand good
// files are worth keeping, and the entry says what went wrong.
res.Failures++
m.Add(res.entryFor(files[i], "", false, r.err))
m.Add(res.entryFor(files[i], wrote{}, false, r.err))
}
}

Expand Down Expand Up @@ -723,7 +723,7 @@ func releaseRunLock(path string, own os.FileInfo) {
_ = core.RemoveOwn(path, own)
}

func (res *Result) entryFor(f PlannedFile, sha string, materialized bool, failure error) manifest.File {
func (res *Result) entryFor(f PlannedFile, w wrote, materialized bool, failure error) manifest.File {
var notes []manifest.Note
for _, n := range f.Plan.Notes {
notes = append(notes, res.note(n.Code, n.Detail))
Expand All @@ -745,6 +745,7 @@ func (res *Result) entryFor(f PlannedFile, sha string, materialized bool, failur
notes = append(notes, res.note("label_not_guaranteed",
core.Says("engine.LabelNotGuaranteed", "The label was written before the file was damaged, so the damage may have overwritten it.")))
}
notes = res.withCutNote(notes, w.cut)

e := manifest.File{
ID: f.ID,
Expand All @@ -755,13 +756,13 @@ func (res *Result) entryFor(f PlannedFile, sha string, materialized bool, failur
Bytes: f.Plan.Bytes,
Format: f.Desc.ID,
Fidelity: string(f.Desc.Fidelity),
Hashes: manifest.Hashes{SHA256: sha},
Hashes: manifest.Hashes{SHA256: w.sha},
Seed: core.SeedLabel(f.Seed),
Generator: manifest.GeneratorRef{Name: f.Desc.ID, Version: f.Desc.GeneratorVersion},
Determinism: string(f.Plan.Determinism),
Properties: props,
LabelEmbedded: label,
Damage: damageFor(f),
Damage: damageFor(f, w.cut),
Notes: notes,
Expected: expectationFor(f),
Group: f.Target.Group,
Expand All @@ -779,19 +780,6 @@ func (res *Result) entryFor(f PlannedFile, sha string, materialized bool, failur
return e
}

// note is one note for the manifest, remembered as a sentence once for each
// different thing it says.
func (res *Result) note(code string, said core.Said) manifest.Note {
detail := said.String()
if res.NotesSaid == nil {
res.NotesSaid = map[string]core.Said{}
}
if _, have := res.NotesSaid[detail]; !have {
res.NotesSaid[detail] = said
}
return manifest.Note{Code: code, Detail: detail}
}

func expectationFor(f PlannedFile) manifest.Expected {
switch f.Target.Expected {
case "":
Expand Down
22 changes: 22 additions & 0 deletions internal/engine/entrynotes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package engine

import (
"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
)

// The notes a manifest entry carries, each sentence remembered once for the
// window to say in its own language.

// note is one note for the manifest, remembered as a sentence once for each
// different thing it says.
func (res *Result) note(code string, said core.Said) manifest.Note {
detail := said.String()
if res.NotesSaid == nil {
res.NotesSaid = map[string]core.Said{}
}
if _, have := res.NotesSaid[detail]; !have {
res.NotesSaid[detail] = said
}
return manifest.Note{Code: code, Detail: detail}
}
Loading
Loading