diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbdddb..b56e49b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ because it turns other people's test suites red. ### Changed +- **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 @@ -500,6 +507,12 @@ because it turns other people's test suites red. ### Fixed +- **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 -w` on 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, diff --git a/internal/core/limits.go b/internal/core/limits.go index dca2e64..f2c6673 100644 --- a/internal/core/limits.go +++ b/internal/core/limits.go @@ -91,7 +91,9 @@ const MaxPlanBytes = 2 << 30 // Every file goes out under a temporary name and is renamed into place, so the // output directory never holds a half written file. The full name is // ".tfg-partial-", with the process id there because two -// runs writing into one directory used to meet on the temporary file. +// runs writing into one directory used to meet on the temporary file. A final +// name too long for that to fit in MaxNameBytes is shortened in front of the +// marker, never after it - SiblingName, since 2026-09-24 (O239). // // The marker is declared here rather than built at the point of use, because // two parts of the tool have to agree on it: the engine writes it, and the @@ -124,7 +126,8 @@ func IsPartialName(name string) bool { // somebody - the manifest being rewritten over an earlier one, or the recipe // that "recipe fmt -w" is formatting in place. The full name is // ".tfg-writing", with no process id, because the name is claimed -// exclusively rather than made unique. +// exclusively rather than made unique. Shortened in front of the marker the +// same way when it would not fit, by SiblingPath. // // Declared here beside PartialMarker on 2026-09-06, and the argument for it is // the one already written above: two parts of the tool have to agree on the diff --git a/internal/core/replace.go b/internal/core/replace.go index 195d294..75cca1c 100644 --- a/internal/core/replace.go +++ b/internal/core/replace.go @@ -52,7 +52,9 @@ func ReplaceFile(path string, content []byte) error { return err } - tmp := path + writingSuffix + // A sibling rather than a plain join, so a recipe whose own name is up to + // the length every system stores can still be formatted in place (O239). + tmp := SiblingPath(path, writingSuffix) if err := writeWhole(tmp, content, mode); err != nil { // Only what this call created is taken away. A refusal from CreateNew // means the name was already somebody's - a leftover from an diff --git a/internal/core/sibling.go b/internal/core/sibling.go new file mode 100644 index 0000000..afd3732 --- /dev/null +++ b/internal/core/sibling.go @@ -0,0 +1,75 @@ +package core + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "unicode/utf8" +) + +// MaxNameBytes is the longest file name, in bytes of UTF-8, that every system +// this tool writes on will store. +// +// The three file systems count three different things, each up to 255: ext4 +// counts bytes, NTFS counts UTF-16 units and APFS counts characters. A name +// never has fewer bytes than either of the other two, so a name that fits in +// 255 bytes fits on all three. Measured on 2026-09-24 with one recipe on each +// (docs/O239-LONG-NAMES-2026-09-24.md): a name of 200 CJK characters, 604 +// bytes, was stored on Windows and on macOS and cannot be on Linux. +const MaxNameBytes = 255 + +// siblingTagDigits is how much of the digest of the whole name a shortened +// sibling carries, in hex digits. Sixty four bits, which puts the chance that +// two names of one run share a sibling at about three in a million million +// for the largest preset there is, 10 040 files. +const siblingTagDigits = 16 + +// SiblingName is the name of a file that stands beside another one while it is +// being written: the name with suffix after it, whenever that fits. +// +// It exists because the plain join did not always fit. Every file of a run is +// written as ".tfg-partial-" and renamed afterwards, and that suffix +// is eighteen bytes, so a name from 238 bytes up was one no file system would +// take in its temporary form - on every system, while the name itself was +// perfectly legal (O239, measured on 2026-09-24 on Windows, Linux and macOS). +// +// When the join is longer than MaxNameBytes, the name is cut to whole +// characters and followed by "~" and the start of the SHA-256 of the whole +// name, and then the suffix. The digest is what keeps two long names that +// begin alike apart - a name template numbering files at the end of a long +// name gives exactly that, inside one run. The suffix stays last in both +// forms, so what an interrupted run leaves behind is recognised by +// IsPartialName and IsWritingName either way. +// +// A name short enough comes back as the plain join, byte for byte, so nothing +// changed for any name this tool could write before. +func SiblingName(name, suffix string) string { + if len(name)+len(suffix) <= MaxNameBytes { + return name + suffix + } + sum := sha256.Sum256([]byte(name)) + tag := "~" + hex.EncodeToString(sum[:])[:siblingTagDigits] + return cutToWholeCharacters(name, MaxNameBytes-len(suffix)-len(tag)) + tag + suffix +} + +// SiblingPath is SiblingName for a path: the sibling in the same directory, +// with the directory spelt exactly as it was given. +func SiblingPath(path, suffix string) string { + dir, name := filepath.Split(path) + return dir + SiblingName(name, suffix) +} + +// cutToWholeCharacters is the longest start of s that is no longer than n bytes +// and does not end part way through a character. +func cutToWholeCharacters(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) <= n { + return s + } + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3734183..976d74f 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -766,173 +766,6 @@ func renderName(t *Target, d format.Descriptor, index int) (string, error) { return name, nil } -// checkFileName keeps a name a name. -// -// A name carrying a path escapes the directory the run was pointed at. A -// recipe travels between teams by design, so "../../something" in a file -// somebody sent over would write outside the directory its reader chose - and -// the free space check, the collision check and cleanup all work on the -// directory, so none of them would be looking in the right place. -// -// Both separators are refused on every system, not just the local one. A name -// holding a backslash is legal on Linux and cannot exist on Windows, and a -// recipe that only works on the machine it was written on is not portable. -func checkFileName(setting, where, name string) error { - switch { - case name == "": - return &RecipeError{Setting: setting, Detail: fmt.Sprintf("%s produces a file with no name", where)} - - case strings.ContainsAny(name, `/\`): - return &RecipeError{Setting: setting, - Detail: fmt.Sprintf("%s produces the name %q, which is a path rather than a file name", where, name), - Because: "names stay inside the output directory, and a separator is refused on every system so that a recipe works everywhere", - Remedy: "Choose the directory with the output setting instead"} - - // A colon, on every system, for the same reason as a separator. - // - // Windows reads it as the start of an alternate data stream, so - // "AB:c.txt" names a stream called c.txt inside a file called AB. - // Measured on 2026-08-04: the run reported the file as not produced and - // ended with the partial code, and an empty file called AB was left in the - // directory anyway - not in the manifest, reported by verify as something - // nobody asked for, and beyond the reach of cleanup for good. A single - // letter in front of it is read as a drive instead, which is how the same - // recipe came to be accepted on Linux and refused on Windows. - // - // Legal in a name on Linux and macOS, and refused there too. A recipe - // travels between machines by design, and one that quietly leaves debris on - // somebody else's is worse than one refused on all of them. - case strings.Contains(name, ":"): - return &RecipeError{Setting: setting, - Detail: fmt.Sprintf("%s produces the name %q, which holds a colon", where, name), - Because: "Windows reads that as a drive or as an alternate data stream rather than as part of the name, so the file arrives called something else or not at all. It is refused on every system so that a recipe means one thing everywhere", - Remedy: "Take the colon out, or ask for the file inside an archive where the name survives"} - - // Characters Windows will not put in a file name, refused on every system - // for the same reason as the separator and the colon above. - // - // Measured on 2026-08-25, on each of <>"|?* and on a name holding a tab. - // All seven planned cleanly, --dry-run answered "1 file in 1 target" and - // exit 0, and the run then failed that one file with the system's own - // words: "open a"|?*` - -// firstForbidden is the first character of a name that Windows will not store, -// or zero when there is none. -// -// The first rather than all of them, because a refusal naming one character a -// reader can find beats a list they have to compare against their own name. -func firstForbidden(name string) rune { - for _, r := range name { - // Below the space, which is every control character. Windows refuses - // the whole range, and a name holding one is unreadable on any system - // - a tab in a file name is a name nobody can type back. - if r < 0x20 || strings.ContainsRune(forbiddenChars, r) { - return r - } - } - return 0 -} - -// describeForbidden names a character in a way somebody can act on. A control -// character has nothing to show, so it is given as its number instead of being -// printed into the middle of a sentence where it would do what it says. -func describeForbidden(r rune) string { - if r < 0x20 { - return fmt.Sprintf("a control character, U+%04X", r) - } - return fmt.Sprintf("the character %q", string(r)) -} - func runID(seed int64) string { h := sha256.Sum256([]byte(fmt.Sprintf("run:%d", seed))) return "run_" + hex.EncodeToString(h[:5]) diff --git a/internal/engine/filename.go b/internal/engine/filename.go new file mode 100644 index 0000000..f8803de --- /dev/null +++ b/internal/engine/filename.go @@ -0,0 +1,198 @@ +package engine + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The rules a file name has to pass before anything is written under it. +// Moved out of engine.go on 2026-09-24, when the length rule (O239) took +// that file past its ceiling: this is the part of planning that judges a +// name, and nothing else in it does. + +// checkFileName keeps a name a name. +// +// A name carrying a path escapes the directory the run was pointed at. A +// recipe travels between teams by design, so "../../something" in a file +// somebody sent over would write outside the directory its reader chose - and +// the free space check, the collision check and cleanup all work on the +// directory, so none of them would be looking in the right place. +// +// Both separators are refused on every system, not just the local one. A name +// holding a backslash is legal on Linux and cannot exist on Windows, and a +// recipe that only works on the machine it was written on is not portable. +func checkFileName(setting, where, name string) error { + switch { + case name == "": + return &RecipeError{Setting: setting, Detail: fmt.Sprintf("%s produces a file with no name", where)} + + case strings.ContainsAny(name, `/\`): + return &RecipeError{Setting: setting, + Detail: fmt.Sprintf("%s produces the name %q, which is a path rather than a file name", where, name), + Because: "names stay inside the output directory, and a separator is refused on every system so that a recipe works everywhere", + Remedy: "Choose the directory with the output setting instead"} + + // A colon, on every system, for the same reason as a separator. + // + // Windows reads it as the start of an alternate data stream, so + // "AB:c.txt" names a stream called c.txt inside a file called AB. + // Measured on 2026-08-04: the run reported the file as not produced and + // ended with the partial code, and an empty file called AB was left in the + // directory anyway - not in the manifest, reported by verify as something + // nobody asked for, and beyond the reach of cleanup for good. A single + // letter in front of it is read as a drive instead, which is how the same + // recipe came to be accepted on Linux and refused on Windows. + // + // Legal in a name on Linux and macOS, and refused there too. A recipe + // travels between machines by design, and one that quietly leaves debris on + // somebody else's is worse than one refused on all of them. + case strings.Contains(name, ":"): + return &RecipeError{Setting: setting, + Detail: fmt.Sprintf("%s produces the name %q, which holds a colon", where, name), + Because: "Windows reads that as a drive or as an alternate data stream rather than as part of the name, so the file arrives called something else or not at all. It is refused on every system so that a recipe means one thing everywhere", + Remedy: "Take the colon out, or ask for the file inside an archive where the name survives"} + + // Characters Windows will not put in a file name, refused on every system + // for the same reason as the separator and the colon above. + // + // Measured on 2026-08-25, on each of <>"|?* and on a name holding a tab. + // All seven planned cleanly, --dry-run answered "1 file in 1 target" and + // exit 0, and the run then failed that one file with the system's own + // words: "open a core.MaxNameBytes: + return &RecipeError{Setting: setting, + Detail: fmt.Sprintf("%s produces the name %q, which is %d bytes long", where, name, len(name)), + Because: fmt.Sprintf("Linux stores at most %d bytes in a file name, and a letter outside ASCII takes two to four of them. "+ + "It is refused on every system so that a recipe means one thing everywhere", core.MaxNameBytes), + Remedy: fmt.Sprintf("Shorten it to %d bytes or fewer, or ask for the file inside an archive where the name survives", core.MaxNameBytes)} + + // One reserved device name, and one only. + // + // The folklore list has twenty two - CON, PRN, AUX, COM1 to COM9, LPT1 to + // LPT9 - and every one of them was measured on 2026-08-25 rather than + // remembered, on two editions: Windows 11 Pro 26200 and Windows Server + // 2025 build 26100. con, con.txt, prn, aux, com1, com1.bin, lpt1 and + // conin$ each came back an ordinary file that verify then passed, on both. + // Refusing that list would refuse con.pdf, a name both systems store + // perfectly well, which is a rule written from memory doing damage. + // + // NUL is the exception on both, and it is the one worth catching, because + // it does not fail. The write succeeds and the bytes go nowhere, which is + // the silence rule broken as completely as it can be - a manifest + // describing a file that was never on the disk. The run is refused a step + // later today, by the collision check finding something at the path, and + // that is safe but tells the reader to remove a file nobody can remove. + // + // The bare name only. An extension saves it - nul.txt is an ordinary file + // on both editions - so this is not the "any extension" rule the folklore + // describes either. + case strings.EqualFold(name, "nul"): + return &RecipeError{Setting: setting, + Detail: fmt.Sprintf("%s produces the name %q, which names the null device on Windows rather than a file", where, name), + Because: "writing there succeeds and the bytes go nowhere, so the run would record a file that is not on the disk. It is refused on every system so that a recipe means one thing everywhere", + Remedy: "Give it an extension, nul.txt is an ordinary name, or choose another one"} + + case name == "." || name == "..": + return &RecipeError{Setting: setting, Detail: fmt.Sprintf( + "%s produces the name %q, which names a directory rather than a file", where, name)} + + // A name Windows stores under a different name than the one it was given. + // Refused on every system for the same reason a separator is: a recipe that + // only works on the machine it was written on is not portable. + // + // Measured on 2026-08-03, and it is the silence rule broken rather than a + // portability nicety. "--name trailing." finished with exit code 0, the + // file landed as "trailing", and the manifest recorded "trailing." - so the + // run described a file that was not there under that name, and "tfg verify" + // on the tool's own output failed with exit code 7 a second later. + // + // Producing such a name deliberately is a real test case and it belongs to + // the name laboratory, which writes it into an archive rather than onto the + // host filesystem for exactly this reason. See D10. + case strings.HasSuffix(name, ".") || strings.HasSuffix(name, " "): + return &RecipeError{Setting: setting, + Detail: fmt.Sprintf("%s produces the name %q, which ends in a dot or a space", where, name), + Because: "Windows stores such a name without it, so the file on disk would not be the file the manifest describes and verify would report both", + Remedy: "Take the last character off, or ask for the file inside an archive where the name survives"} + + // Judged the same way on every system, like the separator above. Using + // filepath here asks the machine this build runs on, and the answer + // differs: measured on 2026-08-04, "a:b.txt" was accepted on Linux and + // refused on Windows from one recipe, so a fixture set written on one + // machine failed on the next. That is the failure this rule exists to + // prevent, arriving through the rule itself. + case filepath.IsAbs(name) || core.HasVolumeName(name): + return &RecipeError{Setting: setting, + Detail: fmt.Sprintf("%s produces the absolute path %q", where, name), + Because: "a recipe carries no absolute paths, because then it only works on the machine it was written on", + Remedy: "Choose the directory with the output setting instead"} + } + return nil +} + +// forbiddenChars are the printable characters Windows refuses in a file name. +// +// The separator and the colon are not here. They are refused above with their +// own sentences, because what goes wrong with them is not "the file is not +// written" but something worse and worth its own explanation - a name that +// leaves the output directory, and a name Windows reads as a drive or as a +// stream inside another file. +const forbiddenChars = `<>"|?*` + +// firstForbidden is the first character of a name that Windows will not store, +// or zero when there is none. +// +// The first rather than all of them, because a refusal naming one character a +// reader can find beats a list they have to compare against their own name. +func firstForbidden(name string) rune { + for _, r := range name { + // Below the space, which is every control character. Windows refuses + // the whole range, and a name holding one is unreadable on any system + // - a tab in a file name is a name nobody can type back. + if r < 0x20 || strings.ContainsRune(forbiddenChars, r) { + return r + } + } + return 0 +} + +// describeForbidden names a character in a way somebody can act on. A control +// character has nothing to show, so it is given as its number instead of being +// printed into the middle of a sentence where it would do what it says. +func describeForbidden(r rune) string { + if r < 0x20 { + return fmt.Sprintf("a control character, U+%04X", r) + } + return fmt.Sprintf("the character %q", string(r)) +} diff --git a/internal/engine/preflight.go b/internal/engine/preflight.go index c8ad74b..f25608f 100644 --- a/internal/engine/preflight.go +++ b/internal/engine/preflight.go @@ -189,8 +189,12 @@ func tempPathFor(outDir, name string) string { // tempNameFor is that name without the directory in front of it, which is what // a directory listing gives back. Split out so the listing and the path cannot // disagree about how the temporary name is spelt. +// +// core.SiblingName rather than a plain join since 2026-09-24: the join made a +// name from 238 bytes up too long for any file system in its temporary form, +// so a legal name was never written (O239). func tempNameFor(name string) string { - return fmt.Sprintf("%s%s%d", name, core.PartialMarker, os.Getpid()) + return core.SiblingName(name, fmt.Sprintf("%s%d", core.PartialMarker, os.Getpid())) } func exists(path string) bool { diff --git a/internal/guard/codeshape_test.go b/internal/guard/codeshape_test.go index dcae9a5..f4bb72a 100644 --- a/internal/guard/codeshape_test.go +++ b/internal/guard/codeshape_test.go @@ -50,7 +50,10 @@ const ( // Lowered from 433 on 2026-09-09: the tfg preset command moved out of // preset.go into presetcmd.go, leaving the machinery behind generate // --preset on its own. The longest file is engine.go again. - longestFile = 408 + // Lowered from 408 on 2026-09-24: the rules a file name has to pass moved + // out of engine.go into filename.go when the length rule (O239) took it + // past the ceiling. The longest file is cli/generate.go now. + longestFile = 407 // Depth answers a different question than length, and it is the better // question of the two. A hundred line function that is flat reads top to diff --git a/internal/guard/longnames_test.go b/internal/guard/longnames_test.go new file mode 100644 index 0000000..0734a00 --- /dev/null +++ b/internal/guard/longnames_test.go @@ -0,0 +1,195 @@ +package guard + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" +) + +// Long file names, O239 (docs/O239-LONG-NAMES-2026-09-24.md). +// +// Every file is written under a temporary name beside it and renamed once it +// is whole. Until 2026-09-24 that name was the file's name with eighteen bytes +// after it, so a name from 238 bytes up was never written on any system, while +// every file system took the name itself. And a name over 255 bytes was +// written on Windows and macOS and could not be on Linux - one recipe, two +// answers. Every guard of the write path used short names, so neither had a +// guard at all. + +// TestANameAsLongAsEverySystemStoresIsWritten writes a name of 255 bytes and +// one of 253 bytes in 87 characters, and asks verify about both. +func TestANameAsLongAsEverySystemStoresIsWritten(t *testing.T) { + suffix := fmt.Sprintf("%s%d", core.PartialMarker, os.Getpid()) + for _, name := range []string{ + strings.Repeat("a", core.MaxNameBytes-len(".txt")) + ".txt", + strings.Repeat("日", 83) + ".txt", + } { + // The case in question: the name fits, and its temporary name joined + // the plain way would not. + if len(name) > core.MaxNameBytes || len(name)+len(suffix) <= core.MaxNameBytes { + t.Fatalf("a name of %d bytes is not the case this guard asks about", len(name)) + } + dir := t.TempDir() + code, _, errOut := run(t, "generate", "--format", "txt", "--size", "1kb", "--count", "1", "--name", name, "--out", dir) + if code != cli.ExitOK { + t.Errorf("a name of %d bytes, which every system stores, ended with exit %d:\n%s", len(name), code, errOut) + continue + } + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("a name of %d bytes is not on the disk: %v", len(name), err) + } + if code, _, errOut := run(t, "verify", filepath.Join(dir, engine.DefaultManifestName)); code != cli.ExitOK { + t.Errorf("verify on a run with a name of %d bytes gave exit %d:\n%s", len(name), code, errOut) + } + } +} + +// TestTwoLongNamesThatBeginAlikeGetTwoSiblings asks the one place a name beside +// a file is made. A short name keeps the plain join, so what an ordinary run +// leaves behind looks the same as ever. Two long names sharing their first 240 +// bytes - a numbered template at the end of a long name - get two different +// siblings, each short enough, each whole characters, each ending in the +// marker that says what it is. +func TestTwoLongNamesThatBeginAlikeGetTwoSiblings(t *testing.T) { + // The longest process id a system hands out, a Windows DWORD. + suffix := core.PartialMarker + "4294967295" + if short := "report.txt"; core.SiblingName(short, suffix) != short+suffix { + t.Errorf("a short name's sibling is %q rather than the plain join, so every leftover of an ordinary run "+ + "changed its shape", core.SiblingName(short, suffix)) + } + + // Three bytes to a character, so a cut made by the byte lands inside one. + begin := strings.Repeat("日", 80) + seen := map[string]string{} + for _, name := range []string{begin + "_0001.txt", begin + "_0002.txt"} { + if len(name)+len(suffix) <= core.MaxNameBytes { + t.Fatalf("a name of %d bytes is short enough to keep the plain join, so nothing is asked", len(name)) + } + got := core.SiblingName(name, suffix) + if len(got) > core.MaxNameBytes { + t.Errorf("the sibling of a name of %d bytes is %d bytes, longer than every system stores", len(name), len(got)) + } + if !utf8.ValidString(got) { + t.Errorf("the sibling %q was cut part way through a character", got) + } + if !strings.HasSuffix(got, suffix) || !core.IsPartialName(got) { + t.Errorf("the sibling %q does not end in the marker, so a leftover would not be known for what it is", got) + } + if other, taken := seen[got]; taken { + t.Errorf("%q and %q share the sibling %q, so two files of one run meet on it", other, name, got) + } + seen[got] = name + } +} + +// TestALeftoverUnderAShortenedNameIsNamedForWhatItIs puts both kinds of +// leftover, in the shortened form, into the output of a run - a file being +// produced and a record being replaced - and asks verify what they are. +func TestALeftoverUnderAShortenedNameIsNamedForWhatItIs(t *testing.T) { + long := strings.Repeat("a", 250) + ".txt" + for _, marker := range []string{core.PartialMarker + "99999", core.WritingMarker} { + out, mf := generated(t) + name := core.SiblingName(long, marker) + if name == long+marker { + t.Fatalf("the leftover %q is in the plain form, so the shortened one is not asked about", name) + } + if err := os.WriteFile(filepath.Join(out, name), []byte("half a file\n"), 0o644); err != nil { + t.Fatalf("writing the leftover: %v", err) + } + code, _, errOut := run(t, "verify", mf) + if code != cli.ExitVerify { + t.Errorf("exit %d, expected %d - a leftover is still a difference:\n%s", code, cli.ExitVerify, errOut) + } + // The kind in the report's first column, the same word for both + // markers. The sentence under it differs between them. + if strings.Contains(errOut, "extra "+name) || !strings.Contains(errOut, "leftover "+name) { + t.Errorf("a leftover in the shortened form, %q, is not reported as a leftover:\n%s", name, errOut) + } + } +} + +// TestANameLongerThanEverySystemStoresIsRefusedBeforeAnythingIsWritten asks for +// a name one byte over, and one of 86 characters that is 262 bytes, and then +// for one exactly at the line. +func TestANameLongerThanEverySystemStoresIsRefusedBeforeAnythingIsWritten(t *testing.T) { + for _, name := range []string{ + strings.Repeat("a", core.MaxNameBytes+1-len(".txt")) + ".txt", + strings.Repeat("日", 86) + ".txt", + } { + if len(name) <= core.MaxNameBytes { + t.Fatalf("a name of %d bytes is not over the line", len(name)) + } + dir := t.TempDir() + code, _, errOut := run(t, "generate", "--format", "txt", "--size", "1kb", "--count", "1", "--name", name, "--out", dir) + if code != cli.ExitRecipe { + t.Errorf("a name of %d bytes ended with exit %d rather than a refusal of the recipe:\n%s", len(name), code, errOut) + } + if !strings.Contains(errOut, fmt.Sprintf("%d bytes", len(name))) || !strings.Contains(errOut, fmt.Sprint(core.MaxNameBytes)) { + t.Errorf("the refusal does not say how long the name is and what the limit is:\n%s", errOut) + } + if entries, err := os.ReadDir(dir); err != nil || len(entries) != 0 { + t.Errorf("a refused name left %d entries in the output directory (%v)", len(entries), err) + } + } + + atTheLine := strings.Repeat("a", core.MaxNameBytes-len(".txt")) + ".txt" + if code, _, errOut := run(t, "generate", "--format", "txt", "--size", "1kb", "--count", "1", + "--name", atTheLine, "--out", t.TempDir(), "--dry-run"); code != cli.ExitOK { + t.Errorf("a name of exactly %d bytes was refused, a byte too early:\n%s", len(atTheLine), errOut) + } +} + +// TestAManifestNamedAsLongAsEverySystemStoresIsSaved names the record of a run +// 250 bytes long, which the plain join with its temporary marker put over the +// line. +func TestAManifestNamedAsLongAsEverySystemStoresIsSaved(t *testing.T) { + name := strings.Repeat("m", 245) + ".json" + if len(name) > core.MaxNameBytes || len(name)+len(core.WritingMarker) <= core.MaxNameBytes { + t.Fatalf("a manifest name of %d bytes is not the case this guard asks about", len(name)) + } + dir := t.TempDir() + out := filepath.Join(dir, "out") + path := writeRecipe(t, dir, `version: 1 +targets: + - id: a + format: txt + count: 1 + size: 1kb +output: + dir: `+filepath.ToSlash(out)+` + manifest: `+name+` +`) + if code, _, errOut := run(t, "generate", path); code != cli.ExitOK { + t.Fatalf("a run with a manifest name of %d bytes ended with exit %d:\n%s", len(name), code, errOut) + } + if _, err := os.Stat(filepath.Join(out, name)); err != nil { + t.Errorf("the manifest of %d bytes is not on the disk: %v", len(name), err) + } +} + +// TestARecipeNamedAsLongAsEverySystemStoresCanBeReplacedInPlace is the same +// question for "recipe fmt -w", asked of the one function it replaces a file +// through. +func TestARecipeNamedAsLongAsEverySystemStoresCanBeReplacedInPlace(t *testing.T) { + path := filepath.Join(t.TempDir(), strings.Repeat("r", 245)+".yaml") + if base := filepath.Base(path); len(base)+len(core.WritingMarker) <= core.MaxNameBytes { + t.Fatalf("a recipe name of %d bytes is not the case this guard asks about", len(base)) + } + if err := os.WriteFile(path, []byte("version: 1\n"), 0o644); err != nil { + t.Fatalf("writing: %v", err) + } + want := "version: 1\ntargets: []\n" + if err := core.ReplaceFile(path, []byte(want)); err != nil { + t.Fatalf("a recipe named %d bytes long could not be replaced in place: %v", len(filepath.Base(path)), err) + } + if body, err := os.ReadFile(path); err != nil || string(body) != want { + t.Errorf("after the replace the recipe reads %q (%v)", body, err) + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 4748d35..5ddf54b 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -852,7 +852,10 @@ func (m *Manifest) writeOver(path string) error { // literal until 2026-09-06, which is how verify came to report our own half // written manifest as "extra" - the reading side recognised the other // marker and had never been told about this one. - tmp := path + core.WritingMarker + // + // A sibling rather than a plain join, so a manifest named up to the length + // every system stores can be written under its temporary name (O239). + tmp := core.SiblingPath(path, core.WritingMarker) // Claimed rather than created, and core.CreateNew says why: this name sits // in a directory the run does not own, nothing else in the tool checks it, // and a create that is not exclusive follows whatever is at the name.