All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Changes that a user of the tool would notice. Written for people who run
tfg, not for people who read its source.
A major version bump is also a statement about file hashes. Anything that changes the bytes of a generated file is listed here as a breaking change, because it turns other people's test suites red.
- The website in twenty-two languages. Beside English and Polish it now
reads in Simplified and Traditional Chinese, Japanese, German, French,
Spanish, Brazilian Portuguese, Italian, Indonesian, Russian, Turkish, Czech,
Vietnamese, Hindi, Korean, Arabic, Romanian, Dutch, Ukrainian and Thai.
Every page exists in every language, with the same facts: the numbers come
from the program, the same as before. A menu in the header lists the
languages by the names they call themselves. Arabic reads from right to
left, and commands and code stay left to right in it. Each page tells a
search engine which language it is in and where its translations are, so
somebody searching in their own language is sent to the page in it. The
pages of Latin-script languages have addresses in the language
(
/de/dokumentation/), the others keep the English words under the language prefix (/ja/docs/). The window and the command line are not part of this. - Two guides on the website. How to make a corrupt file for testing, with the damages listed from the program itself, and how to generate test files in a CI pipeline, with a GitHub Actions workflow, a GitLab job and the PowerShell catch. Both are in every language of the site.
- A Tools tab, and
tfg tool. Small things to do with files you already have, in the window and on the command line with the same settings. The first works out the checksum of a file - md5, sha1, sha256, sha512 or crc32, sha256 unless you ask - and compares it with one you were given, telling the algorithm from its length:tfg tool checksum file.iso --expected <checksum>. A checksum that does not match ends with code 7, the same astfg verify. The file is only read.tfg tool listandtfg tool show <id>say what there is, both with--json. - The window in Polish, and a Preferences tab. The window now speaks the
language your system is set to when it has that language, and English
otherwise - so on a system set to Polish it opens in Polish. Preferences
lets you choose another language. The choice takes effect the next time
the window opens, and Restart now does that at once. It is off while files
are being made, and anything typed on the other tabs is cleared. In Polish
the settings of every format, the presets and the sizes of a run are Polish
as well, so a size reads 10,0 MB. The values you pick from a list
(portrait, a4), the names of the formats and the reason a run is refused
stay in English, and so do the command line, recipes and manifests. The
same tab says what the window keeps between runs (the output directory,
the window size and the language), shows the folder they are kept in, and
Forget clears them.
tfg-gui --pseudo-languageopens the window with every sentence longer and accented, to see how a translation will fit before one exists. It is never saved as a choice. - A Windows installer,
tfg-setup_<version>_windows_amd64.msi, beside the zip archives. It installs the window and the command line for every account on the machine, inProgram Files\Testing Files Generator, puts that folder on the machine'sPATHonce and the window in the Start menu, and asks for administrator rights to do it, in a prompt that names Testing Files Generator. Upgrading while atfgcommand runs does not stop the command. The new version is in place at once, and the old copy is removed at the next restart. Upgrading with the window open, Windows names the window and lets you close it before going on. Uninstalling removes the folder entry fromPATHand leaves any file in the folder that the installer did not put there. A release candidate gets no installer. - Eleven new PDF settings.
orientationlays pages upright, wide, or both in turn.page_size=mixedgoes through a4, letter, legal, a3 and a5, one a page.rotateasks the reader to turn every page by 90, 180 or 270 degrees while the page itself stays upright, so a reader that ignores the request shows something different from one that follows it.pdf_versionwrites 1.4 instead of 1.7 at the start of the file. And the document properties a reader shows:title,author,subject,keywords,creator,producer, and the datescreatedandmodified, written as2024-02-29or2024-02-29T13:45:00+02:00, ornoneto leave the date out. Text outside ASCII, such as Polish letters, reads back unchanged. The manifest says what each file carries. A file made without these settings is byte for byte the file this version made before. - The window groups the settings of a format when there are enough of
them to need it. PDF shows its document properties under a heading of
their own, and
tfg formats pdfprints the same heading in the same place.
- An empty text box in the window reads "not set" instead of "worked out from the size" when leaving it empty means going without - the password of an archive, the author of a PDF. What that means for the file is in the sentence beside the box.
-
The FAQ on the website said a deliberately broken file was not possible yet.
tfg damagehas been in the tool since 0.3.0, and the answer still called it a planned feature. It now says how to make one, in every language. -
The window no longer offers to write into a folder it cannot or should not write into. Started from Finder on macOS it offered
/tfg-out, which is read-only, so the first run ended in a refusal from the system. Started by a double click, or from a shortcut, in the folder the program lives in, it offered atfg-outfolder there - under Program Files that is refused, and in a package manager's folder the files can go with the next upgrade. Started in the root of a disk or in the program's own folder, however it was started, the window now offerstfg-outin your home folder. Started in any other folder, from a terminal for example, it still offerstfg-outin that folder, and the command line is unchanged. A window that remembered one of these folders from an earlier run offers the home folder too. -
A file put under a name while this tool is writing that name is no longer written over. Each generated file, the manifest, the instructions beside it and a recipe written by
tfg preset eject -oare written under a temporary name first, and until now the last step replaced whatever had appeared under the final name in the meantime - another program's file, or a person's. Now that step refuses a name somebody holds: the other file stays as it is, a generated file that could not take its name is reported as a failed file (exit code 8), and a manifest or recipe that could not is reported as a write failure (exit code 5). While a run goes, its manifest is reserved asmanifest.json.tfg-writingrather than as an emptymanifest.json, so a run that is killed leaves that name behind, and the next run into the directory says a run is going or was killed and names the file to remove, instead of calling an empty file the record of an earlier run. -
Ctrl+Enter, Ctrl+P and Escape act on the tab you are looking at. With About on show, Ctrl+Enter used to run Generate on the last work tab you had left, which you could not see, and Escape could stop a run there. On a tab with nothing to run the three keys now do nothing.
0.4.0 - 2026-09-25
-
tfg preset ejectwrites apurposeline for every target, so an ejected recipe and therecipe_hashof every run from a preset differ from 0.3.0. The recipe carries the purposes so that an ejected preset still produces exactly what the preset does. The bytes of every generated file are unchanged. A pipeline comparingrecipe_hashacross tool versions will see a new value once. -
A file name longer than 255 bytes is refused before anything is written, on every system. Linux stores at most 255 bytes in a name, while Windows and macOS count characters, so a name of 200 Chinese or Japanese characters used to be written on two systems and fail on the third with no reason given. The refusal says how long the name is and how long it may be. A letter outside ASCII takes two to four of those bytes.
-
The window's look, after a review of every screen. An open list and the explanation beside a field stand on a card with an edge and a shade instead of a flat grey block, and a list opened under its box shrinks to what the filter left. Every list shows its tick at the end of the row. Controls switched off for a run are never drawn brighter than at rest, and a ticked switch keeps its tick while it is off - it showed an empty square for the length of every run.
Donatecarries a red heart, and it andAdd a batchstand on the form's left edge. In a narrow window the run buttons move right instead of making the window wider, so it can be narrowed further. Two refusals in one row stand one under the other from the row's edge rather than as a staircase.Removeis written in red, the rails beside groups of settings are one grey,Choose...is as tall as the box beside it, and the About screen's sentence sits under its title like the other screens'. -
The list of formats is grouped by kind and can be filtered. The open list stands under headings - Archives, Documents, Pictures, Sound, Text and data - each saying how many formats are under it, and a box at its top narrows it to the formats whose identifier holds what is typed, or whose kind or full name has a word that starts with it (
gzfindstargz,pictevery picture,dataevery text and data format,excelfindsxlsx). Every format stands beside its full name,jxlbeside JPEG XL, so the open list is wider than theFormatbox, which keeps its width. The letters that matched are drawn in bold, in the identifier and in the name, the arrows step over the headings, and typing at the shutFormatmenu opens the list with those letters in the box, sojxltyped there ends onjxl. When nothing matches, the list saysNo format matches - clear the box to see all. The shut menu draws the kind of the format it holds, and archives are drawn as a folder rather than as three bars that looked like text. The command line lists the formats in one alphabetical order as before. -
The window lays its forms out in columns. A field now takes as many columns of the form as its value needs and no more, so
Format,Size,How many filesandDamagestand in one row instead of one under another, and a box for a number is no longer as wide as a box for a path. The window opens at the height of the form it shows, and an open list is as wide as the menu it drops from. -
Every section of a form folds away, and the settings of a format and of a damage stand in groups of their own. Each group is framed, with a line down its left edge in the colour of what it is about - blue for a format's settings, amber for a damage's, grey for notes to the manifest - so opening both no longer runs them into the fields above them and into each other. A refusal about a field inside a folded section opens that section.
-
A refusal about a field is written under its row, across the form. It used to wrap inside the width of the field, three lines deep for one sentence.
-
A size below what a format can make comes with a button that uses the smallest size that works. The refusal names that size to the byte, and the button puts it in the box. Nothing is filled in by itself.
-
When a run will not write because a file, a manifest or another run is already in the output directory, the refusal stands under
Output directorywith a button that opens it. It used to be one line at the foot of the window, scrolled, with no way to the directory but a file manager. -
A menu shows its arrow in the accent colour, so it can be told from a box to type in and from a button, which it matched to the pixel.
-
A box to tick carries its name beside it, and pressing the name ticks the box. The sentence about building on a preset says "with the box ticked" instead of "with the switch on".
-
A grey value in an empty box says it is a default, as in
default: 60, because a grey60read as a value somebody had typed. -
Smaller things:
PreviewandGeneratestand apart, and so doDuplicateandRemove.Donateis quieter, and onAboutit stands in the Support card instead of a bar of its own. The line at the foot counts formats past three instead of naming all twenty-six. What a preset typically finds and how to use the program are written in ordinary text instead of the smallest grey. The licence onAboutwraps to the window, and the code carried in the program is listed as a table. A menu holding the keyboard is marked with a ring instead of being filled blue. -
Generatemeans the same thing on every screen. TheSeveral batchesscreen used to open withBatch nameandSizeempty under a red star each, so the button that works straight away onSingle batchturned you down on the third tab. The first batch now arrives with the same two values the single batch screen has always had,filesand10mb, and the summary at the foot of the screen says what the run would come to instead of only where it would go. Nothing else on that screen is filled in: a count, a kind of case and an expected outcome left alone still reach the run unstated, which is what lets the manifest record that you did not state them. A batch you add yourself still arrives empty, because two batches under one name is refused. -
The first screen says what this tool gives you that a file generator does not. The sentence under
Single batchread "Files of one format and one size, as many as you need", which describes the mechanism. It now names the manifest and what it is for. -
The window is painted from one system rather than from colours chosen one at a time. Every surface and every piece of text now comes from a single ladder: one hue, an even step between one surface and the next, and one lightness shared by the accent and the three colours that carry meaning. What you see is a window that reads the same way it did, with the things that were nearly the same colour no longer nearly the same colour - the list that drops down from the format box stands clear of the form behind it, where it used to sit almost exactly as light as the box it came from. No generated file changes in any way. The old values met every contrast threshold and the new ones meet the same thresholds, checked the same way.
-
The window opens on a preset that was chosen rather than sorted. The Presets tab, and the "Build on a preset" section of the batches screen, used to start on whichever preset came first alphabetically - so what you saw when you opened the tab changed whenever a preset was added. A preset says now whether it is the one to start on, and
empty-and-minimalis it: pressing Generate without touching anything writes 32 667 B rather than the 73 MB the size-boundaries defaults come to, and its set means something without a number from you first. -
A recipe this program writes for you reads like one written by hand. Where the tool composes a recipe - the batch screen, and
tfg preset eject- a count and a size are now written as bare numbers (
size: 1024) rather than quoted (size: "1024"), and the entries oftargetsare indented under their key. Both are what every example in the documentation looks like, which matters because the header of an ejected recipe invites you to edit it: a target pasted in from the documentation used to land at a different indent and the file stopped parsing. A name made of digits stays text, so a file called007keeps its name. The files a recipe produces are unchanged to the byte. What does change is therecipe_hashrecorded in the manifest of a run started from the batch screen, because that hash is taken from the text of the recipe.
- a count and a size are now written as bare numbers (
-
The line before the second start says what was checked, not what was guessed. When the window's first attempt gives no window and the program starts again with the software renderer shipped beside it (Windows), the line on standard error used to state that the graphics driver offers no OpenGL 2.1. That is the usual cause and the one measured, but the only thing the program has checked at that point is that the first attempt did not succeed. The line now says that, names the usual cause as usual rather than as fact, and then says what is being done about it.
-
Two names are compared with a corrected Unicode normaliser. The library that decides whether two file names are one name spelled two ways,
golang.org/x/text, moves from 0.41.0 to 0.42.0, and that release fixes how a few rare sequences compose - a combining mark after a Hangul syllable, and a mark that an earlier letter should have kept apart. A recipe naming such a pair is now refused as a collision, where an older build wrote two files that macOS would have stored as one. No generated byte moves: the normaliser only compares names and never rewrites the one written to disk. The window'sgolang.org/x/image(0.46.0, no code change) andgolang.org/x/sys(0.48.0) move with it, and the third-party notices name the new numbers. -
The window says where things begin and end. Every field's name now stands above its box rather than beside it, in a lighter ink than the value under it, so a form reads as a column of named boxes. A section draws a line round its edge again. Preview, Choose, Duplicate and Add a batch stand on a surface of their own, brighter than a box to type in, so a button no longer looks like a field. A folded block of settings inside a section is titled at the rank of a subheading rather than a section, and the pointer lights only its words rather than the whole row. A field's explanation opens with an edge and a shadow, so it reads as something laid over the form rather than a patch of it.
-
Nine things the owner saw in the running window. A field's explanation opens on the same raised surface as an open list, so it no longer lies flat on the section it covers. A list of formats keeps its tick in front of the picture and the word, the shape it had before the tick moved to the end of every row. A list opens downward whenever a few rows fit under its box, shorter and scrolling, and turns upward only for a box standing just over the bar at the foot. A menu no longer opens marked as holding the keyboard when the window comes to the front, which is what made the first menu on the first screen blue and every other one grey - the mark is drawn for the keyboard alone, as it always was meant to be. A box for a name, a template, a file name, a password or a list of sizes is the width of two number boxes rather than of the whole row, and only a path still takes the row. The tick in a checked box fills its square. The first start is sized for the first screen rather than the tallest one - see the entry on the window's height further down. Preview, Choose, Duplicate and Add a batch have a raised face rather than an outline round nothing. And the busy face - the frozen form, Cancel, the bar - waits a moment before it appears, so a preview that is over in a blink no longer flashes it and takes it back, and the run buttons stand where they stood once the work is done.
-
The window draws its own buttons, switches and choosers. A button now has one filled face for the action that does the work and an outline for the ones beside it, lightens under the pointer, darkens when pressed, and shows a clear ring in the accent colour when the keyboard is on it - where the toolkit's own button blended that ring into the fill so it could not be seen on the filled one, and had no pressed look at all. Enter presses a button as well as the space bar. The three ways of stating a size are one segmented switch instead of three circles: one keyboard stop the arrow keys step through. A tick-box switch stands under its name in the column like every other field rather than carrying its own words, and a caption under a field now recedes to the same quiet grey as a hint rather than sitting a shade brighter.
-
The window lays every form out as a grid. A field is one row now: its name in a column of names, its box beside it, and every box on a screen starting on the same edge. Until now the name stood over the box, so each field cost two rows, and the small button that opens a field's explanation was the tallest thing on the name's line - the name of a box floated 13 px above the box's middle. Names are regular weight, the one bold thing on a screen is the title of a section, and the count of bytes a size comes to stands right after the size box instead of at the far end of the line.
Underneath it, every distance in the window is one of six steps and every number about the look lives in one file, so the gaps between things are the ones somebody chose. The settings a format declares stand one to a row - two of them no longer share a line - and the three ways of stating a size on the batch screen keep their switch above one box, in the column of controls.
-
The line under the buttons says what the run comes to, before anything is pressed. How many files, how many bytes, what kinds and where, on one line, worked out from the form as it is typed - with no disk read and no planning. A range says "between" its two ends until Preview draws the sizes, a container sized by its contents says so, and a form that cannot be added up yet names only the destination. After Preview the line is exact and the destination carries the room left on its disk. A change of the form puts the summary back over whatever a press said, where until now the line named the destination until the first press and never again.
The refusal about a run as a whole now scrolls inside the same room as the run's other messages, so the bar no longer grows when a run is refused, and choosing from a menu reaches the live check the way typing in a box does.
-
The tabs across the top stand on the same edge as the screen under them, and a screen has one name. The strip is drawn by the tool now rather than by the toolkit: its words start where the title and every field name start, the chosen one carries a 2 px mark in the accent colour, and a word lights up under the pointer. The title of a work screen is the word on its tab - Single batch, Presets, Several batches - and under it a quiet sentence says what the screen is for, where until now each screen had one name on the tab and another over the form.
-
An open list is as tall as half the window, not eight rows. The list a menu drops down stopped at eight rows in every window - 224 px at 800x600 and at 1100x1300 alike - so a third of the twenty-four formats showed however tall the window was. It now covers up to half the window's height, in whole rows: ten rows in a window 600 px tall, eighteen in one 1025 px tall, and the rest under a scroll. It still opens upward when there is more room above the box than below it, and is still cut to the room on whichever side it lands.
-
The window is set in Inter. The window was drawn in Noto Sans, the toolkit's own face. It is set in Inter 4.1 now, Regular and Bold, embedded in the window binary under the Open Font License - the licence notice and
tfg-gui's About screen name it beside the other bundled work. The window binary is about 800 kB larger for it. The command line binary carries no font and is unchanged. -
The window opens as tall as the screen it opens on needs, and no taller than a 1080p screen. A first start used to open at a height measured against the forms of an earlier version, and left a band of nothing under the form once the forms grew shorter. It now opens exactly tall enough for the first screen to show whole - today that is 851 px - and never taller than fits a 1080p screen with its taskbar. The taller screens scroll a little on arrival, which the batch screen does from the second batch on in any case. A window you have resized still comes back at the size you left it.
-
The files inside an archive are a table with one row of headings. The table on the batch screen named every column again in every row, so two files read as six names. The names stand once over the first row now, and the Remove button is centred on the row it removes.
-
Every menu is one width. The format menu was 152 px on two screens and 140 px on the third, and in a table with two rows the first row's menu was wider than the second's - a menu measured after it had been drawn once came out wider than the same menu measured before. Every menu is now as wide as its values need and never narrower than the boxes beside it, the same on every screen.
-
A title too long for its room ends in an ellipsis instead of running past the panel. No title in the window is that long today, and a check keeps it so under every format, so the ellipsis only ever appears in a window made narrower than a title needs.
-
The switch between the three ways of stating a size is drawn as one shape. The chosen way was a sharp rectangle under a rounded border, with a hairline against its edge and the keyboard ring outside - three geometries at once. The fill is rounded and sits inside the border now, and a line stands only between two ways neither of which is chosen.
-
The words in an open list start where the word in the box does. Every row kept a column for the tick in front of its words, whether or not anything in the list was ticked, so the words of a list with nothing chosen floated a column to the right of the box. The tick stands at the end of the row now, and the picture of a file kind stays in front where it was.
-
Every preset now says what each of its files is for. A run from a preset writes
manifest.instructions.mdbesidemanifest.json: the question the preset answers, how to read accept, reject, sanitize and unspecified, and then every file - its name, format and size, what your system is expected to do with it and one or two sentences on why it is in the set. Many files of one kind, such as the fifty of a mass upload, are one entry. The file is named after the manifest, sorun2.jsongetsrun2.instructions.md, and it is written only when some file has a purpose - a plaintfg generate --format pngwrites none.generateprintsinstructions:undermanifest:,verifydoes not count the file as extra, andcleanup --with-manifestremoves it with the manifest - only when it is named after that manifest, so an edited or renamed manifest never takes instructions that may belong to another run. A run into a directory that already holds instructions of that name is refused before anything is written. If the file cannot be written, the run says so and does not fail over it, because the manifest was saved and holds the same facts. -
A
purposefor every target of a recipe. One or two sentences on what the files are and why they are in the set. They reachfiles[].purposein the manifest and the instructions beside it, and change no byte of any file. The batch screen has aPurposebox among the manifest notes of each batch, and the window offersOpen instructionsbesideOpen manifestafter a run that wrote them. -
tfg preset eject <id> -o my.yamlwrites the recipe to a file itself. The help used to say> my.yaml, and Windows PowerShell 5.1 saves that as UTF-16, whichtfgthen refused to read. Piping throughOut-File -Encoding utf8was no way round it: PowerShell 5.1 first reads the output in the console's code page, which changes letters outside ASCII, so a name in Polish or Korean arrived different or not at all.-owrites byte for byte what would have been printed, in every shell. A file already at that name is refused and left as it is, because it may be a recipe you edited.> my.yamlstill works in cmd, bash and PowerShell 7. -
A preset for unusual file names:
filename-handling. It answers "will my system store, show and give back a file name it did not expect?" with fifty names in seven groups: scripts from Polish to Korean, names that look like other names, leading spaces and dots, shell and SQL metacharacters, names that mean something to a web server or a desktop, names read as values, and names at the length limits. Every one is written byte for byte on Windows, Linux and macOS - measured on NTFS, ext4 and APFS. On another file system a name may be refused, and the run then ends with code 8 and names it. The files aretxtunless--formatsays otherwise, and the names about length count the format's extension in. Four names are expected to be accepted, the rest are left to your system's policy with a reason. -
Every format has its full name.
tfg formatshas aNAMEcolumn (jxlis JPEG XL,pngPortable Network Graphics),tfg formats jxlgives it on anameline, andtfg formats --jsoncarries it under the new keyname. No key already there changes, and no generated file changes. The formats table on the website has the same column. Names are proper names and stay in English on every page. -
The window says where the manifest went, and opens it. A finished run used to say
3 files written.and nothing else, while the same run from the command line printed the path of the manifest beside the count. The line now names the file -3 files written. Manifest: manifest.json- and a second button,Open manifest, stands besideOpen folderfor as long as there is a record to open. The manifest is what carries the expected outcome of every file, its hash and the seed it was made from, so it is the part of a run a test suite reads. Neither button appears after a preview, because a preview writes nothing. -
The About screen says what to do with the program, in three steps. It opened with one sentence about what this tool is and ran straight into the licence notice. A short section above the licence now says how to get from an empty window to files and a manifest a test can read.
-
The switch that chooses how a batch states its size has a name. On
Several batchesthe row ofOne size | A range | Around a limitstood between two fields with nothing over it, so what it was about had to be guessed from its neighbours. It readsHow the size is givennow, with the same explanation button every other setting has - and that explanation says what each of the three does, includingAround a limit, which makes three files: one byte under the limit, one on it, one over. -
The palette is something you can open.
tfg --catalogue, the hidden screen that shows every control the window is built from, now ends with the palette itself: every colour, what it is for, the value it holds and the measurement that decides whether it is doing its job - the contrast ratio for anything read, the distance in lightness for a surface. The light palette is shown beside it and labelled: it is worked out and held to the same thresholds, and the window does not use it, because this program has one look. -
Two more formats, both for configuration:
yamlandtoml. A config file at an exact size is what a size-limited upload is for a picture, and neither format had a way to ask for one. Runtfg formatsfor all twenty six, ortfg formats yamlfor what one takes.Both write records: a
recordsblock of entries carrying an id, a name, an email, an amount, a flag, a list of tags, a nested address and a note. YAML comes out in block style rather than flow style, so it reads like a YAML file rather than like JSON with a different extension. TOML comes out as an array of[[records]]tables.The smallest yaml is 241 B and the smallest toml is 212 B, each one whole record. An empty file is legal in both - and it stays something you ask for by shape rather than by byte count, the same answer
jsongives about an empty array.The label rides in a comment, which is new: these are the first record formats whose label sits inside the file without touching the data being tested.
csvandjsonlabel from the outside because an extra field changes the very structure under test, and a comment is not data. Ask for a file at exactly the minimum and there is no room for it beside a whole record, so it is left out and the run says so.TOML takes no
encodingorbomsetting and says why rather than calling it an unknown option. TOML is UTF-8 by its own specification, and both readers tested refuse a TOML file that opens with a byte order mark. -
Three more presets:
upload-validation,text-encodingandtabular-import. Runtfg preset listfor all five, ortfg preset show <id>for what one takes and what it would produce before it writes anything.upload-validationanswers "does my upload form take what it should and turn the rest away?" - 71 files in eight groups at its defaults, 115 MB. A file a byte under your limit, one at it, one a byte over and one twice it. A real file of every type you allow, which is the positive control. A file for every extension you deny, an SVG and an HTML among them, because both are routinely taken for a picture and for plain text. A PDF named.jpg. A file with no extension, one namedPHOTO.JPGand one namedinvoice.jpg.exe. A name 204 characters long, a name outside ASCII, and a name with spaces and brackets that is perfectly legal. And fifty files at once.--limitis the size your form declares, and the run says out loud when you did not give one, because a set built around our placeholder says nothing about your form.--allowand--denyare lists with commas. An extension in--denythat this build has no format for -exe,sh- still gets a file under that name, holding plain text, and the run says so: it tests a form reading the end of a name, not one reading what is inside.--far-over 10xasks for a file ten times the limit rather than twice it,--far-over offleaves it out, and--bulk 0leaves the mass upload out. Anything a setting empties is named in the output rather than quietly missing. What this set does not do is put a path in a file name:../../etc/passwdis not a name this tool will write, deliberately.text-encodinganswers "does my reader know which encoding a file is in, or is it guessing?" - 20 files, 80 kB. TXT, MD and XML in UTF-8, UTF-16LE and UTF-16BE, each with and without a byte order mark, plus CSV and LOG with LF and with CRLF endings. UTF-8 expectsacceptwith or without a mark. UTF-16 expectsunspecified: whether your system handles it at all is your policy, and the manifest does not invent it. Two combinations are left out and said out loud, because XML in UTF-16 has to open with a mark. No format in this build carries an encoding and a line ending at once, so the two halves are separate files rather than one grid - the run says that too.--samplesets how big each file is, and refuses an odd number, because a file in UTF-16 always has an even number of bytes.tabular-importanswers "does my table import survive what real tools export?" - 13 files, 2.9 MB. One CSV per dialect: comma, semicolon, pipe and tab, LF and CRLF, with and without a header row, and three quoting styles, one setting at a time against a base so a failure names its cause. A CSV with more columns than a spreadsheet will show, which expectsunspecifiedwithcount_limit. A spreadsheet of--rowsby--columns, written at exactly the size that many cells package to. And the same JSON records written indented, minified and one to a line. -
A second preset:
empty-and-minimal. It answers "does a file that is valid and as small as the format allows get through?" and builds the smallest legal file of every format this build has, plus a file of nought bytes for every format that has a legal empty form. The whole set is 28 files and 32 667 B, so it checks twenty-six paths through your reader for the price of thirty-two kilobytes. Run it withtfg generate --preset empty-and-minimal, or pick it on the Presets screen.The set comes in two groups, because two different answers are honest. Every file in
minimalis valid, so it expectsaccept- those are the positive control, and if they are turned away the refusals in any other set mean nothing. Every file inemptyis legal and nought bytes long, so it expectsunspecifiedwith the reasonsize_zero: whether an empty file should be kept or turned away is your policy, and the manifest does not invent it.--formatsnarrows the set, written as a list with commas ---formats png,jpg,giffor an image pipeline. It takesallon its own for every format. Two things to expect from a run: several lines about files too small to carry the label this tool writes into them, which is the tool saying so rather than going quiet, and, for a set built only from formats that cannot be empty, a line saying it has no empty files and naming the formats that can. -
A recipe can build on a preset. Two keys the recipe reader used to refuse as not built yet now work:
extends: preset:<id>names the preset andwith:fills its parameters, written the way the flags take them (limit: 5mb,spread: 1B,1kb,1mb,format: png). The preset's files come first and the recipe's owntargetsare added after them, so the file is the same run astfg preset ejectwith the extra targets typed under it, byte for byte, and shorter. A target whoseidthe preset already uses is refused rather than replaced,withwithoutextendsis refused, andextendsnames a preset and nothing else yet - not another file. A recipe withextendsand notargetsof its own is legal, which is how a preset run is committed to a repository. The manifest records the preset underrun.presetwith the parameters left out listed asdefaulted, as a--presetrun does, andrun.recipe_hashis the hash of the file as written.tfg validate --jsoncarries the samepresetblock. On the desktop window, the batch screen has a section "Build on a preset": a switch, the preset, and its parameters drawn under it. A preset flag beside a recipe file (tfg generate r.yaml --limit 5mb) is refused with a sentence saying the value goes underwith. -
The batch screen's "Label in each file" switch starts on. It started off, while the single batch screen and a recipe file with no
defaultssection both have the label on - so the same recipe run from that screen gave different bytes. All three now agree. -
Every control shows where the keyboard is and answers the pointer. Every place the keyboard can land - a box, a menu, a switch, a button, the segmented switch, a word on the tab strip - draws the same 2 px ring when a key put it there and not when a press did, and every control you can click lights up under the pointer. Buttons gain a pressed look. This closes the gap where some stops drew a focus mark too faint to see and one drew none at all.
-
The tabs can be worked from the keyboard. Tab reaches each word on the strip, Enter or Space opens its screen and puts the keyboard on the first field with its mark showing, and the arrow keys move along the strip without opening anything. The toolkit's tabs answered the mouse only.
-
tfg-gui --catalogueopens a catalogue of every part of the window in every state it has. A hidden screen for anybody changing the window's look: each button, box, menu, switch, list, tab and rank of text, at rest, under the pointer, holding the keyboard, refused, disabled and given a sentence too long for it - sixteen entries and ninety-three states, drawn at the widths a form gives them.--catalogis accepted too. Any other argument is ignored, as every argument was until now. -
On Windows the window opens without a graphics driver. The Windows archive of the window carries a software OpenGL renderer, Mesa llvmpipe 26.2.0, in an
openglfolder next to the program. When the first attempt at a window fails - typically a graphics driver with no OpenGL 2.1: a virtual machine without 3D acceleration, a remote desktop, a server - the window starts itself again with the renderer and opens, drawn in software: slower, and it says so on its About screen and on standard error. On a machine with a driver the renderer is not touched unless asked for:tfg-gui --software-glloads it on any Windows machine, which is the way to see the window as such a machine sees it. If the folder is missing, the refusal says which file it looked for, and if a file is there but cannot be read, it says that instead. Measured on Windows Server 2025 under VirtualBox without 3D acceleration, where the window refused until now. Both files are signed like the program, named inTHIRD-PARTY-NOTICES.mdwith the sums they were reviewed at, and listed in the bill of materials. Nothing changes for Linux or macOS, where nothing of the kind is shipped - there the flag says so and changes nothing.
-
tfg cleanup --with-manifestsays before it acts that the manifest goes too. The list printed without--yesnamed only the files the manifest lists, and--yesthen removed the manifest as well. It now ends with the manifest and the instructions beside it, or says why they would stay, and the run with--yesnames them once they are gone. With--jsonboth reports carry them in a newrecordlist.files,removed,keptandwould_removestill count only what the manifest lists. -
A recipe saved as UTF-16 is refused with the reason and the way round it. The refusal told you to save the file as UTF-8, when the usual way to get UTF-16 is not saving at all but
>in Windows PowerShell 5.1. It now says the file is UTF-16, where that comes from, and to usetfg preset eject <id> -o my.yamlor>in PowerShell 7, cmd or bash. The exit code is still3, and such a file is still not read: by then PowerShell may already have changed its letters outside ASCII. -
A report shows a character nobody can see in a file name as an escape.
verify,cleanup, the notes of a run, every error message and the refusals in the window printed such a character as it was, in a file name and in the name of a folder. A right to left override then made the terminal draw another name than the one on the disk, and a zero width space made two names look the same. Such a character is printed as an escape now, such as\u202efor a right to left override. A name without one is printed as before. The manifest and every--jsonreport still carry the exact name. -
A recipe the tool writes shows a character nobody can see as an escape.
tfg preset ejectwrote a right to left override, a zero width space or a line separator into the recipe as it was, so the file read as something other than what it held, and a YAML 1.1 reader such as PyYAML refused it at the line separator. Such a value is written in double quotes now, with the character as an escape that every YAML reader turns back into the same character. Every other value is written as before. A run started in the window records a different recipe hash only when a value holds such a character. -
A space from outside ASCII at the start or the end of a recipe value is kept. A file name beginning with an ideographic space or a no break space lost it, and the file was written under a different name than the recipe asked for, with nothing said. A plain space or a tab at the ends of an unquoted value is still not part of it, as in any YAML file.
-
A file name from 238 to 255 bytes long is written. Every system stores such a name, and none of them got one: each file is written under a longer temporary name first, and that one was over the limit. The same held for a manifest named that long and for
tfg recipe fmt -won a recipe file named that long. -
The window uses far less memory, and rebuilding a screen or opening a list no longer adds to it. Every quiet line on a screen - a subtitle, a caption, the count of bytes beside a size, the line a folded section keeps, the message under a field - parsed its own copy of the fonts, and parsed another each time the screen was rebuilt and the line said something new. The open list of formats did the same every time it opened, and more with every letter typed into its filter - ten openings kept about 160 MB. After visiting the four tabs the window held about 290 MB and it now holds about 120 MB. Nothing on the screen looks different.
-
The
Several batchesscreen no longer slows down the longer it is used. Every batch added, removed or copied, every format chosen and every press ofStart from a presetmade each box on the screen report a change one more time, so the switch took 0.7 seconds at its first press and 6 seconds at its twentieth. With a preset switched on, every key typed took about 0.4 seconds, because the smallest file of every format was worked out again for each one. Both are now done once, and the window uses less memory while you type. -
The window gives memory back once it has been left alone. After a spell of work it used to keep about 200 MB for as long as it stood idle. About a minute and a half after the last setting changed or the last press of Preview or Generate, the window now gives back what it no longer uses - 215 MB down to 129 MB in one measurement. Scrolling does not count, and while Preview or Generate is running it waits. Nothing on the screen moves when it does. A minimised window gives back less, because the toolkit only lets go while it draws.
-
Changing a setting of a preset is about four times faster. With
upload-validation, changing one of its settings held the window for about 0.15 seconds, and so did every key typed onSeveral batchesbuilt on it. Both now take about 0.03 seconds. Switching the base preset onSeveral batchestoupload-validationwent from about 0.3 to 0.07 seconds. The sets the presets build are byte for byte the same as before. -
Typing on the
Presetsscreen no longer lags. Withupload-validationchosen, every key typed into a box held the window for about 0.3 seconds, and about 0.07 seconds withtabular-import, because the preset was worked out twice for each key. Typing the seed or the output folder now takes no noticeable time, and changing a setting of the preset itself takes half as long as before. On theSeveral batchesscreen with a preset switched on, a key typed into a box now works the preset out once rather than twice. -
Choosing a format, switching the base or choosing a base preset on
Several batchesworks the form out once instead of twice. Switching the base toupload-validationtook half as long as before. -
A preview or a run refused while it was being planned no longer leaves "Working out what this would cost..." standing over the refusal.
-
A refusal about two settings that bound each other now says how far over you are. Asking for a picture of 20000 by 2001 pixels was turned down with "together they come to 40 megapixels and the limit is 40" - the same number twice, because the counts were rounded to whole megapixels and the request is 40.02 of them. The limit had no unit after it either. Both counts are written out exactly when rounding would put them on one number ("together they come to 40 020 000 pixels and the limit is 40 000 000 pixels"), and the readable form is kept where it still tells you something ("400 megapixels and the limit is 40 megapixels"). The same sentence is used by every format with a rule of this kind:
avif,gif,jpg,jxl,pngandxlsx. -
The whole head row of a section opens and closes it. Until now only the small arrow after a section's title was the target, so a click on "Notes for the manifest" or on "Settings for png" did nothing and the section stayed shut. The row is one control now: the title, the arrow, the line a closed section shows about its contents, and the room to the right of them all open and close it, the row lights under the pointer, and it is one stop for the keyboard, where Space and Enter open and close it. The Duplicate and Remove buttons in a batch's head keep doing their own job. Section titles stay exactly where they were.
-
A window the graphics toolkit could not create is a refusal that says why, not a process with no window in it. On a Windows machine whose graphics driver offers no OpenGL 2.1 - a virtual machine without 3D acceleration, measured on Windows Server 2025 under VirtualBox - the window binary opened nothing, said nothing, and stayed in the task list until the session ended. It now says what did not happen, quotes the driver's reason as the toolkit reported it, points at the command line (which needs no graphics driver), and exits 1 - on standard error and in a system dialog, because the window that would have carried the message is what failed.
-
The switch between the three ways of stating a size freezes with the rest of the form while a run is going. It stayed live: during a run, choosing another way rebuilt the size boxes under a form drawn as frozen. A frozen switch also kept no sign of which way was chosen. Both are fixed, and the switch thaws with the form when the run ends.
-
The explanation beside a field opens on the first hover, and opens and closes from the keyboard. Pointing at the information mark in a fresh window showed nothing until something else on the screen happened to repaint, and Space on the mark with the keyboard drew nothing at all. The window's controls now tell the canvas about each piece they change, and the sheet the explanation is drawn on says so when a box is put on it or taken off.
-
A refused preset no longer carries the note of the preset before it. A limit of 512 B was refused with "no limit was given" under it - a note left over from the last expansion that worked.
-
A preview asks the disk how much room it has from the worker, not from the interface thread. The rest of a preview moved off that thread on 2026-08-26 and this one read stayed behind, so a directory on a slow share could still stop the window from drawing for as long as the share took.
-
Asking for damaged files and declaring they will be accepted is now refused on the command line too. A damaged file is one a reader was measured to refuse, so
--expected acceptbeside--damageasks for something nothing can deliver.A recipe saying the same thing has always been refused. The command line was not: it wrote the files and recorded in the manifest that a deliberately broken file should be accepted, which is the one place this tool must not say something untrue.
tfg generate --damage zero-head --expected acceptnow ends with exit code2and writes nothing, and a recipe still ends with3and names the target the problem is in.Only
acceptis refused.rejectis what damage already means, andsanitizeandunspecifiedare both real questions to ask about a broken file - a system under test may be meant to repair it, or that may be the point of the test - so all three still work, as does--expected accepton files that are not damaged. -
The documentation website lists every command the tool has.
tfg damagearrived in 0.3.0 and the page describing the commands still showed the other nine, because that list was written out by hand. The page now takes the list from the program itself, so a command added later cannot go missing from it, and the site has a section explaining how to produce a file that is broken on purpose. -
The window spells a byte count the way the command line does. The count under a size box said
10485760 Bwhiletfgsaid10 485 760 Babout the same number - the window had a spelling of its own that the grouping of digits in 0.3.0 never reached. There is one spelling now.
0.3.0 - 2026-09-09
-
WAV files asking for 24 or 32 bit audio have slightly different bytes. A steady tone repeats, so it is now worked out once for a single cycle and read back for the rest of the file instead of being calculated again for every sample.
Reading it back is not quite identical to calculating it - the two answers differ far below the step between one sample value and the next. At the default 16 bit depth that difference never reaches the file, and 16 bit WAVs are byte for byte what they were. At 24 and 32 bits it does reach the file, in roughly one sample in twenty thousand.
Nothing audible changes, and neither the size nor the structure of the file changes. If you record hashes of 24 or 32 bit WAVs, they will not match.
This is what makes producing a WAV cheaper. Measured on a 256 MB file, runs interleaved: 2.0 times less processor time and 1.7 times less wall clock.
silence,noiseandsweepare untouched - a sweep never repeats, so there is nothing to read back. -
ZIP, TAR.GZ and WAV files have different bytes. The padding these three formats write is now drawn from the random generator eight bytes at a time instead of one byte at a time. For a ZIP or a TAR.GZ that padding is almost the whole file, so almost every byte changes.
Nothing a reader can see is different. The size is the same to the byte, the structure is the same, the same tools open the same files. What changes is the content of the padding, so a hash you recorded from an earlier version will not match one you generate now.
This is what makes large archives faster to produce. Measured on 64 MB files, runs interleaved: ZIP 2.8 times faster, TAR.GZ 2.7 to 3.3 times faster.
WAV is on this list for consistency rather than for speed. Its padding is at most a few bytes per file, so only those bytes differ and the time to produce one is unchanged.
Every other format keeps exactly the bytes it had. Twelve of them share the same new code and were checked against their recorded hashes and across every format at five sizes and two seeds.
-
A generated
.csvquotes only the fields that need it, so its bytes are different. Sizes are unchanged. Every size that worked before still works, every reader that took these files still takes them, and the file is still RFC 4180.The description column used to be quoted on every row. It is quoted now only when it carries the separator, which is the one thing that makes a quote necessary. The description is three to seven words and drops a separator every third one, so a short one carries none - measured on a 4 kB table, 9 of its 44 rows lost their quotes.
This arrives as a new setting,
quote_style, which takesminimal,allornone:minimalis the new default and is what a spreadsheet writes.allwraps every field on every row, the header included.nonewraps nothing. It also stops the description carrying the separator, because an unquoted field cannot hold one without ending early - so this value changes what the file says and not only how it is punctuated.
The smallest
.csvis 115 B rather than 117, because the shortest row has an empty description and an empty field needs no quotes. Withquote_style=allthe smallest is 139 B.tfg formatsprints the current numbers.A suite pinning
.csvhashes will go red once and then stay green. There is no switch back to the old bytes: they were not any of the three styles RFC 4180 describes, and carrying a fourth name for them forever costs more than the one red run. -
Six formats have different bytes, because the tool is built with Go 1.27 now. Sizes are unchanged. Every size that worked before still works, every reader that took these files still takes them, and the same sizes are reachable. What changed is the compressed data inside them.
Affected:
targz,png,docx,xlsx,pptx,icowhen it holds a png, andzipwhen you ask for compression.zipleft alone is untouched, because its default stores rather than compresses. The other seventeen formats are byte for byte what they were.Go 1.27 changed
compress/flate, which is what all of those run through. Below the default compression level the change is only how a stream is closed, and above it the compressor itself behaves differently.Two minimums moved with it. The smallest
pngis 74 B rather than 73, and sizes 75 to 82 and 85 are the ones it cannot produce. The smallesttargzis 1049 B rather than 1052, the next size up is 1051, and 1050 is the one it cannot produce.tfg formatsprints the current numbers.A suite pinning hashes for those formats will go red once and then stay green. There is no switch back: staying on the old compiler was not a choice this tool can offer, since the compiler comes from whoever builds it.
-
.tar.gzcould not be produced at all under Go 1.27 until this release. Every size was refused with an error saying the generator produced three bytes fewer than planned. The size of a.tar.gzis worked out rather than measured - compressing twice to learn a length would make a preview cost what the run costs - and that arithmetic carried a number that turned out to describe one release of Go.It measures that number now, at first use, and checks its own answer before trusting it. A later Go release can move these bytes again, but it can no longer stop the format from being written.
-
A generated
.tar.gzhas different bytes, because a lot of them could not be opened by a Go program. Sizes are unchanged, every size that worked before still works, and every reader that took these files still takes them. What moved is where the padding sits inside the header.The padding used to ride in the gzip header comment. Go's own
compress/gzipreads that field into a fixed buffer and refuses a comment of 512 bytes or more, so 4134 of the 11 260 reachable sizes produced an archive no Go program could open - and the message it gives,gzip: invalid header, reads like a corrupt file rather than like a field the reader will not take. 7-Zip, GNU tar, bsdtar, Python and node all took those files without a word, which is why it was not noticed sooner.The padding now rides in the gzip extra field, which Go reads to the end of. After the change: 11 260 sizes reachable, none unreachable, none unreadable.
There is no way back to the old bytes, and that is the difference between this and the other two entries here. The old bytes are the ones a Go program cannot read, so keeping a switch for them would be keeping a switch for the fault. A suite pinning
.tar.gzhashes will go red once and then stay green. -
A generated log now advances through time, so its bytes are different. Every entry used to carry the same instant. Ten thousand requests all landing at one moment is not a log anybody can test a time window, a rate alert or a rotation against, and it was obvious the moment you looked at a file.
Entries are now one second apart by default, and
ratesets how many arrive a second. The bytes of every log change, so a suite pinning their hashes will go red.The way back is
--set timestamps=fixed, ortimestamps: fixedon a target in a recipe. That holds the clock still and writes the same bytes this tool wrote before, to the byte - there is a pinned hash proving it. -
A generated GIF now moves, so its bytes are different. A GIF is the one picture format here that can hold more than one frame, and a still one told you nothing about how the system under test treats an animation - whether it keeps it, flattens it to the first frame, or re-encodes it. Every GIF now carries a marker that travels across the picture in three frames, and the manifest says
animatedandframe_countfor each file.Two things change with it. The smallest GIF this tool will write goes from 41 B to 114 B, because the number a format announces as its minimum has to be a number a plain run accepts, and a plain run animates. And the bytes of every GIF change, so a suite pinning their hashes will go red.
The way back is
--set frames=1, orframes: 1on a target in a recipe. That takes the plain encoder and writes the same bytes this tool wrote before, to the byte - there is a pinned hash proving it. -
A setting value is spelled the way the format declares it.
--set page_size=A4and--set directory_entries=TRUEused to be accepted and now refuse with exit 4, naming the setting and the value. Writea4andtrue.Nothing else changes: the bytes of every file are what they were, and a recipe writing
header: trueis unaffected, because a YAML boolean arrives astrueeither way. Only a value quoted into another case is refused.It is a breaking change for a fourth of a reason and a fix for the rest. A value the declaration did not contain used to pass the check and land on the format, which then did one of four things with it: refuse in its own words, understand it anyway, quietly ignore it and produce the default file, or read it as something else entirely.
--set entry_owner=USERon atargzwrote an archive owned by nobody and reported success. Now nothing gets that far.
-
A new command says what this build can break:
tfg damage.tfg damage every damage, and what each one does to the bytes tfg damage zero-head what one of them takes tfg damage --json the same, for a scriptThe window already drew the settings of a chosen damage. The command line named them and stopped there, so the only way to learn that
zero-headtakesbytes, and thatbytesruns from 4 to 4096, was to type a wrong value and read the refusal.Each one is listed with the smallest file it can be given. That number follows the settings - for
zero-headit is the number of bytes you asked it to zero - so the column is measured with the defaults and says so.Asking about a damage that does not exist ends with 2, the code that means the invocation was mistyped, and it is the same code
--damagealready gave for the same mistake. Asking about two at once is refused rather than answered about the first. -
Files can be broken on purpose. A target takes
damage, and the files it produces are ones a reader refuses: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=16Until 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 formatsis 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 takesdocumentorfragment. It defaults to the whole page these files have always been, so a recipe that says nothing gets the same bytes it got before.A fragment is the same blocks without the skeleton - no doctype, no
htmlelement, no head and no body. It is what a content field, an email body or a partial render really holds, and it is what a system under test is handed when something else owns the page around it.Two things follow from carrying less. The smallest fragment is 8 B rather than 118 B, because there is no skeleton to pay for. And the label, which a page carries twice - once in the title for the tab and once in a heading for the reader - rides in the heading alone, since a fragment has no head to put a title in. It is still visible.
-
XML documents can be written in UTF-16. Two new settings on
xml:encoding, which takesutf-8,utf-16leorutf-16be, andbom, which says whether the file opens with a byte order mark. Both default to what these documents have always been, so a recipe that says nothing gets the same bytes it got before.The declaration at the top of the file names the encoding the bytes are really in, so a reader is never told one thing and handed another.
Two things are worth knowing before you use it. A UTF-16 file stores two bytes for every character, so it always has an even number of them and an odd size is refused - the refusal names the nearest size above and below that it can write. And the smallest XML file grows from 264 B to 532 B, because the declaration, the root element and one whole record all cost twice as much.
encoding=utf-16leandencoding=utf-16beneedbom=true. The XML specification requires a mark on a UTF-16 document, and asking for one without it is refused rather than written. -
SVG drawings can be any size you ask for. Two new settings on
svg:widthandheight, both a whole number of pixels from 1 to 20000. They default to the 800 by 600 these drawings have always been, so a recipe that says nothing gets the same bytes it got before.tfg generate --format svg --size 20kb --set width=1920 --set height=1080There is no joint limit on the two, unlike the picture formats, because nothing is drawn into pixels here - the file only says how big it is. That makes a small file that claims to be enormous, which is the point: a 3 kB drawing declaring 20000 by 20000 asks whether whatever opens it has a limit on picture size and not only on file size. Pillow, for one, refuses to open the result.
A drawing shorter than 57 pixels has no room for the label along its bottom edge. It is still produced and still named, and the run says which files those were.
-
Text and Markdown files can be written in UTF-16, with or without a byte order mark. Two new settings on
txtandmd:encoding, which takesutf-8,utf-16leorutf-16be, andbom, which istrueorfalse. Both default to what these formats have always produced, so a recipe that says nothing gets the same bytes it got before.tfg generate --format txt --size 4kb --set encoding=utf-16le --set bom=trueIn UTF-16 an odd number of bytes is refused. Every character takes two bytes, so only an even size can be a whole file, and asking for 4001 B gets an error naming 4000 B and 4002 B rather than a file that is one byte out. Three readers were measured on a UTF-16 file cut to an odd length: Python, Node and .NET all reject it when asked strictly, and all three repair it in silence otherwise, which is why this is an error and not a rounded size.
The self describing label costs twice as much in UTF-16, so it needs a file of at least 66 B rather than 33 B to fit. Below that the file is still produced and the manifest says the label was left out.
Single byte encodings such as Windows-1252 are deliberately not offered. The generated text is English, so a file written in one would be byte for byte the same file as UTF-8 - a setting that changes nothing.
-
JSON documents can be minified or indented. A
formattingsetting onjsontakingrecord-per-line,minifiedorindented. It defaults to the one record per line this format has always written, so a recipe that says nothing gets the same bytes.tfg generate --format json --size 1mb --set formatting=minifiedEvery reader accepts all three, which is the point: what changes is everything around the parser. A minified document of any size is one single line and ends without a newline, and an indented one holds roughly a third fewer records in the same number of bytes. The smallest document each layout can produce differs too, and asking for less names the layout it is about.
-
csvtakescolumns, so a table can be as narrow or as wide as the thing you are testing. Two to 32768, and six by default - which is the six columns this tool has always written, so a.csvyou already generate does not change by a byte.Fewer than six drops them from the middle:
idstays first anddescriptionstays last, because that is the field stretched to reach the exact size you asked for. More than six addsfield_7,field_8and so on in front of the description.Above 16384 columns a spreadsheet quietly keeps the first 16384 and drops the rest. Measured with LibreOffice Calc: 16384 comes back whole, 16385 comes back with one column missing and no warning anywhere. The ceiling here is deliberately past that, so you can build the set either side of the line rather than only the last file that survives it.
The smallest file moves with the setting, the way it already does for row endings and quoting - 36 B at two columns, 115 B at six, 5017 B at 256.
tfg formats csvprints what a given table needs. -
A
targzmanifest says what its entries claim about themselves. Two new keys on the file entry,entry_modeandentry_owner, written every run rather than only when you ask for them, so a harness never has to read a missing key as "nobody owns this". The manifest schema version is unchanged. -
A CSV can be written in the dialect you were handed.
--set delimiter=semicolon,--set line_ending=crlfand--set header=falseon acsv, separately or together. Separators are named rather than typed, sotabandpipeneed no escaping: the four arecomma,semicolon,tabandpipe.These are the three ways a real CSV differs before its contents do. A European spreadsheet exports with semicolons, anything written on Windows ends its rows with CRLF, and a table dumped straight out of a database has no header. All three are CSV and all three break a reader that assumed the other thing.
The description column keeps carrying the separator, so a semicolon file still exercises quoted fields rather than quietly testing less than a comma one does.
Two things worth knowing. The smallest file changes with the dialect, because a CRLF row is a byte longer and a header is a whole line - the tool tells you the floor for the settings you gave it. And the manifest records the separator as the character that is in the file, where the recipe names it as a word.
The defaults are
comma,lfand a header, which is what this tool has always written, so no existing file changes by a byte. -
A log can be made quiet, or full of errors.
--set level_mix=errorson alog, withrealistic,quiet,errorsanddebugto choose from. It decides which severities appear, the waystatus_mixalready decides which response codes do.Only the
plainandjson-linesentry formats carry a severity at all. Ask for a mix beside one of the other four and the tool says so and stops, naming both settings, rather than accepting a setting that would do nothing.One thing worth knowing before you pick
quiet: it draws onlyINFO, which is a shorter word thanERROR, so the smallest log it can write is one byte smaller than the other mixes. The tool tells you the floor for the settings you gave it.The default is
realistic, the mix these logs have always had, so no existing file changes by a byte. -
An archive can compress what it holds.
--set compression=beston azipor atargz, withnone,fast,defaultandbestto choose from.The archive still comes out exactly the size you asked for. What changes is how much of it is your files and how much is padding: at
besta megabyte archive holding four 32 KB text files carries the same four files deflated, and the padding entry grows to make up the difference. A reader sees real deflated entries, which is what a tool under test has to cope with.The default is
none, which is what archives from this tool have always been, so no existing file changes by a byte.Two combinations are refused rather than half-supported, and the message says which two settings to choose between. Compression with a size taken from the contents: the archive's length would then be whatever the contents compress to, which is only knowable by compressing them, and that would make a preview cost as much as the run. Compression with a password: a locked entry has to state its length before its data is written, so a compressed one would have to be held in memory whole.
Compressing costs time at write, not at preview. A 10 MB archive takes about 25 ms at
fastand 140 ms atdefault, against 8 ms stored, and a.tar.gzpays that twice because gzip compresses the whole stream and the size has to be measured before it can be hit. -
An archive can hold its files in directories.
--set depth=3puts every file three levels down, and--set directory_entries=truealso makes the archive list the directories themselves. Both work onzipand ontargz.Two settings rather than one, because they are two questions. Depth is about the paths inside. Directory entries are about whether the archive names the directories at all - and extractors differ there: some create a directory when they meet a path that needs one, and some create only what the archive names. An archive is the one format where you can test both.
The default is flat, which is what archives from this tool have always been, so no existing file changes by a byte. Asking for
directory_entrieswithout a depth is refused rather than quietly ignored: a flat archive has no directories to name, and the message says so and names both settings.Depth goes up to 50. The limit is measured rather than picked: a
.tar.gzwrites USTAR headers, which carry a path in a 155 byte prefix and a 100 byte name split on a slash, and past a certain length no split works. Directories cost 512 bytes each in a.tar.gzand about 76 plus the path in a.zip. The size you order is still the size you get, to the byte.The padding entry stays at the top of the archive rather than moving into the directories, so you can always tell it apart from the files you asked for.
-
A zip can be locked with ZipCrypto, the old scheme.
--set encryption=zipcrypto.It is here for what it does to a reader rather than for what it protects. Measured: .NET's own
ZipFileopens one of these, reports the entry at its true length, hands back a stream and fills it with the ENCRYPTED bytes - and never says the entry was encrypted at all. An application built on that library processes noise and calls it data. AES fails loudly in the same library, which is the safer defect and the less interesting one.So this is the fixture for finding out whether something in a pipeline waves an encrypted archive through.
It is not protection and it is not offered as any. ZipCrypto has been broken for decades. Use
aes-256when the point is that the contents are hard to read. -
A zip can be locked with a password.
tfg generate --format zip --size 30kb --set entries=3 \ --set password=Secret123 --set encryption=aes-256writes an archive of exactly 30720 B that 7-Zip opens with that password and refuses without it. The methods are
aes-128,aes-192andaes-256.The password goes into the manifest in plain text. A locked fixture nobody can open is worth nothing, so the manifest records it exactly as you typed it - that is the point rather than a leak. Do not use a password you use anywhere else.
Both settings are needed together. A password with
encryption=none, or an encryption with no password, is refused rather than guessed at, and the refusal names both of them.Limits worth knowing before you build a fixture. Some readers cannot open AES archives at all - .NET's own
ZipFilelists the entries and then fails on reading one. Nothing in this build writes the older ZipCrypto scheme yet, so an archive meant for a reader that only speaks that is not something this can make. Andtar.gzcannot be locked at all - neither tar nor gzip has any encryption in it, and asking for one there is refused with that reason rather than ignored. -
A tar.gz can say what permissions its files have and who owns them.
--set entry_mode=755and--set entry_owner=root. The modes are the ones chmod takes, from000through777, and the owners areunset(the default, and what this tool has always written),rootanduser.The useful cases are the ones nobody makes by accident:
000is a file nothing can read after unpacking,777is one a scanner should have something to say about, and an archive claiming root owns everything is what a careless extractor turns into a privilege problem.It changes no bytes unless you ask for it, and the size of the archive is the same either way.
-
A log can now be six shapes rather than one, and seven settings shape it.
tfg generate --format log --set entry_format=nginxwrites an nginx access log. The others areapache-combined(the default, and what this format has always written),apache-common,syslog,plainandjson-lines.Every template was taken from a real file rather than from a specification remembered: a real nginx and a real Apache, and rsyslog on a real machine. Two of them would have been wrong otherwise. An nginx line carries one more quoted field than "combined" does, and Apache's own default is
common, with no referrer and no agent at all.The rest of the settings:
timestampsandratefor the clock,methodsfor which verbs appear,status_mixfor which response codes,ip_versionto put IPv6 addresses in front of a reader that may not expect them, andline_endingfor a log written by a Windows service.A setting that could not do anything is refused rather than ignored. Asking for
methodsbesideentry_format=syslogis an error naming both, because a syslog line carries no request - and a setting that silently does nothing is worse than one that is not offered.Every shape still hits the size to the byte, and every line is still a whole entry.
tfg formats loglists all of it. -
JPEG XL, the twenty fourth format. One frame, 8 bit, RGB.
tfg generate --format jxl --size 300kbwrites a JPEG XL picture in the container the format defines for it.width,heightandqualitycan be set, and the picture goes up to 40 megapixels, so Full HD and 4K are both in reach. Left alone, the picture is the largest of a fixed set that fits the size asked for, up to 640x480 - the same as JPG and AVIF, so the picture formats answer the same request with the same sized picture.Every size from its minimum of 147 B upwards is reachable, with no gaps. The padding travels in a
freebox, which is the box the container sets aside for space that means nothing, and it takes any length at all.The second format here whose pixels are coded by somebody else's encoder. It is pinned, so raising it is a breaking change like any other, and it is pure Go: no C compiler, no shared library and no socket. The files were read back by two independent decoders, one of them libjxl, and both refuse a file that has been truncated or corrupted.
-
AVIF, the twenty third format. One frame, 8 bit, 4:2:0.
tfg generate --format avif --size 300kbwrites an AV1 picture in an ISO base media container.width,heightandqualitycan be set, and the picture goes up to 40 megapixels, so Full HD and 4K are both in reach. Left alone, the picture is the largest of a fixed set that fits the size asked for, up to 640x480 - the same as JPG.Every size from its minimum of 311 B upwards is reachable, with no gaps. The padding travels in a
freebox, which is the box the format sets aside for space that means nothing, and it takes any length at all.This is the first format here whose pixels are coded by somebody else's encoder rather than by code in this repository. AV1 is too large to write by hand for one format - the coefficient tables alone in the nearest implementation are fourteen times the size of this project's whole WebP encoder. The encoder is pinned, so raising it is a breaking change like any other, and it is pure Go: no C compiler, no shared library and no socket.
-
WEBP, the twenty second format. Lossless, one frame, no alpha.
tfg generate --format webp --size 300kbwrites a picture worth 300 kB rather than a thumbnail followed by filler, because the encoder measures out three bytes a pixel and the size is therefore arithmetic - the same shape as BMP and TIFF.widthandheightcan be set, and naming one lets the other be worked out from the size. The smallest WEBP this produces is 148 B.Every size from that minimum upwards is reachable, with no gaps. No other format here manages that. A WebP is made of RIFF chunks and a chunk always costs an even number of bytes, so the padding is in two parts: a private chunk for the bulk, and up to seven bytes after it for the rest.
There is no lossy variant and no
quality. Lossy WebP is VP8, which is a different codec rather than a setting, andtfg formats webpsays what this build writes rather than implying more. -
frameson GIF, from 1 to 60, default 3. How many frames the animation has. Set it to 1 for a still picture. -
TIFF, the twenty first format. Uncompressed, RGB, one page, little-endian.
tfg generate --format tiff --size 300kbwrites a picture worth 300 kB rather than a thumbnail followed by filler, because TIFF stores its pixels uncompressed and the size is arithmetic - the same shape as BMP.widthandheightcan be set, and naming one lets the other be worked out from the size. The smallest TIFF this produces is 183 B.
-
On Windows, the desktop window loads the library it uses for dark menus from the system directory rather than by name. Asking for
uxtheme.dllby name goes through the standard Windows search order, and the directory the program was started from comes before the system one in that order - so a file of that name left beside a downloadedtfg-gui.exewould have been loaded into the program and run.Nothing about the window changes. The menu is still dark, which was checked by asking Windows on a real machine rather than by reading the code.
-
The password of a locked archive is no longer repeated in the recorded command line. It appeared twice in the manifest: under the file's own
properties, where it is written on purpose because a locked fixture nobody can open checks nothing, and again insiderun.command, which records the whole command line as typed.The second one was a side effect.
run.commandis the line people copy - into a bug report, into a README, into a commit beside a set of fixtures - and it reads like metadata rather than like fixture data, so it was not treated with the same care. It now reads--set password=***. The deliberate copy is untouched, so nothing that opens these archives changes.If you compare
run.commandbetween runs, that string is different now.A manifest that carries a password is also written
0600rather than0644, so it is readable by its owner rather than by every account on the machine. Every other manifest, and every generated file, keeps the mode it had - this tool exists to produce files somebody else's CI will read. Windows has no permission bits, so nothing changes there. -
Building the program yourself now needs the build tag, and says so if you leave it out. The AVIF encoder has an assembly path that reads past the end of a buffer and takes the process down on some picture sizes. Every workflow here has passed the tag that turns it off since the encoder arrived - and none of the three ways
README.mdoffered for building it yourself did, sogo install, a distribution package and a build from a checkout all got the path this project describes as reading outside its buffer.A build without the tag now stops at the compiler with a message naming it, rather than producing a binary that works until it meets the wrong picture size. Install with:
go install -tags noasm github.com/donislawdev/TestingFilesGenerator/cmd/tfg@latestThe files the program produces are the same either way, so nothing you have generated changes.
-
A recipe that nests lists deeply is refused before it is read, whichever way it is written. The check that already refused deeply nested brackets counted only brackets, and the same nesting written as
- - - - xcosts two bytes a level and carries none.Measured against the previous build. A 500 kB recipe of nothing but dashes ran for 88 seconds and ended in
fatal error: out of memorywith a page of Go internals on standard error, leaving with the exit code a mistyped flag gets. At 1 MB - the largest recipe the size limit allows - it ran for 70 seconds and said nothing at all. The same file is now refused in under a second, with a message naming the depth and the limit.The limit counts how deeply lists and mappings nest, in either style, and it is 32. An ordinary recipe reaches one, and an archive declaring what it contains reaches two.
-
A file is never written under a name something else already holds. Every file this tool writes goes to a temporary name first and is renamed into place. Three of those temporary names were created in a way that follows a link, so a link left at one of them by somebody else sent the bytes wherever it pointed - outside the directory you gave - and the run still reported success.
Reproduced against the previous build. A link at the manifest's temporary name put the manifest onto a file outside the output directory and exited 0, after which
verifycalled that run a match andcleanupreported it removed. The same shape maderecipe fmt -wwrite your recipe onto somebody else's file and leave your recipe itself as a link. On Windows none of this needs a privilege, because a hard link is enough.Every one of those names is now claimed rather than created, and a name something else holds is a refusal that says which name and what to do. Pointing
--outat a directory reached through a link keeps working, which is the setup this was measured against.What changes for an ordinary run: nothing. The one case you can meet without somebody working against you is a leftover
.tfg-writingfile from a run that was killed part way through. That used to be written over in silence. It is now a refusal naming the file, so remove it and run again.
-
Two commands now refuse a name they cannot answer about, instead of quietly answering about a different one.
tfg formats png svg described png and said nothing about svg, ending with 0 tfg preset list some-name printed the whole list and ignored the name, ending with 0Both now end with 2 and name the word they could not use. A script that asked about the wrong thing was getting a confident answer about something else, which is worse than being told no.
tfg preset listtakes no name at all, so anything after it was always a mistake - most likely somebody reaching fortfg preset show. The refusal says so.Nothing changes when you pass the right number of names, and no generated file moves a byte.
-
A spreadsheet can now be built wider than a spreadsheet can open. The
columnssetting ofxlsxused to stop at 64. It now reaches 32768, which is the ceilingcsvalready had.The number matters because of where a reader stops. Excel and LibreOffice Calc both hold 16384 columns. Measured with Calc: a sheet of 16384 columns opens whole, and a sheet of 16385 opens as 16384 - the last column is dropped and nothing is said about it. At a ceiling of 64 there was no way to build a file that asks a spreadsheet about its own limit, which is the kind of file this tool exists to produce.
rowstimescolumnsstill cannot pass 2 million cells, so a sheet 16385 columns wide holds up to 122 rows. Nothing about a sheet of 64 columns or fewer changes, and the default is still one column. -
Byte counts are grouped in threes. A total used to print as
2516582400 B. It now prints as2 516 582 400 B, in every message that names a number of bytes -tfg formats, the summary a run prints, what a preset says its budget is, and whattfg validatereports.Grouped with a space rather than a comma, because a comma is a thousands mark in some countries and a decimal point in others, and this tool is read in both.
Machine output is untouched.
--jsonand the manifest carry numbers rather than sentences, so nothing that parses those sees any of this. If you have a script reading a byte count out of the human output, it needs to take the spaces out. -
Notes are reported once per thing they say, not once per file. A run of 25 000 one-byte text files used to print 25 001
note:lines, every one of them the same sentence about the label not fitting. It now prints one, with the count and the first three names:note: 25000 files: The label needs 32 B and the file is 1 B, so this file carries no label. Its name and the manifest still identify it. Named: files_0001.txt, files_0002.txt, files_0003.txt. 24997 files not named here.A note about a single file still leads with that file's name, unchanged.
The reason this matters beyond tidiness: a run whose manifest will be too big for this build to read back warns you first, in a line that looked exactly like the 25 000 that followed it. That warning is the only thing standing between you and a directory that
verifyandcleanupcan never read.Nothing in the manifest changes. Every entry still carries its own note, where a machine reads it and nothing scrolls.
-
Building from source now needs Go 1.27.0. It used to say 1.26.5, and that sentence was true of compiling and false of the product. Go 1.27 changed
compress/flate, so a copy built on 1.26 answers the same version number, writes that number into every manifest, and produces different bytes for PNG, DOCX and TAR.GZ than the release of that name. The compiler now refuses that build instead of producing one that quietly disagrees with everybody else's.If you are pinned to Go 1.26 you can no longer build from source. The downloadable binaries are unaffected, and so is
go install.A manifest already records which Go produced it, under
tool.go, so two runs that disagree can be told apart after the fact as well. -
The
encryptionsetting now says that a locked archive is a fixture rather than protection. Nothing about the files changes - the sentencetfg formats zipprints, and the one the window shows beside the field, gained the two facts that were missing: the key is worked out from the run seed, so the same recipe gives the same archive on every machine, and the password is written into the manifest beside the file.Both are deliberate and both are what makes these archives useful for testing a reader.
aes-256means something else everywhere else it is written, which is why it is now said out loud.README.mdsays it too. -
Files are written over several threads, so a run of many files is several times faster. They used to be written one after another.
Nothing about what you get changes. The files are byte for byte identical, the manifest lists them in the same order,
verifyandcleanupbehave exactly as before, and every refusal says what it said.Measured on an eight core machine, variants interleaved and their order reversed between repetitions. 240
.pngfiles of 200 kB went from 2.03 to 0.44 seconds, which is 4.6 times faster. 80.zipfiles of 2 MB, 2.9 times. 240.docxfiles of 200 kB, 2.0 times. Two thousand.txtfiles of 4 kB, 1.4 times - with files that small the time goes into what a run does once rather than into writing them.A single file is unchanged whatever its size, and the measurement says so rather than the reasoning: at one 20 MB
.pngthe two ranges overlap, so no difference is claimed. There is nothing to write beside a single file.The gain follows the number of cores you have and the kind of file. Work the processor does - drawing a picture, compressing an archive - scales best. A run held up by the disk gains less. A handful of files was already quick and is unaffected.
One thing changes if you stop a run part way. Ctrl+C used to leave behind the files finished so far, which were always a consecutive run of them. Several threads means one file can be cut off while a later one is already finished, so what survives can have a gap in it. The manifest names exactly what is on the disk either way, which is what
verifyandcleanupwork from, so neither is affected.The progress bar counts the whole run rather than one file at a time, so its file counter can move by more than one between redraws.
-
Producing
.pngand.giffiles is about twice as cheap. Working out what a file will contain used to draw the whole picture and compress it, only to throw the result away and do it again when the file was actually written. It now does that once.Measured on 300 files of 200 kB:
.pngtakes 2.0 times less processor time and 1.8 times less wall clock. For.gif, 1.8 and 1.6.The files are byte for byte identical. This changes only how the work is ordered, and it was checked that way - across sizes either side of every step in the picture ladder, for several seeds, with the label on and off.
A preview (
--dry-run) of a large run gets the bigger share of this, since previewing was almost entirely the work now removed..jpgis unchanged and cannot get the same treatment: it writes its padding in front of the picture, so it has to know how large the picture is before it starts. -
A recipe is parsed once instead of twice. Reading a recipe checked it for a stray second document and then handed the whole file to the decoder, which parsed it again from scratch. The decoder now works from what was already parsed.
Measured on the largest recipe the size limit allows, 900 kB and 20 000 targets:
validatewent from 839 ms to 754 ms. An ordinary recipe of a few kilobytes was already instant. Where it shows is the batch screen, which re-reads the recipe on every keystroke.Every refusal says exactly what it said before - 48 malformed recipes through two commands, compared character for character including the exit code.
-
A password protected archive allocates once per entry instead of once per block written. Producing a 128 MB locked
.zipused to make the collector run 48 times. It runs 6. The files are identical and the wall clock barely moves, so this is headroom rather than a speed-up you will notice. -
Nesting files deep inside an archive costs almost nothing to name. The directory chain in front of every entry was rebuilt for each one, though it depends only on the depth. At the deepest setting with 10 000 entries that was 82 ms of naming, and it is now close to nothing. Archives left flat, which is the default, never paid it either way.
-
verifyandcleanupread the files over several threads, so checking a large run is several times faster. Nothing about what they report changes - the same differences, in the same order, with the same exit codes.Measured on 6.1 GB in 96 files:
tfg verifywent from 4.28-4.30 seconds to 0.54-0.56 seconds on an eight core machine. The gain follows the number of cores you have, up to the point where the disk becomes the limit. On a set of files too large to sit in memory, where every byte has to come off the disk, it is about 1.6 times faster rather than nine.Small runs are unaffected either way. A handful of files was already instant and still is.
-
Starting a run of many files no longer spends most of its time deciding whether it may start. Before writing anything,
tfgchecks that it is not about to write over something of yours. That check asked the filesystem two questions for every file it planned. It reads the folder once instead.Measured on a run of 100 000 files:
--dry-runwent from 15.7-19.7 seconds to 0.49-0.56 seconds. The check was 97% of it. -
A name held by a shortcut that points at nothing now counts as taken. The check followed the shortcut, found nothing at the other end, and replaced it without a word. It asks what the folder holds now, so anything already sitting under a name your run wants is refused, whatever it points at.
-
A size range no longer fails on sizes the format cannot write. Some formats cannot produce every byte count. A file written in UTF-16 always has an even number of bytes, so half of any range was unreachable, and a picture cannot use the handful of byte counts just above its encoded size, because the smallest padding it can add costs more than that.
Until now a size drawn onto one of those was refused, and the whole run stopped. Whether that happened depended on the seed and the number of files, so the same recipe worked one day and not the next.
A range asks for some size between two ends rather than for a number, so a drawn size the format cannot write is now moved to the nearest one it can, inside the range that was asked for. Files that moved carry a
size_movednote in the manifest, and the run says so once.Two things are deliberately unchanged. A range that starts below what the format can produce at all is still refused, naming the smallest size it can write, because that is a recipe worth correcting rather than a run worth quietly filling with identical files. And
--size 1001still refuses, because that named a number.Runs that worked before are byte for byte what they were.
-
The window now warns when a run's record will be too big for this build to read back. The command line has said this since the ceiling was measured. The window said nothing at all, so somebody who generated 25 000 files from it was left with a directory that
tfg verifyandtfg cleanupboth refuse - and the manifest is the only authority over what may be removed, so those files could never be cleaned up by this tool again.It appears in two places, because the window cannot speak in the middle of a run: under Preview, which is the window's answer to
--dry-run, and again when a run finishes. The second is the one that matters, since Preview is a button somebody may never press. It comes straight after the line saying what the run did, ahead of any other note.The run itself still works and is still not refused. What was missing was that nobody was told.
-
tfg verifyno longer calls another run's files "extra". A directory is allowed to hold more than one run - that is whatoutput.manifestis for - and verifying one of them reported every file the other had written as a file nobody asked for, then called the directory a mismatch. Measured with two runs into one directory whose file names do not collide, both ending0: three differences against one manifest and four against the other, every one of them the neighbour's work.They are reported as
another-runnow, each one naming the record that lists it, and they no longer make the directory a mismatch.tfg verifyon a shared directory ends0, andmatchedin--jsonistrue. A real disagreement is unaffected - a missing or changed file is still a mismatch and still exits7.Nothing is hidden. Every file is still in the report: one entry each in
--json, and in the prose one line per neighbouring record rather than one per file. A directory holding a neighbour's ten thousand files used to print ten thousand and one lines and exit7. It now prints one line and exits0.Two limits worth knowing. A neighbour's record is recognised only when its name ends in
.json, because opening every unlisted file was measured and was too expensive - a record under another name is reported the way it was before. And a file that no manifest in the directory lists is stillextra, so leaving a manifest in a directory does not account for everything in it. -
Two runs writing into one directory can no longer write over each other's files. A run holds the directory it is writing into for as long as it is writing. A second run that starts meanwhile is refused before it writes a byte, with exit code
5and a message saying so.That protection was already there and was attached to the wrong thing. A run takes its manifest name at the start and keeps it until it ends, so two runs both writing
manifest.jsoninto one directory have always been refused. A recipe that pointsoutput.manifestat a name of its own had nothing holding it. Measured with two runs started on the same instant, eight times: twice both ended0, both reported sixty files produced, and sixty files existed - every one of the first run's replaced by the second run's, without either run saying anything.tfg verifyagainst the first manifest then reported sixty changed files for a run that had been told it succeeded.What holds the directory is a file called
.tfg-run-lock. The run removes it when it ends, including when you stop it with Ctrl+C. A run killed outright cannot remove it, so it stays behind and the next run into that directory is refused until you delete it - the refusal names the file and says exactly that.tfg verifyalso names it, as a leftover of ours rather than as a file somebody else put there, andtfg cleanupwill not remove it, because it removes only what a manifest lists.Taking the name at the start has a second effect, on a run nothing else is competing with. A run that filled the disk part way through used to lose its manifest as well, because the manifest asked for its name after the last file and by then there was no room left for it. Such a run ended with exit code
5, printed the files it had not produced, and said that clearing the directory was a job by hand. The name is now taken before the first file, so the manifest is written whatever happens to the disk afterwards,tfg cleanupcan remove what the run left, and the run ends with the partial exit code8instead. Measured in a container with room for twelve names: 0.2.0 and 0.3.0-rc1 both ended5and wrote no manifest, and this release ends8and writes one.The cost is worth stating plainly: two runs can no longer fill one directory at the same time, even when the files they write have different names. For every run that does not set
output.manifestthat was already true. -
The About screen now shows the support address, so the Donate button is no longer the only way to reach it. Pressing Donate asks your desktop to open the page. On a machine with no browser registered that quietly does nothing, which was the intended behaviour - a message about a button somebody pressed out of curiosity is worse than none - except that the address itself appeared on no screen at all, so there was nothing to fall back to.
It is on the About screen now, under Support, on a line of its own so it can be copied in one go.
-
A manifest too big for this build to read back now says so in the report a script reads. A run of 25 000 files writes a manifest of about 25.9 MB against a 16 MB reading limit, so
tfg verifyandtfg cleanupboth refuse it - and the manifest is the only authority over what may be deleted. The run still ends0, because the files are correct and complete.Until now the only warning was a sentence on standard error.
--jsoncarried no trace of it, so a CI job had no way to learn that the directory it had just filled could never be verified or cleaned up by this build. The manifest now carriessummary.too_large_to_read_back.The field is absent on an ordinary run rather than
false, so every manifest already written is byte for byte what it was, andmanifest_versionstays1.0. -
A stopped run says what happened and what survived, instead of
context canceled. Pressing Ctrl+C, or a CI job running out of time, printed six characters of Go vocabulary and left you to work out whether the directory was safe to reuse. It now reads:tfg: stopped before it finished. 897 files written, and the manifest describes exactly those. manifest: /out/manifest.jsonSo
tfg cleanupcan take exactly those away again. The window has said this since it had a progress bar - only the command line was silent about it.Exit codes are unchanged:
130for a cancel,143for a signal that says time is up. A run whose deadline ran out says that rather than that it was cancelled. -
A refusal about a size now names the setting you actually wrote. A target using
size-rangewas refused attargets[1].size, a key that recipe does not have, sovalidate --jsonsent a script - and the window sent a person - to a box that was not there. It now readstargets[1].size-range. Targets usingsizeare unchanged.The wording of every refusal is byte for byte what it was. Only the address moved.
-
verifyno longer calls a half-written manifest a file it knows nothing about. A run killed outright can leave<manifest>.tfg-writingbehind.verifyreported it asextra, the word it uses for a file somebody else put in the directory, so the reader was told their fixtures were polluted by something the tool had written itself.It is now reported as a leftover, with the sentence that case needs: the run was saving the list of what it produced, so the directory may hold finished files that nothing lists - and
cleanupcannot remove those, because it removes only what a manifest names. That is the one case worth looking at rather than just deleting.Half-written files from the same run were already reported this way. This was the second marker, and nothing on the reading side had been told about it.
-
verifyandcleanupread each file in larger pieces, which takes about a fifth off the time spent hashing. -
Every run used to pause twice to tidy memory, however small it was. It pauses once. Nothing about what a run produces changes.
-
A run of a few files is now weighed against the memory ceiling too. The ceiling that stops a run from planning more than it can hold only started counting once a run asked for sixty four files, so a smaller run had no ceiling at all - and it counted files across the whole run, so a recipe of sixty four one file targets began counting after sixty three of them were already planned.
That was reachable with ordinary settings rather than with a contrived one. A zip of ten thousand entries costs about 75 MB to plan, so twenty nine of them come to 2.17 GB, past the ceiling and without a single check. Such a run is now refused before anything is written, and the refusal says how far it had got and what the ceiling is.
Runs this tool was designed around are orders of magnitude under the ceiling and are unaffected. Nothing about the files that are produced changes.
-
A crash inside a generator now costs one file instead of the whole run. Until this release a defect in one of them ended the process, and it left the file it was writing on the disk under its temporary name - a name
cleanupwill not remove, becausecleanuponly removes what the manifest lists, and a file that never finished never reached one.verifythen reported it for good.Such a crash is now an ordinary failure of one file: the rest of the run carries on, the manifest says which file it was and what happened, the temporary file is removed, and the run ends with the partial exit code. A crash while planning ends the run instead, with the exit code that means this tool has a defect rather than the recipe does.
This is a safety net, not a licence. A crash is still a defect worth reporting, and the message says so.
-
Ctrl+C now stops
generate,validateandpreset showwhile they are still planning. Until this release they finished planning first and noticed the key only afterwards, so a large batch could look frozen: ten thousand pictures is about a minute and a half of planning before the first byte is written, and all of it ignored the key. Under a CI timeout the grace period ran out and the job was killed rather than shutting down.Writing was never affected. A run interrupted while producing files already stopped promptly, saved its manifest and left no partial files behind.
-
A recipe can no longer use up all the memory on the machine. A document of 40 kB that nested brackets twenty thousand deep took most of a gigabyte before it was refused, and the size limit did not help - forty kilobytes is a small fraction of what a recipe is allowed to be.
Recipes now refuse to nest brackets and braces more than thirty two deep. Nothing anybody writes comes close: a list such as
[1B, 1kb, 1mb]is one deep, and so is a target written out with braces. Brackets inside a quoted value are text and are not counted. -
A compressed
zipis now either produced at the size you asked for or refused, never quietly written at the wrong length. Two sizes did the second thing, and both passed--dry-runfirst.Asking for a compressed zip at exactly the smallest size the tool reports produced no file at all. That size is now refused, and the smallest compressed zip is a little larger than the smallest stored one - the padding entry a compressed archive needs costs a few bytes of its own.
tfg formatsis unchanged, because it reports the stored floor and compression is off by default.Asking for a zip whose contents are already compressed - other zips, Office documents, pictures - could also land in a narrow band of sizes that could not be produced. Deflate makes such data slightly larger rather than smaller, and the padding could not shrink far enough to make up for it. Those sizes are now refused with the smallest size that does work.
Sizes that worked before are unaffected, byte for byte.
-
A
validate --jsonthat is stopped no longer says the recipe is invalid. It never finished reading the recipe, so it has no verdict to report. The exit code says what happened instead.
0.2.0 - 2026-08-28
-
A file name holding
<,>,",|,?,*or a control character is now refused while the run is being planned, on every system. Windows will not store such a name, so on Windows the file was never written anyway - it was reported as one file that could not be produced, at the end, in the operating system's words. On Linux and macOS the same recipe wrote the file, which is the part that changes: a recipe is meant to mean one thing everywhere, and a path separator has been refused on every system for that reason since the start. If you want such a name on purpose, that is a test case rather than an accident and it belongs inside an archive, where the name survives whatever the host filesystem thinks of it.Two things get better on Windows as well.
--dry-runused to report success for a run that would then fail, and the refusal now says which character is the problem and what to do instead, instead of quoting a system error that named an internal temporary file. -
Two file names that a Mac stores as one file are now refused while the run is being planned, even where the letters are different ones. A name written with the German sharp s and the same name written with a double s used to be accepted, because Windows and Linux really do keep them apart. macOS does not: on a default APFS volume they are one file, so the same recipe wrote two files on one machine and one on another, and the manifest described two either way. The long s against a plain s, and a typographic ligature against the letters inside it, behave the same way there.
This is the rule that already refuses a path separator and the characters Windows will not store, applied to the last case where a recipe still meant different things on different machines. The refusal says which two targets are involved and what the names have in common, and nothing is written.
One pair stops being refused, which is the same change facing the other way: a dotted capital I and a plain lower case i were treated as one letter and are two on every filesystem we measured. If you were working around that, you no longer have to.
-
A file named exactly
nulis refused as well, for a worse reason: on Windows that is the null device, so writing to it succeeds and the bytes go nowhere. The run was already stopped, but by a check that then told you to remove a file you cannot remove.nul.txtis an ordinary name and still works, and so docon,con.txt,prn,aux,com1and the rest of the names people expect to be reserved - they were each tried on Windows 11 and on Windows Server 2025, and every one of them is an ordinary file there now.
-
Windows binaries are signed. The signature sits on the program inside the zip, carries an RFC 3161 timestamp, and names an Open Source Developer certificate issued by Certum. Windows checks it when you run the program, so the SmartScreen warning that used to greet every download is gone.
macOS and Linux binaries are not signed yet and the release notes say so.
A release is now made in two halves. A tag builds everything and opens an empty draft. A person with the signing card runs one script, which checks the build's own provenance before touching it, signs, writes the checksums over what it made, and asks a workflow for the statement about the signed bytes. Nothing is published until somebody reads the draft and presses the button.
-
Every file in a release now carries two statements made while it was built: where it came from, and what is inside it. Both are signed by the build with a short lived identity and written to a public log, so anybody can check a download without having to trust this page:
gh attestation verify <the file you downloaded> -R donislawdev/TestingFilesGeneratorA release also carries
tfg_<version>.spdx.json, a bill of materials naming every library and every font compiled into these binaries, with its licence. The statements themselves are published beside the downloads, so the command above works offline with--bundleas well.The binaries are still not signed and the notes still say so. Provenance answers a different question from a signature: which source and which build produced a file, rather than who vouches for it. It does not stop Windows or macOS from warning about an unsigned program.
-
tfg licensenow lists what the binary in front of you actually carries: every module compiled into it, with its version and its licence, and the fonts and drawings that arrive inside those modules. It used to point at THIRD-PARTY-NOTICES.md, which is no help at all when what you downloaded is one file and that file is the binary.The list is read out of the build's own record, so it describes the binary being asked rather than a source tree somewhere else. The command line answers with three entries. The window answers with thirty.
The window shows the same names and licences on its About screen, which scrolls now. Versions are on the command line's answer and in THIRD-PARTY-NOTICES.md, because the screen is rendered in places where a build record cannot be read.
-
Every manifest entry now says which target the file came from, under
target_id, and the summary counts the files each target produced underby_target. Nothing else answered that question. Theidon an entry is numbered across the whole run, so two targets in one recipe producef_0001tof_0005with nothing marking where one ends.formatis shared the moment two targets ask for one format,groupis optional, and the file name only carries the target while nobody supplies a name template of their own - which is exactly when somebody would want to ask. Counting files per target meant parsing file names, which is the work a manifest exists to remove.manifest_versionstays at1.0. These are added fields, and a reader written against1.0is unaffected by keys it never looks at. -
The manifest records which Go toolchain built the binary that wrote it, as
tool.go. Alongsidetool.versionandtool.generators, this is what makes a hash mismatch diagnosable: without it, a hash that moved because somebody rebuilt with a newer Go looks exactly like a hash that moved because the recipe changed.manifest_versionstays at1.0. The schema grows by adding fields, and a reader is expected to ignore fields it does not recognise. -
A run large enough that this build could not read its own manifest back now says so before it writes anything, and on
--dry-runtoo. A manifest is read into memory to be compared against a directory, so there is a ceiling on how big one may be - and above roughly twenty thousand files a run wrote a manifest past it. From that point neithertfg verifynortfg cleanupwould read it, and since the manifest is the only thing that says which files may be removed, those files had nothing able to remove them. The run still happens: what was missing was being told. -
Keyboard shortcuts:
Ctrl+Entergenerates,Ctrl+Ppreviews,Escstops a run that is going. They work while you are typing in a box, which is when you are most likely to want them, and they do nothing when the button they press is out of use - soCtrl+Entercannot start a second run during the first. -
The keyboard starts on the first field of whichever screen you are on, and moves with you between screens. No reaching for the mouse to begin.
-
A finished run offers Open folder, which opens the directory the files actually went into. It appears when there is something to open and goes away when the next run starts.
-
The window folds away what a format decides for itself and the notes that describe the case, so the batch screen fits without scrolling for the first time. Both open with a click, and a run refused because of a setting inside one of them opens it and marks the box - you never have to go looking.
-
A folded section says what is in it, so a value you typed is never hidden without a word.
-
The four files you check a download against now sit together at the bottom of the release page, and their names say so.
SHA256SUMS.txtis nowverify-SHA256SUMS.txt, and the.spdx.jsonand two.sigstore.jsonfiles gained the sameverify-prefix.Nothing about what they contain changed. What changed is where they appear: GitHub orders a release's download list alphabetically by file name and by nothing else, so the old names put the checksums file at the very top, above every program, and dropped the three statement files into the middle of the list between the desktop archives and the command line ones. Now the programs come first and the things you check them with come last.
If you have a script that fetches
SHA256SUMS.txtby name, it needs the new name from the next release on. The release notes carry it too. -
macOS downloads are signed by the owner and notarised by Apple, and macOS opens them without argument. They used to be refused on the first try, with a note here telling you to open them from the right click menu instead. That note is gone because the reason for it is gone, and it works with the network off as well - the notarisation travels inside the file rather than being looked up.
The archive has a different shape because of it. The program now lives inside
tfg.apportfg-gui.app, with a link calledtfgortfg-guibeside it, so./tfgstill works exactly as before. Keep the two together: a copy of the program lifted out of the bundle on its own is refused, because the signature covers the bundle rather than the file.One thing this takes away, and it is written down rather than left to be discovered. The build's own statement about where a file came from no longer answers for the macOS archives, because signing them changes their bytes. It still answers for Linux. What answers for macOS is the signature itself, and the release notes say which two commands read it.
-
The window no longer offers to put files inside a format that holds none. "Add files inside" now appears under ZIP and TAR.GZ, and nowhere else. It used to appear under every batch, so a PNG batch carried a button whose only destination was a refusal. Rows you already filled in stay on screen if you change the format afterwards, so nothing you typed disappears and the refusal still has a field to point at.
-
Every list of file formats in the window now draws the same small picture beside each value. One of the three lists had them and two did not, so the same twenty formats looked like different kinds of list depending on which tab you were on.
-
A setting chosen from a list now opens on its own default instead of on an extra entry reading "not stated - pdf". The extra entry existed so that a default nobody picked could be told from a value somebody chose. Measured on both surfaces, nothing downstream ever read that difference for a setting drawn as a list: a run leaving an ICO's embed alone and a run asking for embed=bmp produce the same bytes and the same manifest, and the preset block's defaulted list is built from a preset's own parameters, which the format is not one of. Boxes you type into are unchanged - leaving a preset's limit empty still records it as ours rather than yours.
One visible consequence: a run started from the Presets tab now records the format in the manifest's preset parameters, because the screen states it. The files are the same.
-
Four fields explain themselves better. The format list dropped its second sentence, which described the list you were already looking at. "File names" and "Batch name" say what they are for and what they change. The seed says what 0 means - it is the seed a run uses when nobody asks for another one, and it is not a request for random files, which this tool never produces.
-
Two refusals are worded differently. Nothing about what is accepted has changed - only the sentences.
The refusal for an empty output directory used to end "or leave it out to use the current one". That is true when you write a recipe file, and it is advice you cannot take in the window, where leaving the box empty is exactly what was just refused. It now says "Name a directory, for example ./fixtures" and stops there.
A size setting given something that is not a size used to be answered with different words from the ones shown under the empty field - "a size written the way any size is, such as 2mb or a plain byte count" against "a size such as 2mb, or a plain byte count". Both now say the second. Every other kind of setting already said the same thing in both places.
-
A refusal about a format setting in a recipe now says which target it is about, and says what to do instead:
target "photos": width cannot be "99999"rather thanbmp: width cannot be "99999". With twenty batches of the same format, the old wording did not tell you which one to fix. The machine readable report carries the address too, astargets[2].properties.width. -
The window marks the fields you have to fill in with a red star beside their name. Until now the only way to find out that a box could not be left empty was to press Generate and read the refusal - and on the batch screen a setting the run will not do without looked exactly like one nobody need ever touch.
-
A box holding a size says what that size comes to, beside the field's name and updated as you type:
10mbshows10485760 B. This tool counts in 1024s, and the count is the only place on the screen that says so without being asked. -
A run refused for asking too many files, or for a total too large to measure, now says which batch took it over the line. Both limits are about the whole run, so the message always was - but the box you can change belongs to one batch, and on a form with twenty of them "this run asks for 1000001 files" with nothing marked left you to work out which. The window marks it and
validate --jsoncarries"at": "targets[2].count". -
tfg validate --jsonnow splits every refusal intowhat,whyandfix, the way it already did for the ones the recipe reader produced. Before this, a refusal from a format, a preset or the engine arrived as one sentence and a script grouping by reason had to take prose apart to do it. -
Twelve refusals the engine produces read slightly differently as a result. The punctuation moved and nothing else: a full stop or a colon between what is wrong and why becomes a dash, the dash before what to do becomes a full stop, and that last part now starts with a capital letter. So
... holds the character "<". Windows refuses ... everywhere - take the character outreads... holds the character "<" - Windows refuses ... everywhere. Take the character out. Nothing was added or removed, and if you match on these messages in a script, match onatand the three fields instead. -
Exit code: a recipe asking for a format setting the format will not take now ends with
3(the recipe is wrong) rather than4(the format cannot do it). The check moved into the recipe reader so the refusal could name its target, and it now arrives with every other problem that recipe has, in one report.--seton the command line is unaffected and still ends with4. If your CI compares the exact number, this is the line to read. -
Every box you may leave alone now shows what happens if you do. Two on the batch screen said nothing at all - the class of a batch, and the seed - so they were indistinguishable from boxes that have to be answered.
-
The foot of a form that has more content below it fades into a shadow rather than into the page, and carries a small arrow. The old fade was obvious where the last thing on screen was text and nearly invisible where it was empty space, which is the case where a reader has nothing else to go on.
-
The window went through a design review and came out of it looking like one program rather than four screens. Nothing it does has changed and no generated file is different. What changed is what it looks like:
- Space now says what belongs together. A field's own name, box and explanation sit close, two fields sit further apart, and two sections further still. Before this, all three distances were the same and the form read as a wall of text.
- Everything a person reads on a screen starts on one left edge.
- A finished run is drawn in green and a run that skipped files in amber. Until now, "3 files written." was in exactly the same grey as every other line on the screen, while a refusal was in red - so the window shouted about mistakes and whispered about success.
- A progress bar at nothing no longer looks like one at everything. The empty part is a groove rather than a paler version of the fill.
- A box switched off during a run no longer looks like an empty box with a hint in it. They were the same colour.
- A box is as wide as what goes in it. A whole number from 1 to 20000 used to get a box running the width of the window, and two of those took a row each.
- Settings a format declares are labelled the way every other field is -
"Bit depth" rather than
bit_depth. The key to write in a recipe is on the small letter i beside the label, and a refusal about that box now names it the way the screen does. - A refusal gets the full width of the form to say its four parts in, instead of the column its field sits in.
- The format menu shows what kind of file each format is, so twenty three-letter names are not the only thing to go on.
- The tab you are on is the one that stands out.
- A form with more below the fold says so, instead of stopping at the window edge as though that were the end of it. The single batch screen now fits the window it opens at.
-
verifyandcleanupare much faster over large runs on Windows. Checking that an entry stays inside the output directory used to work the directory out again for every single file, and working it out is expensive on Windows - more so the deeper the directory sits. It is now worked out once per command. Measured on 3000 files of 1 kB:verifywent from about 17 seconds to about 0.9 seconds from a deep path, and from about 2.4 seconds to about 0.5 seconds from a short one. Linux was already fast and is unchanged.Nothing about what the two commands accept or refuse has changed. A file that leaves the directory through a link or a junction is still refused, and a directory reached through a link still works.
-
The answer in the README about slow runs on Windows said the cost was the antivirus opening each file. That was wrong, and it is corrected.
-
In the window, the form no longer shifts under the pointer when a run has more than one line to say. The bar at the foot keeps one height now and a longer message scrolls inside it, instead of growing and pushing every field upward at the moment you are reading the buttons.
-
What a run says now comes above the notes about settings you did not fill in, rather than below them. With the bar keeping one height, the first line is the one you are sure to see, and "7 files written." is more use there than a note explaining a default.
-
On macOS the program now has an icon. It is a
.appbundle since the release before this one, and a bundle with no icon in it is drawn by the Finder and the Dock as a blank sheet of paper - which is what a program the system knows nothing about looks like. The icon is the same drawing the other two systems use, on the rounded square macOS puts every icon on, at every size from 16 px to 1024. -
In the window, a menu is now the same width wherever it appears, and always wide enough for the words in it. The menu for choosing a format was 140 px wide on the single batch screen and 98 px on the presets screen and in a row of an archive's contents, for the same twenty formats. In that narrow box the toolkit's own "(Select one)" was cut off mid word, so a row of an archive's contents offered "(Select ..." until a format was picked. No menu is drawn narrower than the boxes standing beside it any more.
-
In the window, the Remove button ending a row of an archive's contents is the size of a button. It was taking a quarter of the form's width and the height of a label and a control together, which drew it as a panel with a word in the middle rather than as something to press.
-
The notices that travel with a release now name the fonts and drawings the window binary carries. Seven font files and ninety-seven images are compiled into
tfg-guifrom inside the graphics toolkit, under the SIL Open Font License, the Bitstream Vera licence and MIT, andTHIRD-PARTY-NOTICES.mdnamed none of them. It described modules, and a font is a file inside a module rather than a module of its own, so nothing that asked about modules could see them. Those licences ask for their notices to travel with the bytes, so the full texts are in that file now.tfg, the command line binary, embeds none of this and never did. The file says so as well, rather than leaving it to be assumed.The same file also stated that
golang.org/x/textwas version 0.40.0 while every binary linked 0.41.0. Both numbers are now compared with the build. -
verifyandcleanupno longer contradict each other about a file stored under a different case. On Windows and on a Mac,REPORT.TXTandreport.txtare one file, andverifyused to call such a fileextra- the word it uses for somebody else's file - whilecleanup, given the same directory and the same manifest, deleted it and reported a clean sweep.verifynow saysrespelledand names what the manifest calls the file, so the report says what happened instead of sending you looking for a stranger's file.cleanuprefuses to remove it and ends with the partial exit code, because it removes the names the manifest lists and that name is not one of them. Rename the file back, or verify against a manifest written for the names you have.Nothing changes where the filesystem keeps the two spellings apart, as Linux does: there they are two files, and both commands always agreed.
-
The window no longer slows down as a recipe grows. Every keystroke on the batches screen re-reads the whole recipe, and the cost of doing that used to rise with the square of its size: a hundred batches took a quarter of a second per key, which reads as the window stalling while you type. It now takes about a sixtieth of that, and the cost rises in step with the recipe rather than ahead of it. Files, hashes and every message are unchanged.
-
A run whose manifest could not be written no longer leaves an empty
manifest.jsonbeside the files it wrote.The name is taken before the first file, as an empty file, so that two runs into one directory cannot both claim it. When the manifest then failed to be written - a full disk, a permission, something already sitting under the temporary name - that empty claim stayed.
tfg cleanupandtfg verifyboth refused it with "unexpected end of JSON input", so the files it should have described could not be removed by the one thing allowed to remove them. Worse, the next run into that directory was refused with a sentence saying the file "is the only record of what an earlier run wrote" - true every other time it is printed, and here about a file that recorded nothing.The claim is now given back when the write fails, so the next run is refused about a file that really is in the way, and names it. A manifest with anything in it is never removed.
-
A run that could not save its manifest now says what that leaves behind. The message about the manifest was about the manifest, and the problem is the files: they are on the disk, nothing records them, and cleanup works from a manifest. That is now said in the run rather than discovered later.
-
Working out what a run would cost no longer freezes the window, and can now be stopped. Pressing Preview or Generate used to work the whole plan out on the thread that draws, on the reasoning that planning is fast. It is fast for text: measured across formats at two thousand files, a text run plans in about 0.4 s and a PNG run in 16 to 23 s, because a picture is encoded while it is planned. Ten thousand pictures was a minute and a half of a window that did not redraw, with both buttons still looking pressable. It now happens off that thread, with Cancel offered while it goes.
-
Closing the window during a preview no longer waits for the whole preview. The check that a preview runs asks the filesystem about every planned file, twice each, and could not be interrupted - so on a large set or a directory on a network share, closing the window sat there until it finished.
-
An archive or an Office file asked for four gigabytes or more is now refused while it is planned. Above that line a ZIP needs extra records to describe itself, and the arithmetic this tool uses to work out an archive's size before writing it cannot account for them - so the file came out 112 bytes longer than planned, which the tool then caught and reported as a fault in itself, after writing four gigabytes and removing them. TAR.GZ is unaffected and keeps working at those sizes.
-
tfg cleanup --jsonnow reports counts that add up. A run of four files with one already deleted reported three removed, none kept, and four files - because the kept count only counted files that were still there and blocked, while the list called every file it did not remove "kept". Adding the two numbers lost an entry with no way to tell which. -
A WAV asked for more than about four gigabytes is now refused instead of being written with a length field that does not match the file. A RIFF file states its own length in a four byte field, and nothing checked it, so a request for eight gigabytes produced a file of exactly that size whose header announced four - and every part of this tool agreed the file was fine. The size was right, so the run succeeded, the hash went into the manifest, and
tfg verifycalled it a match. A file that is broken and certified as sound is worse than one that fails loudly, which is why this is a refusal.The ceiling is 4294967303 B rather than four gigabytes exactly, and the difference is deliberate: the length field counts everything after itself, so a file eight bytes over four gigabytes still describes itself correctly.
-
A refusal about a size that is too large now says so. BMP, ICO and PNG already refused sizes they cannot describe, but all three said "cannot be smaller than N B" about a request that was larger - a sentence that contradicts itself and offers, as the way out, the very ceiling the request had just passed.
-
An archive asked through
containsto hold more files than the format allows is now refused, with the same sentence and the same exit code as asking for the same number through theentriessetting. The two ways of saying it disagreed:entries: 50000was refused as being outside 0 to 10000, while acontainslist asking for fifty thousand files validated cleanly, passed--dry-run, and then built a plan for every one of them. -
A run whose plan would not fit in memory is now refused while the plan is being built, rather than ending as an out of memory kill with no message. There was a ceiling on the number of files, but how much a file costs to plan depends on its format - a PDF of a thousand pages costs about six thousand times what a text file costs - so
--format pdf --set pages=1000 --count 10000passed every check and then asked for around fifty gigabytes before writing a byte. The ceiling is now on the memory, which is the thing that runs out. -
The
size-boundariespreset now answers a limit with no room above it the way--boundaryalready did. A limit at the largest number there is used to be refused with a sentence about a file that "cannot be smaller than nothing", and advice to raise the limit - an answer about the bottom of the range to a question about the top. -
On the batch screen, a refusal about one batch now marks that batch's box. A batch asking for a size its format cannot deliver, or a name your system will not store, used to stop the run and mark nothing at all - with twenty batches on screen there was nothing to say which one to change. Those are the two refusals you meet most.
-
A bad name for the manifest marks the manifest box rather than nothing. It was reported as though it were the name of a file.
-
tfg validatenow checks the name of the manifest, so it stops calling a recipe valid thattfg generaterefuses a second later. If you run validate in a pre-commit hook, that is one fewer way for a broken recipe to get past it. -
A run whose files would take the name of its own manifest is now refused before anything is written. It used to write every file, put one of them where the manifest was going, and then stop with "file already exists" - leaving the files on disk with no manifest, which means
tfg cleanupcould never remove them again. It happened whenever a target produced a file named exactly what the manifest is called, including themanifest.jsona run uses when the recipe does not name one.tfg validateand--dry-runboth called such a recipe fine, so there was no way to find out before the files were on disk. All three now give the same refusal, and it says which target to change. -
tfg verifyno longer calls a file extra because the manifest spells its path the long way round. A manifest listing./report.txtfor a file calledreport.txtused to reportextra report.txt- one difference rather than the pair a real mismatch shows, so it read as a directory somebody had put a file into rather than as two spellings of one name. Both spellings were already accepted everywhere else in the tool. Nothing about which files are looked at changed, only which spellings count as the same name. -
The manifest and
tfg recipe fmt -wnow flush their work to the disk before putting it in place. Both are written beside the target and renamed over it, and a rename can reach the disk before the bytes do - so a power cut at the wrong moment could leave an empty file under the name of your manifest, or of the recipe you had just formatted. Generated files are still not flushed, on purpose: that is ten thousand of them against one of these. -
A machine readable report that could not be written whole no longer ends with a zero exit code.
tfg verify --json | headon a large report used to hand you half a document and say the run was fine, so a script parsing it failed on the syntax and blamed itself. A run that had already failed keeps the code it failed with - a broken pipe is not why your recipe was wrong. -
A recipe is now refused for being too large however that size is discovered. The check used to ask the directory entry before reading, which is a look rather than a limit: a file can grow between the look and the read, and
tfg recipe fmtwould then have formatted the first megabyte of a longer file and reported success. The message and the exit code are unchanged, including the size it reports. The same applies to reading a manifest. -
Files whose format writes them in many small pieces are written much faster. The worst shape the settings allow - a BMP one pixel wide and twenty thousand tall - went from 3.660 s to 0.138 s for sixty files. An ordinary 1 MB BMP is about a third faster, plain text and PNG a little. The bytes are identical, so nothing that checks a hash sees a change.
-
Ctrl+Cduringtfg verifynow stops while it is still listing the directory. On a tree with hundreds of thousands of files it used to finish the listing first, which on a slow disk is a long time to keep pressing it. -
The refusal for a boundary limit below 1 B now reads the same from the command line and from a recipe. Both took it from their own sentence, and the two had already drifted apart by a comma. The wording is the four part shape the rest of the tool uses: what is wrong, why, and what to do instead.
0.1.0 - 2026-08-20
Initial release.