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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ jobs:
# command line binary links exactly four external modules and the
# toolkit is not among them, so a build for a server carries no
# window, no OpenGL and - see internal/guard - no socket.
#
# Five on Linux and macOS since 2026-09-29 (O252): golang.org/x/sys,
# for the one call that gives a finished file its name without
# replacing anything there - renameat2 with RENAME_NOREPLACE and
# renamex_np with RENAME_EXCL. Already in the graph at the same
# version, BSD-3-Clause read from its LICENSE file. Not on Windows:
# golang.org/x/sys/windows imports net, which the command line may not
# link, so Windows makes the same call through syscall. The answer
# therefore depends on the runner, and the list says so.
run: |
set -euo pipefail
# Built with printf rather than written across several lines. A
Expand Down Expand Up @@ -266,6 +275,9 @@ jobs:

linked=$(go list -deps -tags "$(cat .github/build-tags)" -f '{{if .Module}}{{.Module.Path}}{{end}}' ./cmd/tfg | LC_ALL=C sort -u | grep -v '^github.com/donislawdev/TestingFilesGenerator$' | grep .)
wanted=$(printf '%s\n' github.com/gen2brain/gav1d github.com/gen2brain/jxl github.com/goccy/go-yaml golang.org/x/text)
if [ "$RUNNER_OS" != "Windows" ]; then
wanted=$(printf '%s\n' $wanted golang.org/x/sys | LC_ALL=C sort)
fi
if [ "$linked" != "$wanted" ]; then
echo "the command line binary links a different set of modules."
echo "expected: $wanted"
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ because it turns other people's test suites red.
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 -o` are 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 as `manifest.json.tfg-writing` rather than as an empty
`manifest.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.

## [0.4.0] - 2026-09-25

### Changed
Expand Down
10 changes: 6 additions & 4 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ first, so the difference is worth stating rather than leaving to be assumed.

| binary | what it is | third party code in it |
|---|---|---|
| `tfg` | the command line | the Go runtime, and **four** modules: `github.com/goccy/go-yaml`, `github.com/gen2brain/gav1d`, `github.com/gen2brain/jxl` and `golang.org/x/text` |
| `tfg` | the command line | the Go runtime, and **four** modules: `github.com/goccy/go-yaml`, `github.com/gen2brain/gav1d`, `github.com/gen2brain/jxl` and `golang.org/x/text` - plus `golang.org/x/sys` on Linux and macOS, whose notice is in the window's table below |
| `tfg-gui` | the desktop window | the same, plus **27** more for the graphics toolkit, one of them on Linux only |

The window is a separate binary because its toolkit needs a C compiler and
OpenGL, neither of which the command line uses. A server or a build agent
running `tfg` therefore carries none of the 27, and that is checked rather than
asserted: a guard in the source compares what the command line binary actually
links against that list of four.
running `tfg` therefore carries none of the 27 but one - `golang.org/x/sys`, on
Linux and macOS only, for the one system call that gives a finished file its
name without replacing anything already there. What the command line binary
links is checked rather than asserted: a guard in the source compares it with
the reviewed list of modules, and another refuses a network package in it.

---

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ require (
github.com/gen2brain/jxl v0.2.0
github.com/goccy/go-yaml v1.19.2
github.com/nicksnyder/go-i18n/v2 v2.6.1
golang.org/x/sys v0.48.0
golang.org/x/text v0.42.0
)

Expand Down Expand Up @@ -108,7 +109,6 @@ require (
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

Expand Down
15 changes: 12 additions & 3 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,20 @@ func (d Difference) String() string {
// second is a RECORD that was being saved, so the useful thing to say
// is that the directory may hold files nothing lists - which is the
// one case where a person has to look rather than just delete.
//
// Since 2026-09-29 that second one is also how a run reserves its
// manifest's name, from before its first file to its save (O252). So
// it may belong to a run still going, like the lock above, and the
// sentence holds both endings open for the same reason. It names the
// manifest OR a file beside it, because the instructions are written
// through the same marker - a review of #150 caught the first wording
// promising a manifest about a file that would never become one.
if core.IsWritingName(filepath.Base(d.Path)) {
return fmt.Sprintf(
"leftover %s\n a run's record that was not finished being saved, from a run that was "+
"stopped before it could tidy up. The directory may hold files that nothing lists, and cleanup "+
"cannot remove those - check what is here against what you expected before deleting this by hand",
"leftover %s\n a record of a run that is not saved yet - its manifest or a file written "+
"beside it. If a run is going on it will give this its final name when it ends. If none is, that run was stopped before it could "+
"tidy up, and the directory may hold files that nothing lists, which cleanup cannot remove - "+
"check what is here against what you expected before deleting this by hand",
d.Path)
}
return fmt.Sprintf(
Expand Down
40 changes: 22 additions & 18 deletions internal/cli/presetcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"flag"
"fmt"
"io"
"os"
"io/fs"
"strings"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
Expand Down Expand Up @@ -323,30 +323,34 @@ func (f *fileFlag) Set(s string) error {
// the console's code page, which on a stock console changes every letter
// outside ASCII. Only the bytes the tool writes itself arrive as they are.
//
// Claimed first and then replaced whole. The claim is exclusive and does not
// follow a link (core.CreateNew), which is what keeps an edited recipe from
// being written over. The replacement goes through a temporary name and a
// rename (core.ReplaceFile), so a run stopped part way leaves an empty file or
// none rather than a recipe cut short - and a YAML file cut short can still
// read as a smaller recipe.
// Written whole under a temporary name and given its own only while nobody
// holds it (core.WriteNew), so a run stopped part way leaves no recipe cut short
// - a YAML file cut short can still read as a smaller recipe - and a file
// already under that name, maybe a recipe somebody edited, is refused rather
// than written over.
//
// Until 2026-09-29 this claimed the name with an empty file first and renamed
// the recipe over the claim. A file put under the name between the two was
// destroyed by the rename, and a failure removed whatever held the name by
// then - two of the three windows of O252.
func writeEjected(path string, source []byte, errOut io.Writer) int {
f, err := core.CreateNew(path, 0o644)
if err != nil {
var taken *core.NameTakenError
if errors.As(err, &taken) {
if _, err := core.WriteNew(path, source, 0o644); err != nil {
// The temporary name the recipe is written under first, held by a
// write that was stopped or by another program. Saying the recipe
// itself is there would send somebody looking for a file that is not
// (a review of #150).
var held *core.NameTakenError
if errors.As(err, &held) {
fmt.Fprintf(errOut, "tfg: %s is already there. It is the temporary name the recipe is written under before it becomes %s, left by a write that was stopped or put there by another program. Nothing was written. Remove it and try again.\n", core.Shown(held.Path), core.Shown(path))
return ExitIO
}
if errors.Is(err, fs.ErrExist) {
fmt.Fprintf(errOut, "tfg: %s is already there, and -o does not write over a file - it may be a recipe somebody edited. Nothing was written. Choose another name, or remove that file first.\n", core.Shown(path))
return ExitIO
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fmt.Fprintf(errOut, "tfg: cannot write the recipe to %s: %s\n", core.Shown(path), describeError(err))
return ExitIO
}
_ = f.Close()
if err := core.ReplaceFile(path, source); err != nil {
// Only the empty claim this call made is there to take back.
_ = os.Remove(path)
fmt.Fprintf(errOut, "tfg: cannot write the recipe to %s: %s\n", core.Shown(path), describeError(err))
return ExitIO
}
fmt.Fprintf(errOut, "recipe: %s\n", core.Shown(path))
return ExitOK
}
72 changes: 46 additions & 26 deletions internal/core/createnew.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package core

import (
"errors"
"io/fs"
"os"
)

Expand Down Expand Up @@ -34,40 +32,62 @@ import (
// any privilege, which is measured rather than read. So the only answer that
// holds is the one the operating system settles while it creates the file.
//
// O_EXCL IS NOT RELIABLE EVERYWHERE, and that was measured too, on 2026-08-03
// and again on 2026-08-25. On Windows, Go asks for the reparse point rather
// than for what it points at when O_EXCL is set, and the create then reports
// "the file exists" about a file that is not there whenever any part of the
// path is a symbolic link or a junction. A directory reached through a link is
// an ordinary setup - a redirected workspace, a mounted scratch disk - and this
// tool supports it on purpose.
// O_EXCL WAS NOT RELIABLE EVERYWHERE, measured on 2026-08-03 and again on
// 2026-08-25 (O47): on Windows the create reported "the file exists" about a
// file that was not there whenever any part of the path was a symbolic link or
// a junction. This function answered that with a second create, without
// O_EXCL, whenever os.Lstat found nothing - and that second create truncated
// whatever another process put under the name between the two calls, the
// first of the three windows in O252.
//
// So a refusal is believed only when something really is there, and the
// question that settles it is os.Lstat rather than os.Stat: a link pointing at
// nothing is a name being taken, whatever it points at. Where O_EXCL works this
// is exactly O_EXCL. Where it lies, this is what the tool did before it, and
// what is left is the window between the two calls - narrow, on that one
// platform, and smaller than the whole of the door it replaces.
// It is gone since 2026-09-29, because the compiler moved underneath it.
// Measured that day on Go 1.27.0, the oldest compiler go.mod admits: O_EXCL
// through a junction creates the file and says nothing false. A symbolic link
// could not be measured on this machine, which grants no right to make one,
// and TestADirectoryReachedThroughALinkStillWorks asks exactly that question on
// the runners, which do.
//
// A refusal is still read with os.Lstat, and only to choose its words: a name
// that holds something - even a link pointing at nothing - gets the sentence
// about a name already in use, and any other failure is the create's own.
func CreateNew(path string, perm os.FileMode) (*os.File, error) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
if err == nil {
return f, nil
}

_, lookErr := os.Lstat(path)
if lookErr == nil {
// Something is genuinely there. This is the refusal that matters, and
// it is the one the escapes above went round.
if _, lookErr := os.Lstat(path); lookErr == nil {
return nil, &NameTakenError{Path: path, Err: err}
}
if !errors.Is(lookErr, fs.ErrNotExist) {
// A name we cannot ask about is not a name we may write over. Reported
// as the create failed rather than as the look did, because the create
// is what the caller asked for.
return nil, err
}

// OpenOwn opens for writing a file this tool made earlier and closed, and
// refuses when the name holds anything else by now.
//
// The question is asked of the opened file rather than of the name, and that
// order is the point. A name looked at and then opened can be swapped for a
// link in between, and the open follows the link wherever it points - so the
// look proves nothing. The open file is what the bytes would reach, so it is
// the one to ask.
//
// Here beside CreateNew rather than with the writers, because it is the other
// half of the same claim: CreateNew makes the file, and this is the only way
// back into it.
func OpenOwn(path string, own os.FileInfo) (*os.File, error) {
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
return nil, err
}
now, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}

return os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if !sameWrite(own, now) {
_ = f.Close()
return nil, &NotOursError{Path: path}
}
return f, nil
}

// NameTakenError is refusing to write under a name something else is holding.
Expand Down
92 changes: 92 additions & 0 deletions internal/core/publish.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package core

import (
"errors"
"io/fs"
"os"
)

// Publish puts a finished file under its final name, and refuses when anything
// already holds that name.
//
// Every file this tool finishes goes through here: each generated file, the
// manifest, the instructions beside it and a recipe written by "preset eject
// -o". They are all written whole under a temporary name first, so a run cut
// short never leaves half a file under a real one, and this is the step that
// gives the finished bytes their name.
//
// It replaced os.Rename, and the reason is O252, measured on 2026-09-29 with a
// second writer spinning on the same name (docs/O252-ANALIZA-2026-09-29.md).
// A rename REPLACES whatever is at the destination. So a file somebody else put
// under that name while we were writing - after our look, before our rename -
// was destroyed without a word. The measured loss was 2544 of 3000 names on
// NTFS, around 1900 of 1950 on ext4, tmpfs and overlay, and 999 of 1000 on an
// exFAT pendrive. The same writer through this function lost none on any of
// them, because the system settles "is the name free" and "take it" as one
// operation and says fs.ErrExist otherwise.
//
// The call differs by system, and the files beside this one each name theirs:
// MoveFileEx without MOVEFILE_REPLACE_EXISTING on Windows, renameat2 with
// RENAME_NOREPLACE on Linux, renamex_np with RENAME_EXCL on macOS. A hard link
// was the first idea and it is the fallback rather than the rule, because FAT
// and exFAT have none - measured on Linux, where link answers EPERM, and on
// Windows, where it answers "Incorrect function" even for a name that IS taken.
//
// Two fallbacks stand behind the call, for a filesystem that does not know it -
// some network shares, macOS on a FAT stick. Neither was reached on any disk
// measured, which is why each is taken only for an answer that means "this
// filesystem cannot do that" and never for any other failure:
//
// 1. a hard link to the final name, then the temporary name removed. It
// refuses a taken name the same way, where links exist at all.
// 2. the rename this replaced, after a look at the final name. That is the
// window O252 was about, and it is kept rather than refused because the
// alternative is a tool that cannot write to a stick at all. It is
// narrower than it was: it is only ever the last resort.
//
// The temporary name is ours in every case: on success it is gone, and on a
// refusal it is left for the caller, which created it and removes it.
func Publish(tmp, final string) error {
return PublishThrough(tmp, final, renameNoReplace, os.Link)
}

// PublishThrough is Publish with its two system calls passed in.
//
// It exists for one reason: the fallbacks are reached only on a filesystem this
// project's runners do not have, and a fallback nothing can reach is a defence
// nothing can turn red. So a guard walks the chain here on an ordinary disk,
// handing it calls that answer "unsupported". Publish is the only caller in the
// program, and it passes the real ones.
func PublishThrough(tmp, final string, noReplace, link func(string, string) error) error {
err := noReplace(tmp, final)
if err == nil {
return nil
}
if !errors.Is(err, errors.ErrUnsupported) {
return &os.LinkError{Op: "publish", Old: tmp, New: final, Err: err}
}

err = link(tmp, final)
if err == nil {
// The finished file has both names for a moment. The final one is
// what counts, so a failure to drop the other is not a failure of
// the publish - it leaves one of our temporary names behind, which
// verify already names as ours.
_ = os.Remove(tmp)
return nil
}
if !linkUnsupported(err) {
return &os.LinkError{Op: "publish", Old: tmp, New: final, Err: err}
}

// "I could not look" is not "nothing is there", and this is the one step
// that replaces what it lands on - so only a look that found nothing lets
// it through (the rule of review 2026-08-23, 3.7c, asked again on #150).
switch _, lookErr := os.Lstat(final); {
case lookErr == nil:
return &os.LinkError{Op: "publish", Old: tmp, New: final, Err: fs.ErrExist}
case !errors.Is(lookErr, fs.ErrNotExist):
return &os.LinkError{Op: "publish", Old: tmp, New: final, Err: lookErr}
}
return os.Rename(tmp, final)
}
34 changes: 34 additions & 0 deletions internal/core/publish_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package core

import (
"errors"
"fmt"

"golang.org/x/sys/unix"
)

// renameNoReplace is renamex_np with RENAME_EXCL: the rename happens only when
// nothing is at the destination, and EEXIST otherwise.
//
// NOT MEASURED on a Mac - there is none to measure on (O153). The flag is
// documented for APFS and HFS+, and a filesystem that does not know it answers
// ENOTSUP, which sends Publish to its fallbacks rather than failing the write.
func renameNoReplace(from, to string) error {
err := unix.RenamexNp(from, to, unix.RENAME_EXCL)
if err == nil {
return nil
}
if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EOPNOTSUPP) || errors.Is(err, unix.EINVAL) {
return fmt.Errorf("%w: %w", errors.ErrUnsupported, err)
}
return err
}

// linkUnsupported says whether a hard link failed because this filesystem has
// none. NOT MEASURED on a Mac.
func linkUnsupported(err error) bool {
return errors.Is(err, errors.ErrUnsupported) ||
errors.Is(err, unix.EPERM) ||
errors.Is(err, unix.ENOTSUP) ||
errors.Is(err, unix.EOPNOTSUPP)
}
Loading
Loading