From 20368cd28aad2bed90ab04c45c8ee66e26018956 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 20:33:05 +0200 Subject: [PATCH 01/12] format: every format names its media type, from the IANA registry A mail message carries each attachment under a type, and the eml format that follows needs the one each format really has. Twenty seven values come from the IANA registry, wav and webm from WHATWG MIME Sniffing, because the registry lists neither. The source sits beside each value. Nothing writes the type into a file yet, so no byte of any format moves. The completeness guard now asks every format for one. Co-Authored-By: Claude Opus 5.5 --- internal/format/avif/avif.go | 1 + internal/format/bmp/bmp.go | 1 + internal/format/csvfile/csv.go | 1 + internal/format/docx/docx.go | 1 + internal/format/format.go | 16 +++++++++++++++- internal/format/geojson/geojson.go | 1 + internal/format/gif/gif.go | 1 + internal/format/htmlfile/html.go | 1 + internal/format/ico/ico.go | 1 + internal/format/jpg/jpg.go | 1 + internal/format/jsonfile/json.go | 1 + internal/format/jxl/jxl.go | 1 + internal/format/logfile/log.go | 1 + internal/format/md/md.go | 1 + internal/format/mp4/mp4.go | 1 + internal/format/pdf/pdf.go | 1 + internal/format/png/png.go | 1 + internal/format/pptx/pptx.go | 1 + internal/format/svgfile/svg.go | 1 + internal/format/targz/targz.go | 1 + internal/format/tiff/tiff.go | 1 + internal/format/tomlfile/toml.go | 1 + internal/format/txt/txt.go | 1 + internal/format/wav/wav.go | 1 + internal/format/webm/webm.go | 1 + internal/format/webp/webp.go | 1 + internal/format/xlsx/xlsx.go | 1 + internal/format/xmlfile/xml.go | 1 + internal/format/yamlfile/yaml.go | 1 + internal/format/zip/zip.go | 1 + internal/guard/registry_test.go | 17 +++++++++++++++++ 31 files changed, 61 insertions(+), 1 deletion(-) diff --git a/internal/format/avif/avif.go b/internal/format/avif/avif.go index 6e00a82a..dc1230e1 100644 --- a/internal/format/avif/avif.go +++ b/internal/format/avif/avif.go @@ -102,6 +102,7 @@ func init() { ID: "avif", Name: "AV1 Image File Format", Extension: ".avif", + MediaType: "image/avif", // IANA, Alliance for Open Media Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/bmp/bmp.go b/internal/format/bmp/bmp.go index 08e5a8b6..69451a8d 100644 --- a/internal/format/bmp/bmp.go +++ b/internal/format/bmp/bmp.go @@ -61,6 +61,7 @@ func init() { ID: "bmp", Name: "Windows Bitmap", Extension: ".bmp", + MediaType: "image/bmp", // IANA, RFC 7903 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/csvfile/csv.go b/internal/format/csvfile/csv.go index 9d5898b8..778ac5d5 100644 --- a/internal/format/csvfile/csv.go +++ b/internal/format/csvfile/csv.go @@ -105,6 +105,7 @@ func init() { ID: "csv", Name: "Comma-Separated Values", Extension: ".csv", + MediaType: "text/csv", // IANA, RFC 4180 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/docx/docx.go b/internal/format/docx/docx.go index 5fbd9258..81daa80a 100644 --- a/internal/format/docx/docx.go +++ b/internal/format/docx/docx.go @@ -46,6 +46,7 @@ func init() { ID: "docx", Name: "Word (Office Open XML)", Extension: ".docx", + MediaType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // IANA Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/format.go b/internal/format/format.go index 662bd803..6e197080 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -235,7 +235,21 @@ type Descriptor struct { // 2026-09-24 as a widening. Recorded in docs/FORMAT-NAMES-2026-09-24.md. Name string - Extension string + Extension string + + // MediaType is what a message, an upload form or a server calls this kind + // of file - application/pdf, image/png - written the way the IANA registry + // spells it, or the WHATWG MIME Sniffing standard for the two formats the + // registry does not list (wav and webm, checked 2026-10-07). + // + // It exists because a mail message carries its attachments under a type, + // and a test that checks the type against the extension needs the one + // the format really has. Nothing else writes it into a file today, so + // declaring or correcting one changes no byte of any other format (D11). + // The source of each value sits beside it in the format's own package. + // docs/EML-2026-10-07.md section 10.3. + MediaType string + Fidelity Fidelity Determinism Determinism MinBytes int64 diff --git a/internal/format/geojson/geojson.go b/internal/format/geojson/geojson.go index feda9d40..317eefc0 100644 --- a/internal/format/geojson/geojson.go +++ b/internal/format/geojson/geojson.go @@ -31,6 +31,7 @@ func init() { ID: "geojson", Name: "GeoJSON", Extension: ".geojson", + MediaType: "application/geo+json", // IANA, RFC 7946 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/gif/gif.go b/internal/format/gif/gif.go index 3a6c1900..36404673 100644 --- a/internal/format/gif/gif.go +++ b/internal/format/gif/gif.go @@ -103,6 +103,7 @@ func init() { ID: "gif", Name: "Graphics Interchange Format", Extension: ".gif", + MediaType: "image/gif", // IANA, RFC 2046 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go index 745ec27e..33bda56f 100644 --- a/internal/format/htmlfile/html.go +++ b/internal/format/htmlfile/html.go @@ -121,6 +121,7 @@ func init() { ID: "html", Name: "HyperText Markup Language", Extension: ".html", + MediaType: "text/html", // IANA, W3C Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/ico/ico.go b/internal/format/ico/ico.go index 3d3a1240..d61c5e63 100644 --- a/internal/format/ico/ico.go +++ b/internal/format/ico/ico.go @@ -60,6 +60,7 @@ func init() { ID: "ico", Name: "Windows Icon", Extension: ".ico", + MediaType: "image/vnd.microsoft.icon", // IANA Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/jpg/jpg.go b/internal/format/jpg/jpg.go index c73a001a..49c5e78f 100644 --- a/internal/format/jpg/jpg.go +++ b/internal/format/jpg/jpg.go @@ -72,6 +72,7 @@ func init() { ID: "jpg", Name: "JPEG", Extension: ".jpg", + MediaType: "image/jpeg", // IANA, RFC 2046 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/jsonfile/json.go b/internal/format/jsonfile/json.go index 78286763..29761d72 100644 --- a/internal/format/jsonfile/json.go +++ b/internal/format/jsonfile/json.go @@ -63,6 +63,7 @@ func init() { ID: "json", Name: "JavaScript Object Notation", Extension: ".json", + MediaType: "application/json", // IANA, RFC 8259 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/jxl/jxl.go b/internal/format/jxl/jxl.go index e9fb826d..d37132eb 100644 --- a/internal/format/jxl/jxl.go +++ b/internal/format/jxl/jxl.go @@ -135,6 +135,7 @@ func init() { ID: "jxl", Name: "JPEG XL", Extension: ".jxl", + MediaType: "image/jxl", // IANA, ISO/IEC JTC 1 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/logfile/log.go b/internal/format/logfile/log.go index 3b95fb8e..8d1eda8f 100644 --- a/internal/format/logfile/log.go +++ b/internal/format/logfile/log.go @@ -50,6 +50,7 @@ func init() { ID: "log", Name: "Server and application log", Extension: ".log", + MediaType: "text/plain", // IANA, RFC 2046 - a log is plain text Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/md/md.go b/internal/format/md/md.go index 92baaa17..6d9f883c 100644 --- a/internal/format/md/md.go +++ b/internal/format/md/md.go @@ -46,6 +46,7 @@ func init() { ID: "md", Name: "Markdown", Extension: ".md", + MediaType: "text/markdown", // IANA, RFC 7763 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/mp4/mp4.go b/internal/format/mp4/mp4.go index 6cdb03f8..3eaa8886 100644 --- a/internal/format/mp4/mp4.go +++ b/internal/format/mp4/mp4.go @@ -49,6 +49,7 @@ func init() { ID: id, Name: "MP4 video", Extension: ".mp4", + MediaType: "video/mp4", // IANA, RFC 4337 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/pdf/pdf.go b/internal/format/pdf/pdf.go index e47abe2d..864ed24b 100644 --- a/internal/format/pdf/pdf.go +++ b/internal/format/pdf/pdf.go @@ -20,6 +20,7 @@ func init() { ID: "pdf", Name: "Portable Document Format", Extension: ".pdf", + MediaType: "application/pdf", // IANA, RFC 8118 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, MinBytes: minimumBytes(), diff --git a/internal/format/png/png.go b/internal/format/png/png.go index 54097fc2..bf2ac5cc 100644 --- a/internal/format/png/png.go +++ b/internal/format/png/png.go @@ -75,6 +75,7 @@ func init() { ID: "png", Name: "Portable Network Graphics", Extension: ".png", + MediaType: "image/png", // IANA, W3C Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/pptx/pptx.go b/internal/format/pptx/pptx.go index 3c9e7066..7c7af6bf 100644 --- a/internal/format/pptx/pptx.go +++ b/internal/format/pptx/pptx.go @@ -56,6 +56,7 @@ func init() { ID: "pptx", Name: "PowerPoint (Office Open XML)", Extension: ".pptx", + MediaType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", // IANA Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/svgfile/svg.go b/internal/format/svgfile/svg.go index 819d765a..8ef54923 100644 --- a/internal/format/svgfile/svg.go +++ b/internal/format/svgfile/svg.go @@ -190,6 +190,7 @@ func init() { ID: "svg", Name: "Scalable Vector Graphics", Extension: ".svg", + MediaType: "image/svg+xml", // IANA, W3C Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/targz/targz.go b/internal/format/targz/targz.go index 4db1c0dd..51fa0b47 100644 --- a/internal/format/targz/targz.go +++ b/internal/format/targz/targz.go @@ -95,6 +95,7 @@ func init() { ID: "targz", Name: "tar + gzip", Extension: ".tar.gz", + MediaType: "application/gzip", // IANA, RFC 6713 - the outer layer is what a reader meets first Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, MinBytes: minimumBytes(), diff --git a/internal/format/tiff/tiff.go b/internal/format/tiff/tiff.go index 640c6b0e..30a36cdf 100644 --- a/internal/format/tiff/tiff.go +++ b/internal/format/tiff/tiff.go @@ -106,6 +106,7 @@ func init() { ID: "tiff", Name: "Tagged Image File Format", Extension: ".tiff", + MediaType: "image/tiff", // IANA, RFC 3302 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/tomlfile/toml.go b/internal/format/tomlfile/toml.go index b16f0ff2..ff433935 100644 --- a/internal/format/tomlfile/toml.go +++ b/internal/format/tomlfile/toml.go @@ -75,6 +75,7 @@ func init() { ID: "toml", Name: "TOML", Extension: ".toml", + MediaType: "application/toml", // IANA Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/txt/txt.go b/internal/format/txt/txt.go index f5bc661c..b867b907 100644 --- a/internal/format/txt/txt.go +++ b/internal/format/txt/txt.go @@ -36,6 +36,7 @@ func init() { ID: "txt", Name: "Plain text", Extension: ".txt", + MediaType: "text/plain", // IANA, RFC 2046 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/wav/wav.go b/internal/format/wav/wav.go index 5bc99c71..fc4c6f12 100644 --- a/internal/format/wav/wav.go +++ b/internal/format/wav/wav.go @@ -81,6 +81,7 @@ func init() { ID: "wav", Name: "Waveform Audio", Extension: ".wav", + MediaType: "audio/wave", // not in the IANA registry, WHATWG MIME Sniffing Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, MinBytes: baseSize, diff --git a/internal/format/webm/webm.go b/internal/format/webm/webm.go index 4564d533..6fec9019 100644 --- a/internal/format/webm/webm.go +++ b/internal/format/webm/webm.go @@ -39,6 +39,7 @@ func init() { ID: id, Name: "WebM video", Extension: ".webm", + MediaType: "video/webm", // not in the IANA registry, WHATWG MIME Sniffing Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/webp/webp.go b/internal/format/webp/webp.go index 5a05e9c4..0732e8f9 100644 --- a/internal/format/webp/webp.go +++ b/internal/format/webp/webp.go @@ -81,6 +81,7 @@ func init() { ID: "webp", Name: "WebP", Extension: ".webp", + MediaType: "image/webp", // IANA, RFC 9649 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/xlsx/xlsx.go b/internal/format/xlsx/xlsx.go index e2288f06..df7174f0 100644 --- a/internal/format/xlsx/xlsx.go +++ b/internal/format/xlsx/xlsx.go @@ -67,6 +67,7 @@ func init() { ID: "xlsx", Name: "Excel (Office Open XML)", Extension: ".xlsx", + MediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // IANA Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/xmlfile/xml.go b/internal/format/xmlfile/xml.go index b20605ad..72bcf384 100644 --- a/internal/format/xmlfile/xml.go +++ b/internal/format/xmlfile/xml.go @@ -88,6 +88,7 @@ func init() { ID: "xml", Name: "Extensible Markup Language", Extension: ".xml", + MediaType: "application/xml", // IANA, RFC 7303 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/yamlfile/yaml.go b/internal/format/yamlfile/yaml.go index 7d4d4752..19a8c75b 100644 --- a/internal/format/yamlfile/yaml.go +++ b/internal/format/yamlfile/yaml.go @@ -104,6 +104,7 @@ func init() { ID: "yaml", Name: "YAML", Extension: ".yaml", + MediaType: "application/yaml", // IANA, RFC 9512 Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, diff --git a/internal/format/zip/zip.go b/internal/format/zip/zip.go index 73a4f687..8ccd8bbe 100644 --- a/internal/format/zip/zip.go +++ b/internal/format/zip/zip.go @@ -63,6 +63,7 @@ func init() { ID: "zip", Name: "ZIP", Extension: ".zip", + MediaType: "application/zip", // IANA Fidelity: format.FidelityFull, Determinism: format.DeterminismByte, MinBytes: minimumBytes(), diff --git a/internal/guard/registry_test.go b/internal/guard/registry_test.go index 84540cdf..dd4dcda0 100644 --- a/internal/guard/registry_test.go +++ b/internal/guard/registry_test.go @@ -36,6 +36,7 @@ func TestEveryFormatDeclaresTheFullSet(t *testing.T) { if !strings.HasPrefix(d.Extension, ".") { t.Errorf("extension %q does not start with a dot", d.Extension) } + mediaTypeIsSpelledOut(t, d.MediaType) if d.Generator == nil { t.Error("no generator") } @@ -121,6 +122,22 @@ func nameShowsAsItIs(t *testing.T, name string) { } } +// mediaTypeShape is a type and a subtype in lower case, the way the IANA +// registry lists them. Lower case because a type is compared without case and +// written in one, and a mail message carrying Application/PDF beside every +// other sender's application/pdf is a difference a test would trip over. +var mediaTypeShape = regexp.MustCompile(`^(application|audio|font|image|message|model|text|video)/[a-z0-9][a-z0-9.+-]*$`) + +// mediaTypeIsSpelledOut asks for the media type a mail message puts on an +// attachment of this format. Empty is the failure that matters: a message would +// carry the file under no type, and nothing else would notice. +func mediaTypeIsSpelledOut(t *testing.T, mediaType string) { + t.Helper() + if !mediaTypeShape.MatchString(mediaType) { + t.Errorf("the media type %q is not a type and a subtype in lower case, such as application/pdf", mediaType) + } +} + // The registry is the source of the confirmed minimum and the format document // keeps an approximate table beside it. This is what stops the two drifting. // From bca8e14b8091f6e9469bffbd141a3e1d672d79a5 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 20:38:46 +0200 Subject: [PATCH 02/12] format: a container names its members, so a container need not be an archive The rules for ordering contents - both doors at once refused, never inside itself, one ceiling on both - move behind GroupsThrough, which takes the container's own words. The archives keep entries, and the mail format that follows will say attachments. The guards stop reading "entries" by name and stop taking every container for an archive: the ceiling guard reads the count key from the registry, and the compression guards ask about the containers that offer compression. No byte of zip or tar.gz moves. Co-Authored-By: Claude Opus 5.5 --- internal/format/archive/archive.go | 56 +++++++++++++++++++---- internal/format/format.go | 15 ++++++ internal/format/targz/targz.go | 1 + internal/format/zip/zip.go | 1 + internal/guard/archivecompression_test.go | 22 +++++++-- internal/guard/containerentries_test.go | 18 ++++---- internal/guard/registry_test.go | 25 ++++++++++ 7 files changed, 115 insertions(+), 23 deletions(-) diff --git a/internal/format/archive/archive.go b/internal/format/archive/archive.go index 35d426b9..3302bfd7 100644 --- a/internal/format/archive/archive.go +++ b/internal/format/archive/archive.go @@ -251,8 +251,46 @@ func Axes(names ...string) []format.Property { // is what an archive may not hold, since an archive inside an archive needs a // depth limit that does not exist yet. func Groups(id string, r format.Request) ([]format.Content, error) { + return GroupsThrough(id, r, archiveDoor) +} + +// ArchiveMembers is how an archive names what it holds, for its descriptor. +var ArchiveMembers = format.Members{Count: Entries, Format: EntryFormat, Size: EntrySize} + +// MaxMembers is the most files a container holds, through either door. A +// container that is not an archive declares its own count with this ceiling, +// so the two doors of every container stop at one number. +const MaxMembers = maxEntries + +// DefaultSizeText is how big each file inside is when nothing says, written +// the way a declaration shows it. +const DefaultSizeText = defaultSizeText + +// Door is how a container names its contents, for GroupsThrough. +// +// The rules are one set for every container - both doors at once refused, the +// container never inside itself, one ceiling on both - and what differs is the +// words: which keys, how many files when nothing says, and the noun in the +// sentence that asks for fewer. +type Door struct { + Members format.Members + DefaultCount int + // Fewer is the remedy when more files are asked for than MaxMembers. + Fewer core.Said +} + +// archiveDoor is the door both archives share. +var archiveDoor = Door{ + Members: ArchiveMembers, + DefaultCount: defaultEntries, + Fewer: core.Says("format.AskForEntriesOrFewer", "Ask for %d entries or fewer.", core.A("MaxEntries", maxEntries)), +} + +// GroupsThrough is Groups for a container that names its contents its own way. +func GroupsThrough(id string, r format.Request, door Door) ([]format.Content, error) { + keys := door.Members var stated []string - for _, key := range []string{Entries, EntryFormat, EntrySize} { + for _, key := range []string{keys.Count, keys.Format, keys.Size} { if _, ok := r.Properties[key]; ok { stated = append(stated, key) } @@ -262,7 +300,7 @@ func Groups(id string, r format.Request) ([]format.Content, error) { // which is a legitimate thing to ask for, and it is a different statement // from saying nothing at all. if r.Contains != nil { - return fromContains(id, r, stated) + return fromContains(id, r, stated, door.Fewer) } if r.SizeFromContents { @@ -271,19 +309,19 @@ func Groups(id string, r format.Request) ([]format.Content, error) { return nil, core.Defect(fmt.Errorf("%s: the size was left to the contents and there are none", id)) } - entries, err := intProperty(id, r.Properties, Entries, defaultEntries, 0, maxEntries) + entries, err := intProperty(id, r.Properties, keys.Count, door.DefaultCount, 0, maxEntries) if err != nil { return nil, err } // Sizes in properties use the same syntax as --size. Anything else would // mean entry_size=200kb failing while size=200kb works, which nobody would // predict. - entrySize, err := sizeProperty(id, r.Properties, EntrySize, defaultSize) + entrySize, err := sizeProperty(id, r.Properties, keys.Size, defaultSize) if err != nil { return nil, err } entryFmt := DefaultFormat - if v, ok := r.Properties[EntryFormat]; ok && v != "" { + if v, ok := r.Properties[keys.Format]; ok && v != "" { entryFmt = v } if entryFmt == id { @@ -297,7 +335,7 @@ func Groups(id string, r format.Request) ([]format.Content, error) { // Split out of Groups so the branching stays under the ceiling the shape gates // hold, and because it is one subject: everything here is about a contains list // and nothing about it reads a property. -func fromContains(id string, r format.Request, stated []string) ([]format.Content, error) { +func fromContains(id string, r format.Request, stated []string, fewer core.Said) ([]format.Content, error) { if len(stated) > 0 { return nil, &format.ContentsConflictError{Format: id, Keys: stated} } @@ -319,7 +357,7 @@ func fromContains(id string, r format.Request, stated []string) ([]format.Conten // to the exit code: a plain error here would have landed on 1 while // the same request through entries lands on 4, which is the same // disagreement one level further down. - return nil, tooMany(id, "contains", asked) + return nil, tooMany(id, "contains", asked, fewer) } return r.Contains, nil } @@ -328,13 +366,13 @@ func fromContains(id string, r format.Request, stated []string) ([]format.Conten // // TestBothWaysOfAskingForEntriesShareOneCeiling compares the reason the two // produce, and before this it compared two sentences somebody had typed twice. -func tooMany(id, key string, asked int) *format.PropertyValueError { +func tooMany(id, key string, asked int, fewer core.Said) *format.PropertyValueError { return &format.PropertyValueError{ Format: id, Key: key, Value: strconv.Itoa(asked), Reason: core.Says("format.ItTakesAWholeNumberFrom", "it takes a whole number from 0 to %d", core.A("MaxEntries", maxEntries)), - Remedy: core.Says("format.AskForEntriesOrFewer", "Ask for %d entries or fewer.", core.A("MaxEntries", maxEntries)), + Remedy: fewer, } } diff --git a/internal/format/format.go b/internal/format/format.go index 6e197080..678d2cce 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -302,6 +302,21 @@ type Descriptor struct { // that is the silence rule broken in the worst way - the file looks right // and the test suite believes it. Container bool + + // Members names the three settings that say, for files of one format, + // what a container holds - how many, of which format, how big. The zero + // value for every format that is not a container. + // + // Declared because containers name them differently: an archive holds + // entries and a mail message holds attachments. Until eml arrived on + // 2026-10-07 every container was an archive, and the guards asking both + // ways of ordering contents for one ceiling read "entries" by name. + Members Members +} + +// Members is how a container names the settings for contents of one format. +type Members struct { + Count, Format, Size string } // NotAContainerError is contains asked of a format that holds nothing. diff --git a/internal/format/targz/targz.go b/internal/format/targz/targz.go index 51fa0b47..a098a1f6 100644 --- a/internal/format/targz/targz.go +++ b/internal/format/targz/targz.go @@ -146,6 +146,7 @@ func init() { }, }, Container: true, + Members: archive.ArchiveMembers, GeneratorVersion: generatorVersion, Generator: generator{}, }) diff --git a/internal/format/zip/zip.go b/internal/format/zip/zip.go index 8ccd8bbe..109d2248 100644 --- a/internal/format/zip/zip.go +++ b/internal/format/zip/zip.go @@ -82,6 +82,7 @@ func init() { archive.Compression, archive.Depth, archive.DirectoryEntries, archive.Password, archive.Encryption), Container: true, + Members: archive.ArchiveMembers, GeneratorVersion: generatorVersion, Generator: generator{}, }) diff --git a/internal/guard/archivecompression_test.go b/internal/guard/archivecompression_test.go index 6644fda6..3d953d97 100644 --- a/internal/guard/archivecompression_test.go +++ b/internal/guard/archivecompression_test.go @@ -32,7 +32,7 @@ func TestACompressedArchiveStillHitsTheSizeToTheByte(t *testing.T) { levels := []string{archive.CompressFast, archive.CompressDefault, archive.CompressBest} checked := 0 for _, d := range format.All() { - if !d.Container { + if !compresses(d) { continue } for _, level := range levels { @@ -82,7 +82,7 @@ func TestACompressedArchiveStillHitsTheSizeToTheByte(t *testing.T) { // to more than the file holds. func TestAskingForCompressionActuallyCompresses(t *testing.T) { for _, d := range format.All() { - if !d.Container { + if !compresses(d) { continue } build := func(level string) []byte { @@ -155,7 +155,7 @@ func TestAskingForCompressionActuallyCompresses(t *testing.T) { // cannot tell whether to drop the compression or give the archive a size. func TestCompressionWithASizeFromTheContentsIsRefusedNamingBoth(t *testing.T) { for _, d := range format.All() { - if !d.Container { + if !compresses(d) { continue } _, err := d.Generator.Plan(format.Request{ @@ -183,7 +183,7 @@ func TestCompressionWithASizeFromTheContentsIsRefusedNamingBoth(t *testing.T) { func TestCompressionWithAPasswordIsRefusedNamingBoth(t *testing.T) { asked := 0 for _, d := range format.All() { - if !d.Container { + if !compresses(d) { continue } if !offers(d, archive.Password) { @@ -218,7 +218,7 @@ func TestCompressionWithAPasswordIsRefusedNamingBoth(t *testing.T) { // a single recipe changing - untouchable rule 3. func TestAnArchiveNobodyAskedToCompressIsStored(t *testing.T) { for _, d := range format.All() { - if !d.Container { + if !compresses(d) { continue } plain, err := d.Generator.Plan(format.Request{Bytes: 1 << 20, Seed: 7741, Label: true}) @@ -274,3 +274,15 @@ func offers(d format.Descriptor, name string) bool { } return false } + +// compresses is whether a container offers the compression setting. Asked +// rather than taken from Container, because a container is not always an +// archive - a mail message holds files too and squeezes none of them. +func compresses(d format.Descriptor) bool { + for _, p := range d.Properties { + if p.Name == archive.Compression { + return true + } + } + return false +} diff --git a/internal/guard/containerentries_test.go b/internal/guard/containerentries_test.go index 7bf2692a..4d3bdba9 100644 --- a/internal/guard/containerentries_test.go +++ b/internal/guard/containerentries_test.go @@ -44,7 +44,7 @@ func TestBothWaysOfAskingForEntriesShareOneCeiling(t *testing.T) { // value, and the generator's own reading of it is a second line the // engine never reaches. Asking Plan here would compare the two doors at // a layer neither surface uses. - propBad := d.CheckEachProperty(map[string]string{"entries": itoa(over)}) + propBad := d.CheckEachProperty(map[string]string{d.Members.Count: itoa(over)}) var viaProperty error if len(propBad) > 0 { viaProperty = propBad[0] @@ -56,10 +56,10 @@ func TestBothWaysOfAskingForEntriesShareOneCeiling(t *testing.T) { _, viaContains := d.Generator.Plan(req) if viaProperty == nil { - t.Errorf("%s: entries=%d was accepted and the declared ceiling is %d", d.ID, over, ceiling) + t.Errorf("%s: %s=%d was accepted and the declared ceiling is %d", d.ID, d.Members.Count, over, ceiling) } if viaContains == nil { - t.Errorf("%s: contains asking for %d entries was accepted and the declared ceiling is %d", + t.Errorf("%s: contains asking for %d files was accepted and the declared ceiling is %d", d.ID, over, ceiling) continue } @@ -68,11 +68,11 @@ func TestBothWaysOfAskingForEntriesShareOneCeiling(t *testing.T) { // decides the exit code and what a window marks. var propErr, containsErr *format.PropertyValueError if !errors.As(viaProperty, &propErr) { - t.Errorf("%s: the entries refusal is %T, not a refusal about a property value", d.ID, viaProperty) + t.Errorf("%s: the %s refusal is %T, not a refusal about a property value", d.ID, d.Members.Count, viaProperty) } if !errors.As(viaContains, &containsErr) { t.Errorf("%s: the contains refusal is %T, not a refusal about a property value - "+ - "so it lands on a different exit code than the same request through entries", d.ID, viaContains) + "so it lands on a different exit code than the same request through %s", d.ID, viaContains, d.Members.Count) continue } @@ -80,8 +80,8 @@ func TestBothWaysOfAskingForEntriesShareOneCeiling(t *testing.T) { // the reason rather than the whole sentence, because the key differs // on purpose - one says entries and the other says contains. if propErr != nil && propErr.Reason.String() != containsErr.Reason.String() { - t.Errorf("%s: the two doors give different reasons\n entries: %s\n contains: %s", - d.ID, propErr.Reason, containsErr.Reason) + t.Errorf("%s: the two doors give different reasons\n %s: %s\n contains: %s", + d.ID, d.Members.Count, propErr.Reason, containsErr.Reason) } if !strings.Contains(containsErr.Reason.String(), itoa(ceiling)) { t.Errorf("%s: the contains refusal does not name the ceiling %d: %q", @@ -135,10 +135,10 @@ func planRequestWith(props map[string]string) format.Request { func declaredCeiling(t *testing.T, d format.Descriptor) int { t.Helper() for _, p := range d.Properties { - if p.Name == "entries" { + if p.Name == d.Members.Count { return int(p.Max) } } - t.Fatalf("%s is a container with no declared entries property", d.ID) + t.Fatalf("%s is a container with no declared %q property", d.ID, d.Members.Count) return 0 } diff --git a/internal/guard/registry_test.go b/internal/guard/registry_test.go index dd4dcda0..bebb89a9 100644 --- a/internal/guard/registry_test.go +++ b/internal/guard/registry_test.go @@ -37,6 +37,7 @@ func TestEveryFormatDeclaresTheFullSet(t *testing.T) { t.Errorf("extension %q does not start with a dot", d.Extension) } mediaTypeIsSpelledOut(t, d.MediaType) + membersAreDeclared(t, d) if d.Generator == nil { t.Error("no generator") } @@ -138,6 +139,30 @@ func mediaTypeIsSpelledOut(t *testing.T, mediaType string) { } } +// membersAreDeclared asks a container for the three settings that order its +// contents in one group, each one declared as a setting it takes, and asks +// every other format for none. A container naming a key it does not declare +// would refuse its own short way of being asked. +func membersAreDeclared(t *testing.T, d format.Descriptor) { + t.Helper() + keys := []string{d.Members.Count, d.Members.Format, d.Members.Size} + if !d.Container { + if d.Members != (format.Members{}) { + t.Errorf("names its members %v and holds no other files", keys) + } + return + } + declared := map[string]bool{} + for _, p := range d.Properties { + declared[p.Name] = true + } + for _, k := range keys { + if !declared[k] { + t.Errorf("is a container whose members setting %q is not a setting it declares", k) + } + } +} + // The registry is the source of the confirmed minimum and the format document // keeps an approximate table beside it. This is what stops the two drifting. // From 42df3179d1baf7859f4563c7d80087a5ec8d20c1 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 20:56:12 +0200 Subject: [PATCH 03/12] eml: the generator - text, html or both, attachments in base64, names four ways, headers three ways WIP on the branch: the oracle, the guards and the texts follow. Co-Authored-By: Claude Opus 5.5 --- internal/format/all/all.go | 1 + internal/format/archive/archive.go | 15 ++ internal/format/eml/attach.go | 263 +++++++++++++++++++++++++++++ internal/format/eml/eml.go | 207 +++++++++++++++++++++++ internal/format/eml/header.go | 143 ++++++++++++++++ internal/format/eml/message.go | 186 ++++++++++++++++++++ internal/format/eml/settings.go | 230 +++++++++++++++++++++++++ internal/format/eml/text.go | 137 +++++++++++++++ internal/format/targz/targz.go | 16 +- internal/format/zip/zip.go | 16 +- internal/guard/layers_test.go | 3 + 11 files changed, 1187 insertions(+), 30 deletions(-) create mode 100644 internal/format/eml/attach.go create mode 100644 internal/format/eml/eml.go create mode 100644 internal/format/eml/header.go create mode 100644 internal/format/eml/message.go create mode 100644 internal/format/eml/settings.go create mode 100644 internal/format/eml/text.go diff --git a/internal/format/all/all.go b/internal/format/all/all.go index cfbbb057..0f7adfb0 100644 --- a/internal/format/all/all.go +++ b/internal/format/all/all.go @@ -12,6 +12,7 @@ import ( _ "github.com/donislawdev/TestingFilesGenerator/internal/format/bmp" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/csvfile" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/docx" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/eml" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/geojson" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/gif" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/htmlfile" diff --git a/internal/format/archive/archive.go b/internal/format/archive/archive.go index 3302bfd7..9dc609f3 100644 --- a/internal/format/archive/archive.go +++ b/internal/format/archive/archive.go @@ -403,3 +403,18 @@ func intProperty(id string, props map[string]string, key string, fallback, min, } return n, nil } + +// ContentSummary is what a container holds, in the manifest, so a test can +// assert on it without opening the file. One copy for every container, so the +// key names a test reads cannot differ between them. +func ContentSummary(groups []format.Content) []map[string]any { + out := make([]map[string]any, 0, len(groups)) + for _, g := range groups { + out = append(out, map[string]any{ + "format": g.Format, + "count": g.Count, + "bytes": g.Bytes, + }) + } + return out +} diff --git a/internal/format/eml/attach.go b/internal/format/eml/attach.go new file mode 100644 index 00000000..2f93b8c5 --- /dev/null +++ b/internal/format/eml/attach.go @@ -0,0 +1,263 @@ +package eml + +import ( + "encoding/base64" + "fmt" + "io" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +const ( + // base64Line is the longest line of base64 RFC 2045 allows. + base64Line = 76 + + // maxMessage is the largest message this format plans. Far above anything + // a disk holds, and far enough below the int64 every size here is counted + // in that adding the parts up cannot wrap round. + maxMessage = int64(1) << 60 + + // namePrefixWide is what the name of an attached file starts with when + // headers asks for names outside ASCII. + namePrefixWide = "Załącznik_日本語_" + + // percentRun is how much of a name RFC 2231 percent encoding puts on one + // line before it continues on the next, short enough that the line stays + // under 78 characters and cut never inside a %XX. + percentRun = 48 +) + +// child is one attached file, planned before a byte is written. +type child struct { + desc format.Descriptor + plan format.Plan + // name is the file's name as a person reads it. + name string + // contentType and disposition are the two header values, parameters and + // folding included, worked out once while planning. + contentType, disposition string + // encoded is how many bytes the file takes in base64 with its line + // breaks - not the header lines above it, and not the line break the next + // boundary brings. + encoded int64 +} + +// planChildren plans every file the message carries. +// +// The files are real files of another format, each valid on its own, with +// seeds derived from the message's seed and their place in it (RC4), numbered +// per format across the whole message - the archives' rules, for their +// reasons (zip/children.go). +func planChildren(r format.Request, groups []format.Content, s settings) ([]child, error) { + total := 0 + for _, g := range groups { + total += g.Count + } + out := make([]child, 0, total) + numbered := map[string]int{} + var sum int64 + index := 0 + for _, g := range groups { + desc, err := format.Get(g.Format) + if err != nil { + return nil, err + } + for i := 0; i < g.Count; i++ { + cp, err := desc.Generator.Plan(format.Request{Bytes: g.Bytes, Seed: core.FileSeed(r.Seed, index), Label: r.Label}) + if err != nil { + return nil, core.Refuse(core.Says("eml.TheAttachedFileCannotBe", "eml: the attached %s file cannot be made: %w", core.A("Format", g.Format), core.A("Err", err))) + } + numbered[g.Format]++ + c := child{desc: desc, plan: cp, name: fileName(s, g.Format, numbered[g.Format], desc.Extension)} + if cp.Bytes > maxMessage { + return nil, tooLarge(cp.Bytes) + } + c.encoded = encodedLength(cp.Bytes, len(s.eol)) + if sum += c.encoded; sum > maxMessage { + return nil, tooLarge(sum) + } + c.contentType, c.disposition = nameHeaders(s, desc.MediaType, c.name) + out = append(out, c) + index++ + } + } + return out, nil +} + +// tooLarge refuses attachments that add up past maxMessage. +func tooLarge(asked int64) error { + return &format.AboveMaximumError{ + Format: "EML", + Requested: asked, + Maximum: maxMessage, + Reason: core.Says("eml.AMessageThisLargeCannotBe", "a message this large cannot be counted to the byte in this build"), + Hint: core.Says("eml.AskForSmallerAttachments", "Ask for fewer or smaller attachments."), + } +} + +// encodedLength is how many bytes n bytes take in base64 lines of 76 +// characters, the breaks between them included and none after the last. +func encodedLength(n int64, eol int) int64 { + chars := (n + 2) / 3 * 4 + if chars == 0 { + return 0 + } + return chars + (chars-1)/base64Line*int64(eol) +} + +// fileName is the name of an attached file: the format, its number among the +// files of that format, and the extension - with a prefix in Polish and +// Japanese when headers asks for names outside ASCII. +func fileName(s settings, id string, n int, ext string) string { + name := fmt.Sprintf("%s_%04d%s", id, n, ext) + if s.headers != ASCII { + name = namePrefixWide + name + } + return name +} + +// nameHeaders are the Content-Type and Content-Disposition values of one +// attached file, with its name where filename_style puts it and written the +// way headers says. Each parameter goes on a line of its own, so no name pushes +// a line past 78 characters. docs/EML-2026-10-07.md section 10.3 has the table. +func nameHeaders(s settings, mediaType, name string) (contentType, disposition string) { + var inType, inDisposition []string + switch s.names { + case RFC2231: + inDisposition = rfc2231Params(s, name) + case RFC2047: + inType = []string{quotedParam(s, "name", name)} + inDisposition = []string{quotedParam(s, "filename", name)} + case Both: + inType = []string{quotedParam(s, "name", name)} + inDisposition = rfc2231Params(s, name) + default: + inType = []string{quotedParam(s, "name", name)} + } + return withParams(s, mediaType, inType), withParams(s, "attachment", inDisposition) +} + +// withParams is a header value with its parameters, each folded onto a line +// of its own. +func withParams(s settings, value string, params []string) string { + for _, p := range params { + value += ";" + s.eol + " " + p + } + return value +} + +// quotedParam is a parameter in quotes: the name as it is when it is ASCII or +// when utf8 asks for it raw, and RFC 2047 encoded words, folded, when encoded +// asks for them. Words inside quotes are what RFC 2047 forbids and what +// filename_style rfc2047 exists to hand a tester. +func quotedParam(s settings, key, name string) string { + return key + `="` + phrase(s, name) + `"` +} + +// rfc2231Params is the name as RFC 2231 writes it: in quotes when it is ASCII +// or raw, and percent encoded in UTF-8 otherwise, continued over numbered +// parameters when it is long. +func rfc2231Params(s settings, name string) []string { + if s.headers != Encoded || isASCII(name) { + return []string{`filename="` + name + `"`} + } + encoded := percentEncoded(name) + if len(encoded) <= percentRun { + return []string{"filename*=UTF-8''" + encoded} + } + var out []string + for n := 0; encoded != ""; n++ { + cut := min(percentRun, len(encoded)) + // Never inside a %XX - step back to before the percent sign. + if i := strings.LastIndexByte(encoded[:cut], '%'); i > cut-3 && cut < len(encoded) { + cut = i + } + prefix := "" + if n == 0 { + prefix = "UTF-8''" + } + out = append(out, fmt.Sprintf("filename*%d*=%s%s", n, prefix, encoded[:cut])) + encoded = encoded[cut:] + } + return out +} + +// percentEncoded is RFC 2231 section 7's encoding: the characters a parameter +// may carry as they are, every other byte as %XX. +func percentEncoded(text string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(text); i++ { + c := text[i] + if c > ' ' && c < 0x7f && !strings.ContainsRune(`*'%()<>@,;:\"/[]?=`, rune(c)) { + b.WriteByte(c) + continue + } + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&15]) + } + return b.String() +} + +// attach writes one attached file: its header lines, a blank line and the +// file itself in base64, made by its own generator as it is written. +func (m *message) attach(o *out, c *child) { + eol := m.s.eol + o.put("Content-Type: ", c.contentType, eol) + o.put("Content-Disposition: ", c.disposition, eol) + o.put("Content-Transfer-Encoding: base64", eol, eol) + if o.w == nil || o.err != nil { + o.n += c.encoded + return + } + o.lines = lines{w: o.w, eol: eol} + enc := base64.NewEncoder(base64.StdEncoding, &o.lines) + if err := c.desc.Generator.Write(o.ctx, enc, c.plan); err != nil { + o.err = err + return + } + if err := enc.Close(); err != nil { + o.err = err + return + } + o.n += o.lines.n + if o.lines.n != c.encoded { + o.err = core.Defect(fmt.Errorf("eml: the attached %s file came out as %d B of base64 where its plan gives %d B", + c.desc.ID, o.lines.n, c.encoded)) + } +} + +// lines cuts base64 into lines of 76 characters. The break goes in front of +// the next character rather than after the 76th, so the last line never ends +// in one - the boundary that follows brings its own. +type lines struct { + w io.Writer + eol string + col int + n int64 +} + +func (l *lines) Write(p []byte) (int, error) { + written := 0 + for len(p) > 0 { + if l.col == base64Line { + if _, err := io.WriteString(l.w, l.eol); err != nil { + return written, err + } + l.n += int64(len(l.eol)) + l.col = 0 + } + k := min(base64Line-l.col, len(p)) + if err := core.WriteAll(l.w, p[:k]); err != nil { + return written, err + } + l.col += k + l.n += int64(k) + written += k + p = p[k:] + } + return written, nil +} diff --git a/internal/format/eml/eml.go b/internal/format/eml/eml.go new file mode 100644 index 00000000..d43e60a8 --- /dev/null +++ b/internal/format/eml/eml.go @@ -0,0 +1,207 @@ +// Package eml generates mail messages (RFC 5322 with MIME): a text, in plain +// text, HTML or both, and real files of other formats attached to it in +// base64. +// +// What makes this format worth its place is what a system that takes mail in +// does with it - a helpdesk turning messages into tickets, a gateway scanning +// attachments, an archive keeping them. The size a limit is checked against +// is the message, a third larger than the files it carries. The names of the +// files can be written four ways, and readers do not agree on two of them. +// docs/EML-2026-10-07.md has the measurement this is built on. +package eml + +import ( + "context" + "fmt" + "io" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/archive" +) + +const generatorVersion = "1" + +func init() { + format.Register(format.Descriptor{ + ID: "eml", + Name: "Email message", + Extension: ".eml", + MediaType: "message/rfc822", // IANA, RFC 2046 + Fidelity: format.FidelityFull, + Determinism: format.DeterminismByte, + + // The header lines and an empty text, for the default settings. Every + // other combination answers for itself through the same refusal, the + // way json and geojson declare their default layout's minimum. + MinBytes: minimumBytes(), + + Padding: format.PaddingChannel{ + Name: "the words of the text, in every text part", + // Before the attached files when there are any, at the end when + // there are none - so inside, where the format decides. + Where: format.PlacementInside, + Capacity: 0, + }, + Label: format.LabelVisible, + Oracle: "python-email", + Properties: properties(), + Container: true, + Members: members, + GeneratorVersion: generatorVersion, + Generator: generator{}, + }) +} + +type generator struct{} + +// minimumBytes is the smallest message the default settings make, measured by +// the code that writes it. +func minimumBytes() int64 { + s, err := parse(nil) + if err != nil { + panic(fmt.Sprintf("eml: the default settings do not parse: %v", err)) + } + return newMessage(s, 0, nil).size() +} + +func (generator) Plan(r format.Request) (format.Plan, error) { + s, err := parse(r.Properties) + if err != nil { + return format.Plan{}, err + } + groups, err := archive.GroupsThrough("eml", r, door) + if err != nil { + return format.Plan{}, err + } + children, err := planChildren(r, groups, s) + if err != nil { + return format.Plan{}, err + } + m := newMessage(s, r.Seed, children) + + var notes []format.Note + target := r.Bytes + if r.SizeFromContents { + target = settle(m, r) + } else if notes, err = fit(m, r); err != nil { + return format.Plan{}, err + } + share(m, target-m.size()) + if got := m.size(); got != target { + return format.Plan{}, core.Defect(fmt.Errorf("eml: the message comes to %d B and %d B were planned", got, target)) + } + p := describe(m, groups, target) + p.Notes = notes + return p, nil +} + +// fit makes room for the label in a message of the size asked for, or says +// why the size is too small. A label that does not fit is left out with a +// note, as txt leaves it out - the minimum is the message without it. +func fit(m *message, r format.Request) ([]format.Note, error) { + bare := m.size() + if r.Bytes < bare { + return nil, &format.BelowMinimumError{ + Format: "EML", + Requested: r.Bytes, + Minimum: bare, + Reason: core.Says("eml.AMessageHoldsItsHeaderLines", "a message holds its header lines, its parts and every attached file in base64, "+ + "which is about a third larger than the file itself"), + Hint: core.Says("format.AskForBOrMore", "Ask for %d B or more.", core.A("Min", bare)), + } + } + if !r.Label { + return nil, nil + } + m.label = core.Label("eml", r.Bytes, r.Seed) + if m.size() <= r.Bytes { + return nil, nil + } + cost := m.size() - bare + m.label = "" + // Silence is banned - a file the user believes carries a label has to say + // that it does not. + return []format.Note{{ + Code: "label_omitted", + Detail: core.Says("format.TheLabelNeedsBAndThe", "The label needs %d B and the file is %d B, so this file carries no label. Its name and the manifest still identify it.", + core.A("Length", cost), core.A("Bytes", r.Bytes)), + }}, nil +} + +// settle is the size of a message whose size comes from what it carries: no +// words beyond the label, and the label states the size. A longer number makes +// a longer label, so the two are settled against each other - the size only +// grows, so it settles in a few rounds. +func settle(m *message, r format.Request) int64 { + size := m.size() + for round := 0; r.Label && round < 8; round++ { + m.label = core.Label("eml", size, r.Seed) + next := m.size() + if next == size { + break + } + size = next + } + return size +} + +// share hands the bytes still owed to the words of the text. alternative has +// the same words in both parts, so it takes them two at a time, and an odd one +// becomes the space parity puts in the HTML. +func share(m *message, rest int64) { + if m.s.body != Alternative { + m.text = rest + return + } + m.parity = rest%2 == 1 + m.text = rest / 2 +} + +// describe is the plan and what reaches the manifest - public names under +// rule 10, approved on 2026-10-07 (docs/EML-2026-10-07.md section 10.5). +func describe(m *message, groups []format.Content, target int64) format.Plan { + var raw, encoded int64 + for _, c := range m.children { + raw += c.plan.Bytes + encoded += c.encoded + } + p := format.Plan{ + Bytes: target, + Exact: true, + Determinism: format.DeterminismByte, + Properties: map[string]any{ + Body: m.s.body, + TextEncoding: m.s.encoding, + Headers: m.s.headers, + LineEndings: m.s.endings, + FilenameStyle: m.s.names, + Attachments: len(m.children), + "contains": archive.ContentSummary(groups), + "attachment_bytes": raw, + "attachment_encoded_bytes": encoded, + "mime_entities": m.entities(), + "subject": m.id.subject, + "message_id": m.id.messageID, + format.PropertyLabelEmbedded: m.label != "", + }, + Memo: m, + } + // The one format shape keeps the keys the archives give it, so a test + // written against one container reads the other the same way. + if len(groups) == 1 { + p.Properties[AttachmentFormat] = groups[0].Format + p.Properties[AttachmentSize] = groups[0].Bytes + } + return p +} + +func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { + m, ok := p.Memo.(*message) + if !ok { + return core.Defect(fmt.Errorf("eml: the plan was not produced by this generator")) + } + o := out{w: w, ctx: ctx, eol: m.s.eol, scratch: make([]byte, 0, chunkSize+64)} + m.emit(&o) + return o.err +} diff --git a/internal/format/eml/header.go b/internal/format/eml/header.go new file mode 100644 index 00000000..d42cb9c8 --- /dev/null +++ b/internal/format/eml/header.go @@ -0,0 +1,143 @@ +package eml + +import ( + "encoding/base64" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The people a message is between. Addresses only in the domains RFC 2606 +// keeps for examples, so a message that leaves a test by mistake reaches +// nobody. +const ( + fromAddress = "alice@example.com" + toAddress = "bob@example.org" + + fromASCII = "Alice Example" + toASCII = "Bob Example" + + // The same people with names outside ASCII, for headers set to encoded or + // utf8: Polish for one, Japanese for the other, so a reader that handles + // two byte letters and not three byte ones is caught as well. + fromWide = "Łukasz Żółć" + toWide = "山田 太郎" + + subjectASCII = "Test message" + subjectWide = "Zgłoszenie testowe 日本語" + + // wordBytes is how much text one encoded word carries. Thirty bytes of + // UTF-8 are forty characters of base64 and fifty two with the markers, so + // the first word fits beside "Subject: " under 78 characters and no word + // comes near the 75 RFC 2047 allows. + wordBytes = 30 +) + +// dateWindow is the year a message is dated in, from the start of 2026. The +// date comes from the seed rather than the clock, because the same recipe has +// to give the same bytes (D11). +const dateWindow = 365 * 24 * 60 * 60 + +var dateEpoch = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC).Unix() + +// identity is what makes one message this message, worked out from its seed. +// +// Every piece has a fixed length whatever the seed, so the size of a message +// does not depend on which seed it was made with - the minimum is one number +// for one set of settings. +type identity struct { + // messageID is the Message-ID line's value, angle brackets and all, + // because that is how a test finds the ticket the message became. + messageID string + date string + // subject is the subject as a person reads it, before any encoding. + subject string + // boundary separates the parts of multipart/mixed and inner those of + // multipart/alternative. Both carry "=_", which neither base64 nor + // quoted-printable can produce, and they differ in their last letter so + // that neither is the start of the other - RFC 2046 lets a reader match a + // boundary at the start of a longer line. + boundary, inner string + // from, to and subjectLine are the header values as they are written, + // encoded words and folding included. Worked out once here, because the + // message is counted several times while it is planned and a header built + // at every count is an allocation the resource guard counts too. + from, to, subjectLine string +} + +func identify(seed uint64, s settings) identity { + hex := fmt.Sprintf("%016x", seed) + subject, from, to := subjectASCII, fromASCII, toASCII + if s.headers != ASCII { + subject, from, to = subjectWide, fromWide, toWide + } + subject += " " + core.SeedLabel(seed) + return identity{ + messageID: "<" + hex + "@example.com>", + date: time.Unix(dateEpoch+int64(seed%dateWindow), 0).UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"), + subject: subject, + boundary: "=_tfg_" + hex + "_m", + inner: "=_tfg_" + hex + "_a", + from: phrase(s, from) + " <" + fromAddress + ">", + to: phrase(s, to) + " <" + toAddress + ">", + subjectLine: phrase(s, subject), + } +} + +// head writes the lines every message opens with. +func (m *message) head(o *out) { + eol := m.s.eol + o.put("From: ", m.id.from, eol) + o.put("To: ", m.id.to, eol) + o.put("Date: ", m.id.date, eol) + o.put("Message-ID: ", m.id.messageID, eol) + o.put("Subject: ", m.id.subjectLine, eol) + o.put("MIME-Version: 1.0", eol) +} + +// phrase is text for a header line, written the way headers says: as it is +// when it is ASCII or when utf8 asks for it raw, and as encoded words folded +// onto lines of their own when encoded asks for them. +func phrase(s settings, text string) string { + if s.headers != Encoded || isASCII(text) { + return text + } + return strings.Join(encodedWords(text), s.eol+" ") +} + +// encodedWords is text as RFC 2047 B words, cut between characters and never +// inside one, so each word decodes on its own as the RFC requires. +// +// Written here rather than taken from mime.WordEncoder. Its way of cutting is +// the standard library's to change, and a cut moved by a newer Go would move +// the bytes of every message with a name outside ASCII (D11, O169). +func encodedWords(text string) []string { + var out []string + start := 0 + for start < len(text) { + end := start + for end < len(text) { + _, size := utf8.DecodeRuneInString(text[end:]) + if end+size-start > wordBytes { + break + } + end += size + } + out = append(out, "=?UTF-8?B?"+base64.StdEncoding.EncodeToString([]byte(text[start:end]))+"?=") + start = end + } + return out +} + +// isASCII says whether text needs nothing of headers to be written. +func isASCII(text string) bool { + for i := 0; i < len(text); i++ { + if text[i] >= utf8.RuneSelf { + return false + } + } + return true +} diff --git a/internal/format/eml/message.go b/internal/format/eml/message.go new file mode 100644 index 00000000..99e53f00 --- /dev/null +++ b/internal/format/eml/message.go @@ -0,0 +1,186 @@ +package eml + +import ( + "context" + "io" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// message is one message as it will be written, worked out while planning. +// +// The same code counts it and writes it: emit runs against an out with no +// writer to find the size, and against the file to produce it. A size worked +// out by a second copy of the layout would agree with the bytes only for as +// long as somebody kept the two alike by hand. +type message struct { + s settings + id identity + children []child + seed uint64 + // label is the label line's text, or nothing for a message without one. + label string + // text is how many bytes of words each text part carries, and parity is + // the one byte alternative cannot split between its two parts - a space + // in the HTML, where it changes nothing a person sees. + text int64 + parity bool + // plainLine and htmlLine are the line in Polish and Japanese as each part + // writes it, encoded once rather than at every count. + plainLine, htmlLine string +} + +func newMessage(s settings, seed uint64, children []child) *message { + return &message{ + s: s, id: identify(seed, s), children: children, seed: seed, + plainLine: s.internationalLine("", ""), + htmlLine: s.internationalLine("

", "

"), + } +} + +// size is how many bytes the message comes to as it stands. +func (m *message) size() int64 { + o := out{eol: m.s.eol} + m.emit(&o) + return o.n +} + +// entities is every MIME entity in the message, the message itself included - +// the number mailparser counts and refuses past a thousand (measured +// 2026-10-07, docs/EML-2026-10-07.md section 5.1). +func (m *message) entities() int { + text := 1 + if m.s.body == Alternative { + text = 3 + } + if len(m.children) == 0 { + return text + } + return 1 + text + len(m.children) +} + +// emit writes the message: the header lines, then the text alone, or the text +// and every attached file as the parts of multipart/mixed. +func (m *message) emit(o *out) { + m.head(o) + if len(m.children) == 0 { + m.content(o, true) + return + } + eol, b := m.s.eol, m.id.boundary + o.put(`Content-Type: multipart/mixed; boundary="`, b, `"`, eol, eol) + o.put("--", b, eol) + m.content(o, false) + for i := range m.children { + if o.cancelled() { + return + } + o.put(eol, "--", b, eol) + m.attach(o, &m.children[i]) + } + o.put(eol, "--", b, "--", eol) +} + +// content writes the text as one entity: its header lines, a blank line and +// what it says. top is a message with nothing attached, where the text is the +// whole message and a closing boundary ends the file with a line break. +func (m *message) content(o *out, top bool) { + switch m.s.body { + case Plain: + m.textHead(o, "text/plain") + m.plain(o) + case HTML: + m.textHead(o, "text/html") + m.html(o) + default: + eol, in := m.s.eol, m.id.inner + o.put(`Content-Type: multipart/alternative; boundary="`, in, `"`, eol, eol) + o.put("--", in, eol) + m.textHead(o, "text/plain") + m.plain(o) + o.put(eol, "--", in, eol) + m.textHead(o, "text/html") + m.html(o) + o.put(eol, "--", in, "--") + if top { + o.put(eol) + } + } +} + +func (m *message) textHead(o *out, contentType string) { + eol := m.s.eol + o.put("Content-Type: ", contentType, "; charset=", m.s.charset(), eol) + o.put("Content-Transfer-Encoding: ", m.s.encoding, eol, eol) +} + +// plain is the text part: the label, the line in Polish and Japanese, and the +// words that bring the message to its size. +func (m *message) plain(o *out) { + if m.label != "" { + o.put(m.label, m.s.eol) + } + o.put(m.plainLine) + fill(o, m.text, m.seed) +} + +// html is the same text as HTML, the words in one paragraph - line breaks +// inside a paragraph are white space, so the lines keep the text's width. +func (m *message) html(o *out) { + eol := m.s.eol + o.put("") + if m.parity { + o.put(" ") + } + o.put("", eol) + if m.label != "" { + o.put("

", m.label, "

", eol) + } + o.put(m.htmlLine, "

", eol) + fill(o, m.text, m.seed) + o.put(eol, "

", eol, "") +} + +// out is where a message goes - a file, or nowhere while it is being counted. +// +// The first error sticks and everything after it is counted and not written, +// so the layout reads as a list of lines rather than a list of checks. +type out struct { + w io.Writer // nil while counting + ctx context.Context + eol string + n int64 + err error + // scratch is the buffer the words are built in, and lines the writer that + // cuts base64, both kept for the whole message rather than made per part. + scratch []byte + lines lines +} + +func (o *out) put(parts ...string) { + for _, p := range parts { + o.n += int64(len(p)) + if o.w != nil && o.err == nil { + _, o.err = io.WriteString(o.w, p) + } + } +} + +func (o *out) bytes(b []byte) { + o.n += int64(len(b)) + if o.w != nil && o.err == nil { + o.err = core.WriteAll(o.w, b) + } +} + +// cancelled says whether the run was interrupted, and makes that the error. +func (o *out) cancelled() bool { + if o.ctx == nil || o.err != nil { + return o.err != nil + } + if err := o.ctx.Err(); err != nil { + o.err = err + return true + } + return false +} diff --git a/internal/format/eml/settings.go b/internal/format/eml/settings.go new file mode 100644 index 00000000..8453bdcf --- /dev/null +++ b/internal/format/eml/settings.go @@ -0,0 +1,230 @@ +package eml + +import ( + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/archive" +) + +// Setting names. Public names under rule 10, so each is spelled once. +const ( + Body = "body" + TextEncoding = "text_encoding" + Headers = "headers" + LineEndings = "line_endings" + FilenameStyle = "filename_style" + + Attachments = "attachments" + AttachmentFormat = "attachment_format" + AttachmentSize = "attachment_size" +) + +// The values of body. +const ( + Plain = "plain" + HTML = "html" + Alternative = "alternative" +) + +// The values of text_encoding, spelled the way Content-Transfer-Encoding +// spells them, because that is the line a tester reads them back from. +const ( + SevenBit = "7bit" + EightBit = "8bit" + QuotedPrintable = "quoted-printable" +) + +// The values of headers. +const ( + ASCII = "ascii" + Encoded = "encoded" + UTF8 = "utf8" +) + +// The values of line_endings. +const ( + CRLF = "crlf" + LF = "lf" +) + +// The values of filename_style. Named after the standard each one follows or +// breaks, and content-type after the only header it writes. +const ( + RFC2231 = "rfc2231" + RFC2047 = "rfc2047" + Both = "both" + ContentType = "content-type" +) + +// attachmentsGroup is the block the attachment settings sit in, on both +// surfaces. +const attachmentsGroup = "Attachments" + +// defaultAttachments is none: a message is text first, and a file attached by +// default would put base64 in front of somebody who asked for a mail. +const defaultAttachments = 0 + +// settings is every choice a message is made with, read once in Plan. +type settings struct { + body string + encoding string + headers string + endings string + names string + // eol is what ends every line of the file, and its length is what the + // arithmetic counts - never a two written as a constant. + eol string +} + +// international says whether the text carries the line in Polish and +// Japanese - which is what quoted-printable and 8bit exist for, and what 7bit +// cannot carry at all. +func (s settings) international() bool { return s.encoding != SevenBit } + +// door is how this format names its contents, for archive.GroupsThrough. +var door = archive.Door{ + Members: members, + DefaultCount: defaultAttachments, + Fewer: core.Says("eml.AskForAttachmentsOrFewer", "Ask for %d attachments or fewer.", core.A("Max", archive.MaxMembers)), +} + +var members = format.Members{Count: Attachments, Format: AttachmentFormat, Size: AttachmentSize} + +// parse reads the settings a request carries. +// +// A value outside the declaration has already been refused by the registry, +// which checks every format in one place. The check stays here for the reason +// geojson gives: a guard calls Plan directly, and a generator that trusts its +// input is one registry change away from writing a message nobody ordered. +// The three attachment settings are read by archive.GroupsThrough, beside +// contains, because the two doors share their rules. +func parse(props map[string]string) (settings, error) { + values := map[string]string{} + for _, p := range textProperties() { + v, err := valueOf(props, p) + if err != nil { + return settings{}, err + } + values[p.Name] = v + } + names, err := valueOf(props, filenameStyle()) + if err != nil { + return settings{}, err + } + s := settings{ + body: values[Body], + encoding: values[TextEncoding], + headers: values[Headers], + endings: values[LineEndings], + names: names, + eol: "\r\n", + } + if s.endings == LF { + s.eol = "\n" + } + return s, nil +} + +// valueOf is one setting's value, or its default, refused in the +// declaration's own words when the declaration does not allow it. +func valueOf(props map[string]string, p format.Property) (string, error) { + v, ok := props[p.Name] + if !ok || v == "" { + return p.Default, nil + } + if bad := p.Allows(v); !bad.IsZero() { + return "", &format.PropertyValueError{Format: "eml", Key: p.Name, Value: v, Reason: bad, Remedy: p.Instead()} + } + return v, nil +} + +// properties is the declaration, in the order a person meets it: what the +// message says and how it is written, then what it carries. +func properties() []format.Property { + return append(textProperties(), attachmentProperties()...) +} + +// textProperties are the settings of the text and of the header lines. +func textProperties() []format.Property { + return []format.Property{ + { + Name: Body, Kind: format.PropertyChoice, + Choices: []string{Alternative, HTML, Plain}, + Default: Plain, + Detail: "Which text the message carries. plain is one text part and html one HTML part. " + + "alternative carries both with the same words, and a mail program shows one of them.", + }, + { + Name: TextEncoding, Kind: format.PropertyChoice, + Choices: []string{SevenBit, EightBit, QuotedPrintable}, + Default: SevenBit, + Detail: "How the text is written. 7bit keeps it to plain ASCII. " + + "quoted-printable and 8bit add a line in Polish and Japanese, because that is what they are for - " + + "quoted-printable spells those letters as =C5=BC, and 8bit writes them as they are.", + }, + { + Name: Headers, Kind: format.PropertyChoice, + Choices: []string{ASCII, Encoded, UTF8}, + Default: ASCII, + Detail: "Whether the subject, the names of the sender and the recipient and the names of attached files " + + "carry letters outside ASCII, and how. encoded writes them the way RFC 2047 and RFC 2231 say. " + + "utf8 writes them as they are (RFC 6532), and Python's email package reads that and reports a defect in the From line.", + }, + { + Name: LineEndings, Kind: format.PropertyChoice, + Choices: []string{CRLF, LF}, + Default: CRLF, + Detail: "How every line of the file ends. The standard is crlf. " + + "Python, MimeKit, mailparser and Go's standard library all read lf too, " + + "so the difference shows in programs that split the lines themselves.", + }, + } +} + +// attachmentProperties are the settings of what the message carries. +func attachmentProperties() []format.Property { + return []format.Property{ + { + Name: Attachments, Kind: format.PropertyInt, + Min: 0, Max: archive.MaxMembers, + Default: strconv.Itoa(defaultAttachments), + Detail: "How many files the message carries. Use contains instead when the files are not all alike. " + + "mailparser refuses a message of more than 1000 MIME entities, and the manifest gives this one's count as mime_entities.", + Group: attachmentsGroup, + }, + { + Name: AttachmentFormat, Kind: format.PropertyText, + Shape: "the id of a format, as tfg formats lists them", + // Not a choice, for the reason the archives give: the allowed + // values are whatever this build registered. + Default: archive.DefaultFormat, + Detail: "The format of the attached files. Run tfg formats to see what this build supports.", + Group: attachmentsGroup, + }, + { + Name: AttachmentSize, Kind: format.PropertySize, + Default: archive.DefaultSizeText, + Detail: "How big each attached file is. The message grows by about a third more, " + + "because attached files travel in base64.", + Group: attachmentsGroup, + }, + filenameStyle(), + } +} + +// filenameStyle is the one attachment setting this package reads itself. +func filenameStyle() format.Property { + return format.Property{ + Name: FilenameStyle, Kind: format.PropertyChoice, + Choices: []string{Both, ContentType, RFC2047, RFC2231}, + Default: RFC2231, + Detail: "How the names of attached files are written. rfc2231 is the standard way. " + + "rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. " + + "both writes the two, and content-type puts the name only where old mail programs looked for it. " + + "Go's standard library reads no name from content-type and the encoded text itself from rfc2047. " + + "With names in plain ASCII, rfc2047 and both write the same bytes.", + Group: attachmentsGroup, + } +} diff --git a/internal/format/eml/text.go b/internal/format/eml/text.go new file mode 100644 index 00000000..8540bebc --- /dev/null +++ b/internal/format/eml/text.go @@ -0,0 +1,137 @@ +package eml + +import ( + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The text of a message is where the padding goes, decided on 2026-10-07 after +// both candidates were measured (docs/EML-2026-10-07.md section 5.1): every +// reader keeps the text, a system that saves the message again keeps it too, +// and a message of one part has nowhere else to put it. The epilogue after the +// last boundary held up as well in every reader, and RFC 2046 lets anything in +// between throw it away. + +const ( + // lineWidth is one under the 76 quoted-printable allows a line, so the + // last byte of the text can always become a letter - see fill. + lineWidth = 75 + // chunkSize is how much text is built before it is written. + chunkSize = 32 * 1024 +) + +// international is the line in Polish and Japanese that quoted-printable and +// 8bit add to the text - two byte letters and three byte ones, which is what +// those encodings are for. +const international = "Zażółć gęślą jaźń. 日本語のテキストです。" + +// words are lower case and plain, so no line of the text can begin with +// "From " (which mbox files rewrite), with "--" (which reads as a boundary) or +// with a lone full stop (which ends a message in SMTP), and none holds "=". +var words = []string{ + "account", "address", "after", "again", "answer", "before", "below", "between", + "change", "check", "close", "color", "copy", "date", "detail", "draft", "early", + "every", "field", "file", "first", "follow", "group", "help", "issue", "keep", + "later", "letter", "line", "list", "local", "message", "month", "needed", "note", + "number", "office", "order", "other", "page", "paper", "point", "print", "question", + "ready", "record", "reply", "report", "request", "result", "review", "second", + "sender", "short", "small", "status", "subject", "summary", "table", "thank", + "ticket", "today", "update", "value", "week", "while", "words", "write", "year", +} + +// charset is what the text parts declare. +func (s settings) charset() string { + if s.international() { + return "utf-8" + } + return "us-ascii" +} + +// internationalLine is the line in Polish and Japanese the way the text is +// encoded, or nothing for 7bit. +func (s settings) internationalLine(open, close string) string { + if !s.international() { + return "" + } + line := open + international + close + if s.encoding == QuotedPrintable { + line = quotedPrintable(line, s.eol) + } + return line + s.eol +} + +// quotedPrintable is text in quoted-printable (RFC 2045 section 6.7): every +// byte outside printable ASCII, and the equals sign, as =XX, no line longer +// than 76 characters - soft line breaks where a line would be - and no space +// at the end of a line. +// +// Written here for the reason encodedWords is: the bytes it gives have to be +// these bytes on every Go version. +func quotedPrintable(text, eol string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + col := 0 + for i := 0; i < len(text); i++ { + c := text[i] + token := string(c) + if c == '=' || c < ' ' || c > '~' || (c == ' ' && i == len(text)-1) { + token = "=" + string(hex[c>>4]) + string(hex[c&15]) + } + if col+len(token) > 75 { + b.WriteString("=" + eol) + col = 0 + } + b.WriteString(token) + col += len(token) + } + return b.String() +} + +// fill writes exactly n bytes of words in lines of at most lineWidth, each +// line but the last ending in eol. +// +// The last byte is a letter, whatever falls there. A space at the end of a +// line is what quoted-printable forbids, and a carriage return cut from its +// line feed is a lone CR that no reader handles the same way twice. Both end +// a line of at most lineWidth, so the letter keeps it within 76. +func fill(o *out, n int64, seed uint64) { + if n <= 0 || o.err != nil { + return + } + if o.w == nil { + o.n += n + return + } + rng := core.NewRand(seed) + buf := o.scratch[:0] + col := 0 + for remaining := n; remaining > 0 && o.err == nil; { + if o.cancelled() { + return + } + buf = buf[:0] + for len(buf) < chunkSize && int64(len(buf)) < remaining { + word := words[rng.IntN(len(words))] + switch { + case col > 0 && col+1+len(word) > lineWidth: + buf = append(buf, o.eol...) + col = 0 + case col > 0: + buf = append(buf, ' ') + col++ + } + buf = append(buf, word...) + col += len(word) + } + if int64(len(buf)) >= remaining { + buf = buf[:remaining] + if last := buf[len(buf)-1]; last == ' ' || last == '\r' { + buf[len(buf)-1] = 'x' + } + } + o.bytes(buf) + remaining -= int64(len(buf)) + } + o.scratch = buf +} diff --git a/internal/format/targz/targz.go b/internal/format/targz/targz.go index a098a1f6..d108ce0b 100644 --- a/internal/format/targz/targz.go +++ b/internal/format/targz/targz.go @@ -334,7 +334,7 @@ func describe(target int64, label string, m memo, groups []format.Content) forma Determinism: format.DeterminismByte, Properties: map[string]any{ "entries": len(m.children), - "contains": contentSummary(groups), + "contains": archive.ContentSummary(groups), // Where the files sit, and whether the directories are named - // written every time rather than only when nested, so a harness // never has to read a missing key as flat. @@ -368,20 +368,6 @@ func describe(target int64, label string, m memo, groups []format.Content) forma return p } -// contentSummary is what the archive holds, in the manifest, so a test can -// assert on it without unpacking the file. -func contentSummary(groups []format.Content) []map[string]any { - out := make([]map[string]any, 0, len(groups)) - for _, g := range groups { - out = append(out, map[string]any{ - "format": g.Format, - "count": g.Count, - "bytes": g.Bytes, - }) - } - return out -} - // describeGroups is the same thing for a person reading an error. func describeGroups(groups []format.Content) core.Said { parts := make(core.Conjoined, 0, len(groups)) diff --git a/internal/format/zip/zip.go b/internal/format/zip/zip.go index 109d2248..17a84045 100644 --- a/internal/format/zip/zip.go +++ b/internal/format/zip/zip.go @@ -291,7 +291,7 @@ func describe(target int64, label string, m memo, groups []format.Content) forma Determinism: format.DeterminismByte, Properties: map[string]any{ "entries": len(m.children), - "contains": contentSummary(groups), + "contains": archive.ContentSummary(groups), "method": "store", // Where the files sit, and whether the directories are named. // Written every time rather than only when nested, like method @@ -393,20 +393,6 @@ func pad(m *memo, p *format.Plan, r format.Request, target, bare int64, label st return nil } -// contentSummary is what the archive holds, in the manifest, so a test can -// assert on it without unpacking the file. -func contentSummary(groups []format.Content) []map[string]any { - out := make([]map[string]any, 0, len(groups)) - for _, g := range groups { - out = append(out, map[string]any{ - "format": g.Format, - "count": g.Count, - "bytes": g.Bytes, - }) - } - return out -} - // describeGroups is the same thing for a person reading an error. func describeGroups(groups []format.Content) core.Said { parts := make(core.Conjoined, 0, len(groups)) diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index 52269b0a..436e2444 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -49,6 +49,7 @@ var layer = map[string]int{ "internal/format/csvfile": 1, "internal/format/jsonfile": 1, "internal/format/geojson": 1, + "internal/format/eml": 1, "internal/format/yamlfile": 1, "internal/format/tomlfile": 1, "internal/format/xmlfile": 1, @@ -155,6 +156,7 @@ var sameLayerAllowed = map[string][]string{ "internal/format/wav", "internal/format/webm", "internal/format/mp4", + "internal/format/eml", }, "internal/format/imagelabel": {"internal/format"}, "internal/format/imagedim": {"internal/format"}, @@ -183,6 +185,7 @@ var sameLayerAllowed = map[string][]string{ "internal/format/pdf": {"internal/format", "internal/format/imagelabel"}, "internal/format/zip": {"internal/format", "internal/format/imagelabel", "internal/format/archive"}, "internal/format/targz": {"internal/format", "internal/format/archive"}, + "internal/format/eml": {"internal/format", "internal/format/archive"}, "internal/format/tiff": {"internal/format", "internal/format/imagelabel", "internal/format/imagedim"}, "internal/format/webp": {"internal/format", "internal/format/imagelabel", "internal/format/imagedim"}, "internal/format/avif": {"internal/format", "internal/format/imagelabel", "internal/format/imagedim", "internal/format/isobmff"}, From 8a55c472db2947430a3c3eee1181f11b5eb352f1 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 21:18:02 +0200 Subject: [PATCH 04/12] eml: the oracle, the structural check, the guards, and the window's words in two languages Python's email package is the oracle, with every defect a refusal. mailparser joins it where TFG_MAILPARSER names it, and Go's standard library reads every message in the guards themselves. strict.py checks what none of the four readers did: the header lines RFC 5322 requires, the lines, the encodings, and where each name is written. Co-Authored-By: Claude Opus 5.5 --- internal/format/container.go | 110 +++++++++ internal/format/eml/settings.go | 7 +- internal/format/format.go | 97 -------- internal/guard/archivereadable_test.go | 7 + internal/guard/eml_test.go | 214 +++++++++++++++++ internal/guard/emlgo_test.go | 216 +++++++++++++++++ internal/guard/oracle_test.go | 5 + internal/guard/parity_test.go | 9 + internal/gui/text/locale/registry/en.json | 170 ++++++++++++++ internal/gui/text/locale/registry/pl.json | 34 +++ internal/gui/text/locale/said/en.json | 25 ++ internal/gui/text/locale/said/pl.json | 5 + internal/oracle/mailscripts.go | 114 +++++++++ internal/oracle/oracle.go | 6 + internal/oracle/strict.py | 267 +++++++++++++++++++++- 15 files changed, 1184 insertions(+), 102 deletions(-) create mode 100644 internal/format/container.go create mode 100644 internal/guard/eml_test.go create mode 100644 internal/guard/emlgo_test.go create mode 100644 internal/oracle/mailscripts.go diff --git a/internal/format/container.go b/internal/format/container.go new file mode 100644 index 00000000..eac15e01 --- /dev/null +++ b/internal/format/container.go @@ -0,0 +1,110 @@ +package format + +import ( + "sort" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// What a container is, apart from the format it writes: how it names its +// contents, and the three ways asking a format for contents can be refused. +// Split out of format.go on 2026-10-07, when eml - the first container that is +// not an archive - pushed that file past the size the shape guard allows. + +// Members is how a container names the settings for contents of one format. +type Members struct { + Count, Format, Size string +} + +// NotAContainerError is contains asked of a format that holds nothing. +type NotAContainerError struct { + Format string + Containers []string +} + +// What happened, what can do it instead, and what to do about it. +func (e *NotAContainerError) What() string { return e.what().String() } + +func (e *NotAContainerError) what() core.Said { + return core.Says("format.NotAContainer", "%s holds no other files, so it cannot take contains", core.A("Format", e.Format)) +} + +func (e *NotAContainerError) Why() string { return e.why().String() } + +func (e *NotAContainerError) why() core.Said { + return core.Says("format.NotAContainerWhy", "the formats that can are %s", core.A("Containers", strings.Join(e.Containers, ", "))) +} + +func (e *NotAContainerError) Instead() string { return e.instead().String() } + +func (e *NotAContainerError) instead() core.Said { + return core.Says("format.NotAContainerFix", "Drop contains, or change the format") +} + +// Parts is what happened, why and what to do instead, for a reader that lays +// them out apart and in its own language. +func (e *NotAContainerError) Parts() (what, why, instead core.Said) { + return e.what(), e.why(), e.instead() +} + +func (e *NotAContainerError) Error() string { return e.Said().String() } + +// Said is the whole refusal, for a window that says it in its own language. +func (e *NotAContainerError) Said() core.Said { + return core.Says("format.NotAContainerWhole", "%s - %s. %s", core.A("What", e.what()), core.A("Why", e.why()), core.A("Fix", e.instead())) +} + +// ContentsConflictError is contains stated beside format properties saying the +// same thing. Picking one would build an archive holding something other than +// what the recipe says, and the recipe is what somebody reads in a review. +type ContentsConflictError struct { + Format string + Keys []string +} + +func (e *ContentsConflictError) Error() string { return e.Said().String() } + +// Said is the refusal, for a window that says it in its own language. +func (e *ContentsConflictError) Said() core.Said { + if len(e.Keys) == 1 { + return core.Says("format.ContentsConflictOne", + "%s: contains and the %s property both say what the archive holds. Keep contains and drop the properties, or the other way round", + core.A("Format", e.Format), core.A("Key", e.Keys[0])) + } + return core.Says("format.ContentsConflict", + "%s: contains and the %s properties both say what the archive holds. Keep contains and drop the properties, or the other way round", + core.A("Format", e.Format), core.A("Keys", strings.Join(e.Keys, ", "))) +} + +// NestingUnsupportedError is a container asked to hold its own format. +// +// A legitimate test case that needs a depth limit before it is allowed, and +// there is none yet. It says that rather than pretending the format is unknown. +type NestingUnsupportedError struct { + Format string +} + +func (e *NestingUnsupportedError) Error() string { return e.Said().String() } + +// Said is the refusal, for a window that says it in its own language. +func (e *NestingUnsupportedError) Said() core.Said { + return core.Says("format.NestingUnsupported", + "%s cannot hold %s yet - an archive inside an archive needs a depth limit first. Hold a different format, or build the inner archive as its own target", + core.A("Format", e.Format), core.A("Inner", e.Format)) +} + +// Containers lists the formats that accept contains, for a message that tells +// somebody what to write instead. +func Containers() []string { + mu.RLock() + defer mu.RUnlock() + var out []string + for id, d := range registry { + if d.Container { + out = append(out, id) + } + } + sort.Strings(out) + return out +} diff --git a/internal/format/eml/settings.go b/internal/format/eml/settings.go index 8453bdcf..6b97c906 100644 --- a/internal/format/eml/settings.go +++ b/internal/format/eml/settings.go @@ -60,7 +60,7 @@ const ( // attachmentsGroup is the block the attachment settings sit in, on both // surfaces. -const attachmentsGroup = "Attachments" +const attachmentsGroup = "Attached files" // defaultAttachments is none: a message is text first, and a file attached by // default would put base64 in front of somebody who asked for a mail. @@ -170,7 +170,7 @@ func textProperties() []format.Property { Default: ASCII, Detail: "Whether the subject, the names of the sender and the recipient and the names of attached files " + "carry letters outside ASCII, and how. encoded writes them the way RFC 2047 and RFC 2231 say. " + - "utf8 writes them as they are (RFC 6532), and Python's email package reads that and reports a defect in the From line.", + "utf8 writes them as they are (RFC 6532), and Python's email package reads that and reports a defect in the From and To lines.", }, { Name: LineEndings, Kind: format.PropertyChoice, @@ -224,7 +224,8 @@ func filenameStyle() format.Property { "rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. " + "both writes the two, and content-type puts the name only where old mail programs looked for it. " + "Go's standard library reads no name from content-type and the encoded text itself from rfc2047. " + - "With names in plain ASCII, rfc2047 and both write the same bytes.", + "Unless headers is encoded a name is written as it is and only where it goes changes, " + + "so rfc2047 and both write the same bytes.", Group: attachmentsGroup, } } diff --git a/internal/format/format.go b/internal/format/format.go index 678d2cce..e809cde5 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -314,103 +314,6 @@ type Descriptor struct { Members Members } -// Members is how a container names the settings for contents of one format. -type Members struct { - Count, Format, Size string -} - -// NotAContainerError is contains asked of a format that holds nothing. -type NotAContainerError struct { - Format string - Containers []string -} - -// What happened, what can do it instead, and what to do about it. -func (e *NotAContainerError) What() string { return e.what().String() } - -func (e *NotAContainerError) what() core.Said { - return core.Says("format.NotAContainer", "%s holds no other files, so it cannot take contains", core.A("Format", e.Format)) -} - -func (e *NotAContainerError) Why() string { return e.why().String() } - -func (e *NotAContainerError) why() core.Said { - return core.Says("format.NotAContainerWhy", "the formats that can are %s", core.A("Containers", strings.Join(e.Containers, ", "))) -} - -func (e *NotAContainerError) Instead() string { return e.instead().String() } - -func (e *NotAContainerError) instead() core.Said { - return core.Says("format.NotAContainerFix", "Drop contains, or change the format") -} - -// Parts is what happened, why and what to do instead, for a reader that lays -// them out apart and in its own language. -func (e *NotAContainerError) Parts() (what, why, instead core.Said) { - return e.what(), e.why(), e.instead() -} - -func (e *NotAContainerError) Error() string { return e.Said().String() } - -// Said is the whole refusal, for a window that says it in its own language. -func (e *NotAContainerError) Said() core.Said { - return core.Says("format.NotAContainerWhole", "%s - %s. %s", core.A("What", e.what()), core.A("Why", e.why()), core.A("Fix", e.instead())) -} - -// ContentsConflictError is contains stated beside format properties saying the -// same thing. Picking one would build an archive holding something other than -// what the recipe says, and the recipe is what somebody reads in a review. -type ContentsConflictError struct { - Format string - Keys []string -} - -func (e *ContentsConflictError) Error() string { return e.Said().String() } - -// Said is the refusal, for a window that says it in its own language. -func (e *ContentsConflictError) Said() core.Said { - if len(e.Keys) == 1 { - return core.Says("format.ContentsConflictOne", - "%s: contains and the %s property both say what the archive holds. Keep contains and drop the properties, or the other way round", - core.A("Format", e.Format), core.A("Key", e.Keys[0])) - } - return core.Says("format.ContentsConflict", - "%s: contains and the %s properties both say what the archive holds. Keep contains and drop the properties, or the other way round", - core.A("Format", e.Format), core.A("Keys", strings.Join(e.Keys, ", "))) -} - -// NestingUnsupportedError is a container asked to hold its own format. -// -// A legitimate test case that needs a depth limit before it is allowed, and -// there is none yet. It says that rather than pretending the format is unknown. -type NestingUnsupportedError struct { - Format string -} - -func (e *NestingUnsupportedError) Error() string { return e.Said().String() } - -// Said is the refusal, for a window that says it in its own language. -func (e *NestingUnsupportedError) Said() core.Said { - return core.Says("format.NestingUnsupported", - "%s cannot hold %s yet - an archive inside an archive needs a depth limit first. Hold a different format, or build the inner archive as its own target", - core.A("Format", e.Format), core.A("Inner", e.Format)) -} - -// Containers lists the formats that accept contains, for a message that tells -// somebody what to write instead. -func Containers() []string { - mu.RLock() - defer mu.RUnlock() - var out []string - for id, d := range registry { - if d.Container { - out = append(out, id) - } - } - sort.Strings(out) - return out -} - // Allows reports whether raw is a value this property accepts, and says what // is wrong when it is not. // diff --git a/internal/guard/archivereadable_test.go b/internal/guard/archivereadable_test.go index de158ca9..5c899f8b 100644 --- a/internal/guard/archivereadable_test.go +++ b/internal/guard/archivereadable_test.go @@ -103,6 +103,13 @@ func standardReaderFor(id string) (func([]byte) error, bool) { _, err := stdzip.NewReader(bytes.NewReader(b), int64(len(b))) return err }, true + case "eml": + // Read to the last byte of every part, because a message is only + // refused where a part is broken - see emlgo_test.go. + return func(b []byte) error { + _, _, err := goReadMail(b) + return err + }, true } return nil, false } diff --git a/internal/guard/eml_test.go b/internal/guard/eml_test.go new file mode 100644 index 00000000..d748e953 --- /dev/null +++ b/internal/guard/eml_test.go @@ -0,0 +1,214 @@ +package guard + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/eml" + "github.com/donislawdev/TestingFilesGenerator/internal/oracle" +) + +// emlCases is every value of every setting, one at a time, then the +// combinations where the settings meet: names outside ASCII in every style, +// both line endings under quoted-printable, and alternative with all of it. +var emlCases = []map[string]string{ + {}, + {eml.Body: eml.HTML}, + {eml.Body: eml.Alternative}, + {eml.TextEncoding: eml.QuotedPrintable}, + {eml.TextEncoding: eml.EightBit}, + {eml.Headers: eml.Encoded}, + {eml.Headers: eml.UTF8}, + {eml.LineEndings: eml.LF}, + {eml.FilenameStyle: eml.RFC2047}, + {eml.FilenameStyle: eml.Both}, + {eml.FilenameStyle: eml.ContentType}, + {eml.Headers: eml.Encoded, eml.FilenameStyle: eml.RFC2047}, + {eml.Headers: eml.Encoded, eml.FilenameStyle: eml.Both}, + {eml.Headers: eml.Encoded, eml.FilenameStyle: eml.ContentType}, + {eml.Headers: eml.UTF8, eml.FilenameStyle: eml.Both, eml.LineEndings: eml.LF}, + {eml.TextEncoding: eml.QuotedPrintable, eml.LineEndings: eml.LF, eml.Body: eml.HTML}, + {eml.Body: eml.Alternative, eml.TextEncoding: eml.QuotedPrintable, eml.Headers: eml.Encoded, + eml.FilenameStyle: eml.Both, eml.LineEndings: eml.LF}, + {eml.Body: eml.Alternative, eml.TextEncoding: eml.EightBit, eml.Headers: eml.UTF8, eml.FilenameStyle: eml.RFC2047}, +} + +// Three readers and a scanner, each asked what the others cannot answer +// (docs/EML-2026-10-07.md section 5.1). Python's email package has to read +// every message with no defect but the one headers=utf8 is measured to bring. +// mailparser, where it is installed, has to read the same attached files under +// the same names. The structural checker is told the settings and has to find +// the message made the way they say - the header lines RFC 5322 requires, the +// lines, the encodings, where each name is written. Go's standard library has +// its own guard beside this one. +// +// Every size from the smallest the settings allow, where the label does not fit +// and the text is empty, through the next one, to a message with room for +// words. Named to match ReferenceTool, because that is the one job in CI with +// mailparser installed. Anywhere without python it skips, and says so. +func TestEveryEMLSettingSurvivesItsReferenceTool(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + parsed, byNode := 0, 0 + for i, base := range emlCases { + for _, count := range []string{"0", "2"} { + props := withAttachments(base, count, "txt", "1kb") + smallest := d.SmallestAccepted(format.Request{Properties: props}) + for _, size := range []int64{smallest, smallest + 1, smallest + 2, 20000} { + path := filepath.Join(dir, strconv.Itoa(i)+"_"+count+"_"+strconv.FormatInt(size, 10)+".eml") + b, _ := writeEML(t, d, size, uint64(31+i), props) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + py, node := emlSurvivesReaders(t, path, props, size) + parsed, byNode = parsed+py, byNode+node + } + } + } + // Where mailparser is named it has to have read something, or the step + // that installed it and this guard disagree about where it is. + if os.Getenv("TFG_MAILPARSER") != "" && byNode == 0 { + t.Errorf("TFG_MAILPARSER names %q and mailparser read no message", os.Getenv("TFG_MAILPARSER")) + } + t.Logf("%d messages read by python, %d by mailparser", parsed, byNode) + if parsed == 0 { + t.Skip("python is not installed, so no message was read - a skip, not a pass") + } +} + +// emlSurvivesReaders asks the readers about one message and says which of +// python and mailparser read it. +func emlSurvivesReaders(t *testing.T, path string, props map[string]string, size int64) (python, node int) { + t.Helper() + var allowed []string + if props[eml.Headers] == eml.UTF8 { + allowed = append(allowed, "from:UndecodableBytesDefect", "to:UndecodableBytesDefect") + } + py := oracle.PythonEmail(path, allowed...) + if !py.Available { + return 0, 0 + } + if py.Err != nil { + t.Fatalf("%v at %d B: %v", props, size, py.Err) + } + mp := oracle.MailParser(path) + if mp.Available && mp.Err != nil { + t.Fatalf("%v at %d B: %v", props, size, mp.Err) + } else if mp.Available && attachmentsOf(mp.Output) != attachmentsOf(py.Output) { + t.Fatalf("%v at %d B: mailparser and python read different files\n mailparser %s\n python %s", + props, size, attachmentsOf(mp.Output), attachmentsOf(py.Output)) + } + if s := oracle.Strict("eml", path, emlSettings(props)...); s.Err != nil { + t.Fatalf("%v at %d B: %v", props, size, s.Err) + } + return 1, boolInt(mp.Available) +} + +// attachmentsOf is the list of attached files a reader printed, for comparing +// two readers - the subject is left out, because each prints it its own way. +func attachmentsOf(output string) string { + i := strings.Index(output, `"attachments"`) + if i < 0 { + return output + } + return strings.ReplaceAll(output[i:], " ", "") +} + +// A message too small for its label goes without one and says so - the note +// and label_embedded, both - rather than being refused or rounded. The size is +// taken from between the two minimums, so the guard is in the state it is +// about: it asserts that the bare message fits and the labelled one does not. +func TestAnEMLTooSmallForItsLabelSaysItCarriesNone(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + bare := d.SmallestAccepted(format.Request{}) + _, labelled := writeEML(t, d, bare+200, 5, nil) + if labelled.Properties[format.PropertyLabelEmbedded] != true { + t.Fatalf("a message of %d B carries no label, so this guard is not between the two minimums", bare+200) + } + p, err := d.Generator.Plan(format.Request{Bytes: bare, Seed: 5, Label: true}) + if err != nil { + t.Fatalf("the smallest message with a label asked for was refused: %v", err) + } + if p.Properties[format.PropertyLabelEmbedded] != false || len(p.Notes) != 1 || p.Notes[0].Code != "label_omitted" { + t.Errorf("a message of %d B with no room for its label says label_embedded=%v and notes %v", + bare, p.Properties[format.PropertyLabelEmbedded], p.Notes) + } +} + +// Below the smallest message the settings allow is refused with the four +// parts every refusal of size has (D6), and with the reason a tester needs: +// the files travel in base64, a third larger. A message inside a message is +// refused as nesting, the way an archive inside an archive is. +func TestEMLRefusesASizeBelowItsAttachmentsInBase64AndItselfInside(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + props := withAttachments(nil, "3", "txt", "64kb") + _, err = d.Generator.Plan(format.Request{Bytes: 3 * 64 << 10, Seed: 1, Properties: props}) + var below *format.BelowMinimumError + if !errors.As(err, &below) { + t.Fatalf("three files of 64 KiB in a message of 192 KiB were not refused as too small: %v", err) + } + if below.Minimum <= 3*64<<10*4/3 || !strings.Contains(below.Reason.String(), "base64") { + t.Errorf("the refusal gives the minimum %d B and the reason %q", below.Minimum, below.Reason) + } + _, err = d.Generator.Plan(format.Request{Bytes: 1 << 20, Seed: 1, Properties: withAttachments(nil, "1", "eml", "4kb")}) + var nested *format.NestingUnsupportedError + if !errors.As(err, &nested) { + t.Errorf("a message attached to a message was not refused as nesting: %v", err) + } +} + +// withAttachments is a case with the attachment settings added. +func withAttachments(base map[string]string, count, id, size string) map[string]string { + out := map[string]string{eml.Attachments: count, eml.AttachmentFormat: id, eml.AttachmentSize: size} + for k, v := range base { + out[k] = v + } + return out +} + +// writeEML plans and writes one message, and checks it is the size ordered. +func writeEML(t *testing.T, d format.Descriptor, size int64, seed uint64, props map[string]string) ([]byte, format.Plan) { + t.Helper() + p, err := d.Generator.Plan(format.Request{Bytes: size, Seed: seed, Label: true, Properties: props}) + if err != nil { + t.Fatalf("planning %d B with %v: %v", size, props, err) + } + var buf bytes.Buffer + if err := d.Generator.Write(context.Background(), &buf, p); err != nil { + t.Fatalf("writing %d B with %v: %v", size, props, err) + } + if int64(buf.Len()) != size { + t.Fatalf("%v: asked for %d B and got %d", props, size, buf.Len()) + } + return buf.Bytes(), p +} + +// emlSettings is a case as the words the structural checker is told. +func emlSettings(props map[string]string) []string { + out := make([]string, 0, len(props)) + for k, v := range props { + if k == eml.AttachmentFormat || k == eml.AttachmentSize { + continue + } + out = append(out, k+"="+v) + } + sort.Strings(out) + return out +} diff --git a/internal/guard/emlgo_test.go b/internal/guard/emlgo_test.go new file mode 100644 index 00000000..16264290 --- /dev/null +++ b/internal/guard/emlgo_test.go @@ -0,0 +1,216 @@ +package guard + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "mime" + "mime/multipart" + "net/mail" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/eml" +) + +// Go's standard library is the fourth reader of a mail message, and the one +// every service written in Go meets without a mail library: net/mail for the +// message, mime/multipart for the parts, Part.FileName for a name. Measured +// 2026-10-07 (docs/EML-2026-10-07.md section 5.1), it is the strictest of the +// four on structure - the only one that refused a message cut short, one with +// no closing boundary and one with a stray character in base64 - and the only +// one that disagrees about names: it reads none from Content-Type, and it hands +// back an RFC 2047 name inside quotes as the encoded text itself. The owner's +// decision of the same day put it in the guards, where it costs nothing to run. +// +// It runs in this process, so it needs no tool installed and runs everywhere. +// net/mail is linked into the guards only - the command line links no network +// package at all (D16), and a guard checks that. + +// goPart is one leaf of a message as the standard library reads it. +type goPart struct { + mediaType string + name string + attached bool + data []byte +} + +// goReadMail reads a message the way a Go service does and returns its leaves. +func goReadMail(b []byte) ([]goPart, *mail.Message, error) { + msg, err := mail.ReadMessage(bytes.NewReader(b)) + if err != nil { + return nil, nil, fmt.Errorf("ReadMessage: %w", err) + } + var out []goPart + err = goWalk(&out, msg.Header.Get("Content-Type"), msg.Header.Get("Content-Transfer-Encoding"), "", "", msg.Body) + return out, msg, err +} + +// goWalk reads one entity, going down into a multipart one - alternative +// inside mixed is the shape eml writes. +func goWalk(out *[]goPart, contentType, transfer, disposition, name string, body io.Reader) error { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + return fmt.Errorf("ParseMediaType %q: %w", contentType, err) + } + if strings.HasPrefix(mediaType, "multipart/") { + mr := multipart.NewReader(body, params["boundary"]) + for { + p, err := mr.NextPart() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("NextPart: %w", err) + } + if err := goWalk(out, p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), + p.Header.Get("Content-Disposition"), p.FileName(), p); err != nil { + return err + } + } + } + // A part reading quoted-printable decodes it itself and drops the header, + // so only base64 is left to undo here. + if strings.EqualFold(transfer, "base64") { + body = base64.NewDecoder(base64.StdEncoding, body) + } + data, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("reading a %s part: %w", mediaType, err) + } + *out = append(*out, goPart{mediaType: mediaType, name: name, attached: strings.HasPrefix(disposition, "attachment"), data: data}) + return nil +} + +// goNameFor is the name the standard library is measured to read for an +// attached file - the one each setting writes, but for the two places it was +// measured to read something else. +func goNameFor(props map[string]string, written string) string { + switch { + case props[eml.FilenameStyle] == eml.ContentType: + return "" + case props[eml.FilenameStyle] == eml.RFC2047 && props[eml.Headers] == eml.Encoded: + return "=?UTF-8?B?" + } + return written +} + +// TestGosStandardLibraryReadsEveryEMLTheWayItWasMeasured reads a message of +// every setting with the standard library and asks for three things: every +// attached file comes back byte for byte the file its own format writes with +// the seed it was given (RC4), every name is the one the settings write - or +// exactly the other thing this reader was measured to read - and the text +// parts are the ones body orders. +// +// The name of each file is asked of a setting that makes it plain to read, so +// the guard asserts that the disagreement is still there rather than assuming +// it: a reader that started decoding rfc2047 would turn this red, and the +// sentence beside filename_style would then be wrong. +func TestGosStandardLibraryReadsEveryEMLTheWayItWasMeasured(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + disagreed := 0 + for i, props := range emlCases { + props = withAttachments(props, "2", "pdf", "4kb") + b, plan := writeEML(t, d, 40000+int64(i)*101, uint64(70+i), props) + parts, _, err := goReadMail(b) + if err != nil { + t.Fatalf("%v: the standard library refused the message: %v", props, err) + } + var files []goPart + var texts []string + for _, p := range parts { + if p.attached { + files = append(files, p) + } else { + texts = append(texts, p.mediaType) + } + } + if want := textsFor(props[eml.Body]); strings.Join(texts, " ") != want { + t.Errorf("%v: the text parts are %v, and body orders %s", props, texts, want) + } + if len(files) != 2 { + t.Fatalf("%v: %d attached files read, 2 ordered", props, len(files)) + } + for n, f := range files { + if want := childBytes(t, "pdf", 4<<10, core.FileSeed(uint64(70+i), n)); !bytes.Equal(f.data, want) { + t.Errorf("%v: attached file %d is not the pdf its seed makes (%d B against %d B)", props, n+1, len(f.data), len(want)) + } + written := emlName(props, "pdf", n+1, ".pdf") + want := goNameFor(props, written) + if want == "=?UTF-8?B?" && strings.HasPrefix(f.name, want) || f.name == want { + disagreed += boolInt(want != written) + continue + } + t.Errorf("%v: the standard library read the name %q, and %q was measured", props, f.name, want) + } + if got := plan.Properties["mime_entities"]; got != len(parts)+entitiesAbove(props) { + t.Errorf("%v: the manifest counts %v MIME entities and the reader found %d leaves", props, got, len(parts)) + } + } + if disagreed == 0 { + t.Error("no case reached a name the standard library reads otherwise, so the measured disagreement was not asked about") + } +} + +// textsFor is the text parts body orders, as their media types in order. +func textsFor(body string) string { + switch body { + case eml.HTML: + return "text/html" + case eml.Alternative: + return "text/plain text/html" + } + return "text/plain" +} + +// entitiesAbove is how many entities hold the leaves: the message itself as +// multipart/mixed, and multipart/alternative when body orders it. +func entitiesAbove(props map[string]string) int { + if props[eml.Body] == eml.Alternative { + return 2 + } + return 1 +} + +// emlName is the name the generator gives an attached file, spelled here from +// the rule rather than read from the generator, so the two can disagree. +func emlName(props map[string]string, id string, n int, ext string) string { + name := fmt.Sprintf("%s_%04d%s", id, n, ext) + if props[eml.Headers] == eml.Encoded || props[eml.Headers] == eml.UTF8 { + name = "Załącznik_日本語_" + name + } + return name +} + +// childBytes is the file a format writes for a size and a seed, as the message +// is meant to carry it. +func childBytes(t *testing.T, id string, size int64, seed uint64) []byte { + t.Helper() + d, err := format.Get(id) + if err != nil { + t.Fatal(err) + } + p, err := d.Generator.Plan(format.Request{Bytes: size, Seed: seed, Label: true}) + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := d.Generator.Write(context.Background(), &buf, p); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/guard/oracle_test.go b/internal/guard/oracle_test.go index 01b7d3fa..7b061247 100644 --- a/internal/guard/oracle_test.go +++ b/internal/guard/oracle_test.go @@ -181,6 +181,11 @@ var structurallyChecked = map[string]bool{ // layer knows how many decimal places and which winding were ordered // (docs/GEOJSON-2026-10-07.md section 3). "geojson": true, + // Since 2026-10-07, from its first day: four mail readers took a message + // with no From line and a header line of 2000 characters without a word, + // and only this layer knows where each name was ordered to be written + // (docs/EML-2026-10-07.md section 5.1). + "eml": true, } func TestTheStructuralCheckerCoversEveryFormatItShould(t *testing.T) { diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 4aa73520..8acec4c4 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -128,6 +128,7 @@ var reachableFromTheWindow = []string{ "format:pptx", "format:json", "format:geojson", + "format:eml", "format:log", "format:avif", "format:jxl", @@ -161,6 +162,14 @@ var reachableFromTheWindow = []string{ "property:csv.line_ending", "property:csv.quote_style", "property:docx.paragraphs", + "property:eml.attachment_format", + "property:eml.attachment_size", + "property:eml.attachments", + "property:eml.body", + "property:eml.filename_style", + "property:eml.headers", + "property:eml.line_endings", + "property:eml.text_encoding", "property:pptx.slides", "property:xlsx.columns", "property:xlsx.rows", diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index 89689916..7a8ec5b2 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -164,6 +164,21 @@ "hash": "sha256-2c624232cdd2", "other": "8" }, + "Choice.body.alternative": { + "description": "One entry of the body list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-27747689baf9", + "other": "alternative" + }, + "Choice.body.html": { + "description": "One entry of the body list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-e5e239560bc1", + "other": "html" + }, + "Choice.body.plain": { + "description": "One entry of the body list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-a116c9ed46d6", + "other": "plain" + }, "Choice.compression.best": { "description": "One entry of the compression list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-8f4375ec65c2", @@ -479,6 +494,26 @@ "hash": "sha256-b4dc66dde806", "other": "off" }, + "Choice.filename_style.both": { + "description": "One entry of the filename_style list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-ff7772053abf", + "other": "both" + }, + "Choice.filename_style.content-type": { + "description": "One entry of the filename_style list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-846b3a9229ce", + "other": "content-type" + }, + "Choice.filename_style.rfc2047": { + "description": "One entry of the filename_style list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-1eeb8be955da", + "other": "rfc2047" + }, + "Choice.filename_style.rfc2231": { + "description": "One entry of the filename_style list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-c4a81dcefb6e", + "other": "rfc2231" + }, "Choice.format.avif": { "description": "One entry of the format list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-2f47c57aae31", @@ -499,6 +534,11 @@ "hash": "sha256-584cb925e6ad", "other": "docx" }, + "Choice.format.eml": { + "description": "One entry of the format list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-13033c0419d0", + "other": "eml" + }, "Choice.format.geojson": { "description": "One entry of the format list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-64bce794becb", @@ -734,6 +774,21 @@ "hash": "sha256-2b3b18120924", "other": "polygon" }, + "Choice.headers.ascii": { + "description": "One entry of the headers list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-c543ece81605", + "other": "ascii" + }, + "Choice.headers.encoded": { + "description": "One entry of the headers list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-766adc67b02b", + "other": "encoded" + }, + "Choice.headers.utf8": { + "description": "One entry of the headers list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-941b7ecd47e5", + "other": "utf8" + }, "Choice.ids.mixed": { "description": "One entry of the ids list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-3f8fee624f43", @@ -799,6 +854,16 @@ "hash": "sha256-39e741eddb03", "other": "lf" }, + "Choice.line_endings.crlf": { + "description": "One entry of the line_endings list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-c613dc516f32", + "other": "crlf" + }, + "Choice.line_endings.lf": { + "description": "One entry of the line_endings list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-39e741eddb03", + "other": "lf" + }, "Choice.methods.get": { "description": "One entry of the methods list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-2998b3232d29", @@ -934,6 +999,21 @@ "hash": "sha256-7587669ca42e", "other": "fragment" }, + "Choice.text_encoding.7bit": { + "description": "One entry of the text_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-af6ec1d82852", + "other": "7bit" + }, + "Choice.text_encoding.8bit": { + "description": "One entry of the text_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-e318653d2946", + "other": "8bit" + }, + "Choice.text_encoding.quoted-printable": { + "description": "One entry of the text_encoding list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", + "hash": "sha256-7956a9e2a40b", + "other": "quoted-printable" + }, "Choice.timestamps.advancing": { "description": "One entry of the timestamps list, as the list shows it. A recipe and the command line write the value itself, so a token such as utf-8, a4, sha256 or 644 stays as it is.", "hash": "sha256-3656fa4d5539", @@ -1029,6 +1109,46 @@ "hash": "sha256-5d0141e9b1da", "other": "How many paragraphs of text the document holds. The rest of the size you asked for is carried by a part of the package that is not shown." }, + "Detail.format/eml.attachment_format": { + "description": "The sentence under the attachment_format setting of the eml format, after what it takes.", + "hash": "sha256-991e7874cdb1", + "other": "The format of the attached files. Run tfg formats to see what this build supports." + }, + "Detail.format/eml.attachment_size": { + "description": "The sentence under the attachment_size setting of the eml format, after what it takes.", + "hash": "sha256-c6ca8985e21d", + "other": "How big each attached file is. The message grows by about a third more, because attached files travel in base64." + }, + "Detail.format/eml.attachments": { + "description": "The sentence under the attachments setting of the eml format, after what it takes.", + "hash": "sha256-43c96b5ef075", + "other": "How many files the message carries. Use contains instead when the files are not all alike. mailparser refuses a message of more than 1000 MIME entities, and the manifest gives this one's count as mime_entities." + }, + "Detail.format/eml.body": { + "description": "The sentence under the body setting of the eml format, after what it takes.", + "hash": "sha256-5dc479375b75", + "other": "Which text the message carries. plain is one text part and html one HTML part. alternative carries both with the same words, and a mail program shows one of them." + }, + "Detail.format/eml.filename_style": { + "description": "The sentence under the filename_style setting of the eml format, after what it takes.", + "hash": "sha256-96e791aa7eac", + "other": "How the names of attached files are written. rfc2231 is the standard way. rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. both writes the two, and content-type puts the name only where old mail programs looked for it. Go's standard library reads no name from content-type and the encoded text itself from rfc2047. Unless headers is encoded a name is written as it is and only where it goes changes, so rfc2047 and both write the same bytes." + }, + "Detail.format/eml.headers": { + "description": "The sentence under the headers setting of the eml format, after what it takes.", + "hash": "sha256-5002e2b42e63", + "other": "Whether the subject, the names of the sender and the recipient and the names of attached files carry letters outside ASCII, and how. encoded writes them the way RFC 2047 and RFC 2231 say. utf8 writes them as they are (RFC 6532), and Python's email package reads that and reports a defect in the From and To lines." + }, + "Detail.format/eml.line_endings": { + "description": "The sentence under the line_endings setting of the eml format, after what it takes.", + "hash": "sha256-42b29dfbf2d1", + "other": "How every line of the file ends. The standard is crlf. Python, MimeKit, mailparser and Go's standard library all read lf too, so the difference shows in programs that split the lines themselves." + }, + "Detail.format/eml.text_encoding": { + "description": "The sentence under the text_encoding setting of the eml format, after what it takes.", + "hash": "sha256-b8ce76fdb974", + "other": "How the text is written. 7bit keeps it to plain ASCII. quoted-printable and 8bit add a line in Polish and Japanese, because that is what they are for - quoted-printable spells those letters as =C5=BC, and 8bit writes them as they are." + }, "Detail.format/geojson.altitude": { "description": "The sentence under the altitude setting of the geojson format, after what it takes.", "hash": "sha256-702be3958a4b", @@ -1614,6 +1734,11 @@ "hash": "sha256-a1fb88e6372c", "other": "Word (Office Open XML)" }, + "Format.eml": { + "description": "What the eml format is called, beside its id in a list of formats. The name of a standard stays as it is - only a name that describes the file is translated.", + "hash": "sha256-eb653a6eee22", + "other": "Email message" + }, "Format.geojson": { "description": "What the geojson format is called, beside its id in a list of formats. The name of a standard stays as it is - only a name that describes the file is translated.", "hash": "sha256-84fe8f7f1c87", @@ -1739,6 +1864,11 @@ "hash": "sha256-eaca4b306928", "other": "ZIP" }, + "Group.format/eml.Attached files": { + "description": "The heading over a block of settings of the eml format.", + "hash": "sha256-ecc5a28d1f5e", + "other": "Attached files" + }, "Group.format/pdf.Document properties": { "description": "The heading over a block of settings of the pdf format.", "hash": "sha256-7115a778ab47", @@ -1829,6 +1959,21 @@ "hash": "sha256-cd0da1397b61", "other": "Antimeridian" }, + "Label.attachment_format": { + "description": "The name beside the box of a setting a recipe writes as attachment_format.", + "hash": "sha256-29f25b6c026f", + "other": "Attachment format" + }, + "Label.attachment_size": { + "description": "The name beside the box of a setting a recipe writes as attachment_size.", + "hash": "sha256-151d41e8a53c", + "other": "Attachment size" + }, + "Label.attachments": { + "description": "The name beside the box of a setting a recipe writes as attachments.", + "hash": "sha256-634de1147750", + "other": "Attachments" + }, "Label.author": { "description": "The name beside the box of a setting a recipe writes as author.", "hash": "sha256-d95082a2ee57", @@ -1844,6 +1989,11 @@ "hash": "sha256-cc62f2e89a82", "other": "Bit depth" }, + "Label.body": { + "description": "The name beside the box of a setting a recipe writes as body.", + "hash": "sha256-6ccaa6415b5e", + "other": "Body" + }, "Label.bom": { "description": "The name beside the box of a setting a recipe writes as bom.", "hash": "sha256-e39338101951", @@ -1979,6 +2129,11 @@ "hash": "sha256-50009ce1da4d", "other": "File" }, + "Label.filename_style": { + "description": "The name beside the box of a setting a recipe writes as filename_style.", + "hash": "sha256-fbfbf4d41f13", + "other": "Filename style" + }, "Label.folder": { "description": "The name beside the box of what the checksum-write tool works on.", "hash": "sha256-74ccd4330384", @@ -2019,6 +2174,11 @@ "hash": "sha256-ba5caa4285a8", "other": "Header" }, + "Label.headers": { + "description": "The name beside the box of a setting a recipe writes as headers.", + "hash": "sha256-194e9fe656a1", + "other": "Headers" + }, "Label.height": { "description": "The name beside the box of a setting a recipe writes as height.", "hash": "sha256-bfe2eca40179", @@ -2064,6 +2224,11 @@ "hash": "sha256-c4f7d0b83542", "other": "Line ending" }, + "Label.line_endings": { + "description": "The name beside the box of a setting a recipe writes as line_endings.", + "hash": "sha256-c948fba2b0dd", + "other": "Line endings" + }, "Label.methods": { "description": "The name beside the box of a setting a recipe writes as methods.", "hash": "sha256-8696622f3441", @@ -2174,6 +2339,11 @@ "hash": "sha256-68971283841a", "other": "Subject" }, + "Label.text_encoding": { + "description": "The name beside the box of a setting a recipe writes as text_encoding.", + "hash": "sha256-f6726ca0b9fe", + "other": "Text encoding" + }, "Label.timestamps": { "description": "The name beside the box of a setting a recipe writes as timestamps.", "hash": "sha256-755f099dbf49", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index b11523a7..1cf30bb2 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -32,6 +32,9 @@ "Choice.bit_depth.24": { "hash": "sha256-c2356069e9d1", "other": "24" }, "Choice.bit_depth.32": { "hash": "sha256-e29c9c180c62", "other": "32" }, "Choice.bit_depth.8": { "hash": "sha256-2c624232cdd2", "other": "8" }, + "Choice.body.alternative": { "hash": "sha256-27747689baf9", "other": "tekst i html" }, + "Choice.body.html": { "hash": "sha256-e5e239560bc1", "other": "html" }, + "Choice.body.plain": { "hash": "sha256-a116c9ed46d6", "other": "zwykły tekst" }, "Choice.compression.best": { "hash": "sha256-8f4375ec65c2", "other": "najlepsza" }, "Choice.compression.default": { "hash": "sha256-37a8eec1ce19", "other": "domyślna" }, "Choice.compression.fast": { "hash": "sha256-115dc3606fbf", "other": "szybka" }, @@ -95,10 +98,15 @@ "Choice.far-over.10x": { "hash": "sha256-687f5e475cbd", "other": "10x" }, "Choice.far-over.2x": { "hash": "sha256-b595b214b5d4", "other": "2x" }, "Choice.far-over.off": { "hash": "sha256-b4dc66dde806", "other": "wyłączone" }, + "Choice.filename_style.both": { "hash": "sha256-ff7772053abf", "other": "oba" }, + "Choice.filename_style.content-type": { "hash": "sha256-846b3a9229ce", "other": "content-type" }, + "Choice.filename_style.rfc2047": { "hash": "sha256-1eeb8be955da", "other": "rfc2047" }, + "Choice.filename_style.rfc2231": { "hash": "sha256-c4a81dcefb6e", "other": "rfc2231" }, "Choice.format.avif": { "hash": "sha256-2f47c57aae31", "other": "avif" }, "Choice.format.bmp": { "hash": "sha256-7685a5cb2b57", "other": "bmp" }, "Choice.format.csv": { "hash": "sha256-53bb9b0b38ac", "other": "csv" }, "Choice.format.docx": { "hash": "sha256-584cb925e6ad", "other": "docx" }, + "Choice.format.eml": { "hash": "sha256-13033c0419d0", "other": "eml" }, "Choice.format.geojson": { "hash": "sha256-64bce794becb", "other": "geojson" }, "Choice.format.gif": { "hash": "sha256-3b34c6906f7e", "other": "gif" }, "Choice.format.html": { "hash": "sha256-e5e239560bc1", "other": "html" }, @@ -146,6 +154,9 @@ "Choice.geometry.multipolygon": { "hash": "sha256-d9cfa92a41b7", "other": "multipoligon" }, "Choice.geometry.point": { "hash": "sha256-251fecd592e5", "other": "punkt" }, "Choice.geometry.polygon": { "hash": "sha256-2b3b18120924", "other": "poligon" }, + "Choice.headers.ascii": { "hash": "sha256-c543ece81605", "other": "ascii" }, + "Choice.headers.encoded": { "hash": "sha256-766adc67b02b", "other": "kodowane" }, + "Choice.headers.utf8": { "hash": "sha256-941b7ecd47e5", "other": "utf8" }, "Choice.ids.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" }, "Choice.ids.none": { "hash": "sha256-140bedbf9c3f", "other": "brak" }, "Choice.ids.number": { "hash": "sha256-12886f9d0005", "other": "liczba" }, @@ -159,6 +170,8 @@ "Choice.level_mix.realistic": { "hash": "sha256-9d2f45fd706a", "other": "realistycznie" }, "Choice.line_ending.crlf": { "hash": "sha256-c613dc516f32", "other": "crlf" }, "Choice.line_ending.lf": { "hash": "sha256-39e741eddb03", "other": "lf" }, + "Choice.line_endings.crlf": { "hash": "sha256-c613dc516f32", "other": "crlf" }, + "Choice.line_endings.lf": { "hash": "sha256-39e741eddb03", "other": "lf" }, "Choice.methods.get": { "hash": "sha256-2998b3232d29", "other": "get" }, "Choice.methods.mixed": { "hash": "sha256-3f8fee624f43", "other": "mieszane" }, "Choice.methods.read": { "hash": "sha256-3316348dbadf", "other": "odczyt" }, @@ -186,6 +199,9 @@ "Choice.status_mix.success": { "hash": "sha256-aee408847d35", "other": "sukcesy" }, "Choice.structure.document": { "hash": "sha256-43cc23fa52b8", "other": "dokument" }, "Choice.structure.fragment": { "hash": "sha256-7587669ca42e", "other": "fragment" }, + "Choice.text_encoding.7bit": { "hash": "sha256-af6ec1d82852", "other": "7bit" }, + "Choice.text_encoding.8bit": { "hash": "sha256-e318653d2946", "other": "8bit" }, + "Choice.text_encoding.quoted-printable": { "hash": "sha256-7956a9e2a40b", "other": "quoted-printable" }, "Choice.timestamps.advancing": { "hash": "sha256-3656fa4d5539", "other": "rosnące" }, "Choice.timestamps.fixed": { "hash": "sha256-992a93455c71", "other": "stałe" }, "Choice.unlocated.all": { "hash": "sha256-5ef5ef0364b6", "other": "wszystkie" }, @@ -205,6 +221,14 @@ "Detail.format/csv.line_ending": { "hash": "sha256-d802b082ce8b", "other": "Jak kończy się każdy wiersz. Wybierz crlf, żeby dostać kształt, którego wymaga RFC 4180 i który zapisuje Excel." }, "Detail.format/csv.quote_style": { "hash": "sha256-a2427ed0240b", "other": "Które pola mają cudzysłowy. Gdy wybierzesz brak, opis przestaje też zawierać separatory, bo pole bez cudzysłowu nie może go pomieścić." }, "Detail.format/docx.paragraphs": { "hash": "sha256-5d0141e9b1da", "other": "Ile akapitów tekstu zawiera dokument. Resztę zamówionego rozmiaru niesie niewidoczna część paczki." }, + "Detail.format/eml.attachment_format": { "hash": "sha256-991e7874cdb1", "other": "Format załączonych plików. Uruchom tfg formats, żeby zobaczyć, co obsługuje ta wersja." }, + "Detail.format/eml.attachment_size": { "hash": "sha256-c6ca8985e21d", "other": "Jak duży jest każdy załączony plik. Wiadomość rośnie o mniej więcej jedną trzecią więcej, bo załączone pliki są przesyłane w base64." }, + "Detail.format/eml.attachments": { "hash": "sha256-43c96b5ef075", "other": "Ile plików niesie wiadomość. Gdy pliki nie są jednakowe, użyj zamiast tego contains. mailparser odmawia wiadomości z więcej niż 1000 encji MIME, a manifest podaje ich liczbę dla tej wiadomości jako mime_entities." }, + "Detail.format/eml.body": { "hash": "sha256-5dc479375b75", "other": "Jaki tekst niesie wiadomość. zwykły tekst to jedna część tekstowa, a html jedna część HTML. tekst i html niesie obie z tymi samymi słowami, a program pocztowy pokazuje jedną z nich." }, + "Detail.format/eml.filename_style": { "hash": "sha256-96e791aa7eac", "other": "Jak zapisane są nazwy załączonych plików. rfc2231 to sposób standardowy. rfc2047 to forma kodowana, której RFC 2047 zabrania wewnątrz nazwy, a wiele czytników i tak ją dekoduje. oba zapisuje obie, a content-type umieszcza nazwę tylko tam, gdzie szukały jej stare programy pocztowe. Biblioteka standardowa Go nie czyta nazwy z content-type, a z rfc2047 oddaje sam zakodowany tekst. Gdy headers nie jest ustawione na kodowane, nazwa jest zapisana tak, jak jest, i zmienia się tylko jej miejsce, więc rfc2047 i oba zapisują te same bajty." }, + "Detail.format/eml.headers": { "hash": "sha256-5002e2b42e63", "other": "Czy temat, nazwy nadawcy i odbiorcy oraz nazwy załączonych plików mają litery spoza ASCII, i jak są zapisane. kodowane zapisuje je tak, jak każą RFC 2047 i RFC 2231. utf8 zapisuje je tak, jak są (RFC 6532), a pakiet email Pythona czyta to i zgłasza defekt w liniach From i To." }, + "Detail.format/eml.line_endings": { "hash": "sha256-42b29dfbf2d1", "other": "Jak kończy się każda linia pliku. Standardem jest crlf. Python, MimeKit, mailparser i biblioteka standardowa Go czytają też lf, więc różnicę widać w programach, które same dzielą linie." }, + "Detail.format/eml.text_encoding": { "hash": "sha256-b8ce76fdb974", "other": "Jak zapisany jest tekst. 7bit trzyma go w czystym ASCII. quoted-printable i 8bit dodają linię po polsku i po japońsku, bo do tego służą - quoted-printable zapisuje te litery jako =C5=BC, a 8bit tak, jak są." }, "Detail.format/geojson.altitude": { "hash": "sha256-702be3958a4b", "other": "Czy każda pozycja niesie trzecią liczbę, wysokość w metrach od -400 do 8848. Program, który czyta dwie liczby na pozycję, musi ją pominąć albo odrzucić plik." }, "Detail.format/geojson.antimeridian": { "hash": "sha256-e7755a355839", "other": "Czy każda linia i każdy obrys przecina 180. południk bez rozcięcia na dwie części. RFC 7946 każe takie kształty rozcinać, a czytniki się różnią - czytany jako płaskie współrzędne, przecinający obrys przecina sam siebie i biegnie w drugą stronę. Pojedynczy punkt nie może przeciąć i leży obok." }, "Detail.format/geojson.bbox": { "hash": "sha256-2046a38a38a9", "other": "Czy kolekcja i każdy obiekt z położeniem niosą bbox, prostokąt, w którym leżą ich współrzędne. Bbox kolekcji stoi za jej obiektami, bo jej zasięg jest znany dopiero po ostatnim, a prostokąt przecinający antypołudnik ma zachodnią krawędź większą niż wschodnią, jak każe RFC 7946." }, @@ -322,6 +346,7 @@ "Format.bmp": { "hash": "sha256-a9f24d64c3d9", "other": "Windows Bitmap" }, "Format.csv": { "hash": "sha256-6c61f1d4908c", "other": "Comma-Separated Values" }, "Format.docx": { "hash": "sha256-a1fb88e6372c", "other": "Word (Office Open XML)" }, + "Format.eml": { "hash": "sha256-eb653a6eee22", "other": "Wiadomość e-mail" }, "Format.geojson": { "hash": "sha256-84fe8f7f1c87", "other": "GeoJSON" }, "Format.gif": { "hash": "sha256-9a8783e957eb", "other": "Graphics Interchange Format" }, "Format.html": { "hash": "sha256-0bd4039bc33f", "other": "HyperText Markup Language" }, @@ -347,6 +372,7 @@ "Format.xml": { "hash": "sha256-f4210757fbab", "other": "Extensible Markup Language" }, "Format.yaml": { "hash": "sha256-9b6e8abe425e", "other": "YAML" }, "Format.zip": { "hash": "sha256-eaca4b306928", "other": "ZIP" }, + "Group.format/eml.Attached files": { "hash": "sha256-ecc5a28d1f5e", "other": "Załączone pliki" }, "Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" }, "Input.checksum-check.checksum_file": { "hash": "sha256-90d7baf0d228", "other": "Plik sum kontrolnych. Ścieżki w nim są czytane z folderu, w którym leży, i nic nie jest zapisywane." }, "Input.checksum-write.folder": { "hash": "sha256-3040f185111b", "other": "Folder do spisania. Każdy plik w nim jest czytany, a plik sum kontrolnych jest zapisywany do niego." }, @@ -365,9 +391,13 @@ "Label.allow": { "hash": "sha256-e213c161d5ce", "other": "Dozwolone" }, "Label.altitude": { "hash": "sha256-cbc2e75740ca", "other": "Wysokość" }, "Label.antimeridian": { "hash": "sha256-cd0da1397b61", "other": "Antypołudnik" }, + "Label.attachment_format": { "hash": "sha256-29f25b6c026f", "other": "Format załączników" }, + "Label.attachment_size": { "hash": "sha256-151d41e8a53c", "other": "Rozmiar załączników" }, + "Label.attachments": { "hash": "sha256-634de1147750", "other": "Liczba załączników" }, "Label.author": { "hash": "sha256-d95082a2ee57", "other": "Autor" }, "Label.bbox": { "hash": "sha256-708f44b1bd98", "other": "Ramka (bbox)" }, "Label.bit_depth": { "hash": "sha256-cc62f2e89a82", "other": "Głębia bitowa" }, + "Label.body": { "hash": "sha256-6ccaa6415b5e", "other": "Treść" }, "Label.bom": { "hash": "sha256-e39338101951", "other": "BOM" }, "Label.bulk": { "hash": "sha256-3140100df131", "other": "Przesyłanie masowe" }, "Label.bytes": { "hash": "sha256-4d8000301fcc", "other": "Bajty" }, @@ -395,6 +425,7 @@ "Label.expected": { "hash": "sha256-ca99b7f1b14e", "other": "Oczekiwana suma" }, "Label.far-over": { "hash": "sha256-874367c30d4e", "other": "Daleko ponad limit" }, "Label.file": { "hash": "sha256-50009ce1da4d", "other": "Plik" }, + "Label.filename_style": { "hash": "sha256-fbfbf4d41f13", "other": "Zapis nazw plików" }, "Label.folder": { "hash": "sha256-74ccd4330384", "other": "Folder" }, "Label.format": { "hash": "sha256-2f343666aaa8", "other": "Format" }, "Label.formats": { "hash": "sha256-9f01769a4278", "other": "Formaty" }, @@ -403,6 +434,7 @@ "Label.frames": { "hash": "sha256-2c447e8d0598", "other": "Klatki" }, "Label.geometry": { "hash": "sha256-fa9da5754ccf", "other": "Geometria" }, "Label.header": { "hash": "sha256-ba5caa4285a8", "other": "Nagłówek" }, + "Label.headers": { "hash": "sha256-194e9fe656a1", "other": "Nagłówki" }, "Label.height": { "hash": "sha256-bfe2eca40179", "other": "Wysokość" }, "Label.holes": { "hash": "sha256-158a3b71a54c", "other": "Dziury" }, "Label.ids": { "hash": "sha256-d9aa9d4aca97", "other": "Identyfikatory" }, @@ -412,6 +444,7 @@ "Label.level_mix": { "hash": "sha256-cc4f6ea7c7fc", "other": "Poziomy ważności" }, "Label.limit": { "hash": "sha256-674b0ed54bf7", "other": "Limit" }, "Label.line_ending": { "hash": "sha256-c4f7d0b83542", "other": "Koniec linii" }, + "Label.line_endings": { "hash": "sha256-c948fba2b0dd", "other": "Końce linii" }, "Label.methods": { "hash": "sha256-8696622f3441", "other": "Metody" }, "Label.modified": { "hash": "sha256-e8ce5dcaf408", "other": "Zmodyfikowano" }, "Label.orientation": { "hash": "sha256-e3d11242200f", "other": "Orientacja" }, @@ -434,6 +467,7 @@ "Label.status_mix": { "hash": "sha256-e5e3006b5ecd", "other": "Kody odpowiedzi" }, "Label.structure": { "hash": "sha256-7203c2b3dad3", "other": "Struktura" }, "Label.subject": { "hash": "sha256-68971283841a", "other": "Temat" }, + "Label.text_encoding": { "hash": "sha256-f6726ca0b9fe", "other": "Kodowanie tekstu" }, "Label.timestamps": { "hash": "sha256-755f099dbf49", "other": "Znaczniki czasu" }, "Label.title": { "hash": "sha256-7e8cd2056da7", "other": "Tytuł" }, "Label.unlocated": { "hash": "sha256-9d9b671e4837", "other": "Bez położenia" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index 8ca764d4..b6f90ee0 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -521,6 +521,31 @@ "one": "{{.Count}} paragraph", "other": "{{.Count}} paragraphs" }, + "eml.AMessageHoldsItsHeaderLines": { + "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-5b3dd4df2ace", + "other": "a message holds its header lines, its parts and every attached file in base64, which is about a third larger than the file itself" + }, + "eml.AMessageThisLargeCannotBe": { + "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-ff6a6f0065c8", + "other": "a message this large cannot be counted to the byte in this build" + }, + "eml.AskForAttachmentsOrFewer": { + "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Max}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-2145e05752c5", + "other": "Ask for {{.Max}} attachments or fewer." + }, + "eml.AskForSmallerAttachments": { + "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-871e78c75c85", + "other": "Ask for fewer or smaller attachments." + }, + "eml.TheAttachedFileCannotBe": { + "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Format}}, {{.Err}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-cb66d6df0c0f", + "other": "eml: the attached {{.Format}} file cannot be made: {{.Err}}" + }, "engine.CannotCreateOutDir": { "description": "Said by internal/engine - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Dir}}, {{.Cause}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-324037f71709", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index fcd7eb95..ab103032 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -103,6 +103,11 @@ "damage.UnknownWhat": { "hash": "sha256-cfd2875327a9", "other": "nie ma uszkodzenia o nazwie {{.Damage}}" }, "damage.UnknownWhy": { "hash": "sha256-f2720ea32a8a", "other": "uszkodzenie musi być znane tej wersji, a nieznane zostawiłoby plik nienaruszony, podczas gdy manifest nazywałby go uszkodzonym" }, "docx.Paragraphs": { "hash": "sha256-45316182b77a", "one": "{{.Count}} akapitem", "few": "{{.Count}} akapitami", "many": "{{.Count}} akapitami", "other": "{{.Count}} akapitu" }, + "eml.AMessageHoldsItsHeaderLines": { "hash": "sha256-5b3dd4df2ace", "other": "wiadomość zawiera swoje linie nagłówka, swoje części i każdy załączony plik w base64, który jest o mniej więcej jedną trzecią większy niż sam plik" }, + "eml.AMessageThisLargeCannotBe": { "hash": "sha256-ff6a6f0065c8", "other": "tak dużej wiadomości ta wersja nie potrafi policzyć co do bajta" }, + "eml.AskForAttachmentsOrFewer": { "hash": "sha256-2145e05752c5", "other": "Poproś o najwyżej {{.Max}} załączników." }, + "eml.AskForSmallerAttachments": { "hash": "sha256-871e78c75c85", "other": "Poproś o mniej albo mniejsze załączniki." }, + "eml.TheAttachedFileCannotBe": { "hash": "sha256-cb66d6df0c0f", "other": "eml: załączony plik {{.Format}} nie może powstać: {{.Err}}" }, "engine.CannotCreateOutDir": { "hash": "sha256-324037f71709", "other": "nie można utworzyć katalogu wyjściowego {{.Dir}}: {{.Cause}}" }, "engine.CannotStart": { "hash": "sha256-114c2e153b9d", "other": "nie można zacząć przebiegu w {{.Dir}}: {{.Cause}}" }, "engine.Character": { "hash": "sha256-add48a171334", "other": "znak {{.Character}}" }, diff --git a/internal/oracle/mailscripts.go b/internal/oracle/mailscripts.go new file mode 100644 index 00000000..0bf0f839 --- /dev/null +++ b/internal/oracle/mailscripts.go @@ -0,0 +1,114 @@ +package oracle + +import ( + "os" + "strings" +) + +// pythonEmail is Python's email package with policy.default - the parser a +// Python service meets first, which is neither our code nor our language. +// +// It reads nearly anything, measured 2026-10-07 (docs/EML-2026-10-07.md +// section 5.1): of ten broken messages it refused none. What it does instead is +// list defects, and it listed one for every broken structure in the sample - a +// file cut short, a missing closing boundary, a stray character in base64, a +// boundary inside the text. So a defect is a refusal here, the way a warning is +// for GDAL and ffmpeg. What it says nothing about - a missing From line, a line +// past 998 characters - the format's guard asks strict.py. +var pythonEmail = Checker{ + Name: "python email", + find: inPath("python"), + args: func(p string) []string { return []string{"-c", pythonEmailScript, p, ""} }, + accept: expectOK("python email"), +} + +// PythonEmail is the same reader with defects it is told to expect. +// +// One exists today: a header written in UTF-8 as RFC 6532 allows, which the +// package reads correctly and still reports as UndecodableBytesDefect in the +// From and To lines (measured on the program's own messages, 2026-10-07). headers=utf8 asks for exactly that, so its guard passes the name +// rather than the checker passing every defect. +func PythonEmail(path string, allowed ...string) Result { + c := pythonEmail + c.args = func(p string) []string { + return []string{"-c", pythonEmailScript, p, strings.Join(allowed, ",")} + } + return c.Check(path) +} + +const pythonEmailScript = ` +import email, hashlib, json, sys +from email import policy + +# The names printed below are UTF-8 whatever the console's code page is. +sys.stdout.reconfigure(encoding="utf-8") +path, allowed = sys.argv[1], set(a for a in sys.argv[2].split(",") if a) +with open(path, "rb") as f: + msg = email.message_from_bytes(f.read(), policy=policy.default) + +defects = [] +for key in ("subject", "from", "to", "date", "message-id"): + value = msg.get(key) + if value is not None: + defects += ["%s:%s" % (key, type(d).__name__) for d in getattr(value, "defects", ())] + +parts = [] +for part in msg.walk(): + if part.is_multipart(): + continue + if part.get_content_disposition() == "attachment": + data = part.get_payload(decode=True) or b"" + parts.append({"name": part.get_filename(), "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}) + else: + part.get_content() +for part in msg.walk(): + defects += [type(d).__name__ for d in part.defects] + +unexpected = sorted(set(d for d in defects if d not in allowed)) +if unexpected: + print("FAIL defects: " + ", ".join(unexpected)) + sys.exit(1) +print("OK " + json.dumps({"subject": str(msg.get("subject")), "attachments": parts}, ensure_ascii=False)) +` + +// MailParser is mailparser, the parser behind nodemailer and much of the mail +// handling written in Node - the third language this format is read in. +// +// It lists no defects, measured on the same day: it read every broken message +// in silence, two of them with the wrong bytes. What it is here for is what it +// refuses that nobody else does - a message of more than a thousand MIME +// entities - and the names and bytes it reads back. Where it lives is the one +// thing node cannot find on its own: TFG_MAILPARSER names the installed +// package, and without it the guard skips loudly. +func MailParser(path string) Result { + module := os.Getenv("TFG_MAILPARSER") + return Checker{ + Name: "mailparser", + find: func() (string, bool) { + if module == "" { + return "", false + } + return inPath("node")() + }, + args: func(p string) []string { return []string{"-e", mailParserScript, module, p} }, + accept: expectOK("mailparser"), + }.Check(path) +} + +const mailParserScript = ` +const crypto = require("crypto"); +const fs = require("fs"); +const [module, path] = process.argv.slice(1); +const { simpleParser } = require(module); +simpleParser(fs.readFileSync(path)).then((m) => { + const attachments = m.attachments.map((a) => ({ + name: a.filename === undefined ? null : a.filename, + bytes: a.content.length, + sha256: crypto.createHash("sha256").update(a.content).digest("hex"), + })); + console.log("OK " + JSON.stringify({ subject: m.subject, attachments })); +}).catch((e) => { + console.log("FAIL " + e.message); + process.exit(1); +}); +` diff --git a/internal/oracle/oracle.go b/internal/oracle/oracle.go index bb7148d7..79841a26 100644 --- a/internal/oracle/oracle.go +++ b/internal/oracle/oracle.go @@ -351,6 +351,9 @@ var checkers = map[string]Checker{ // GDAL's GeoJSON driver. See geoscripts.go. "gdal-geojson": gdalGeoJSON, + + // Python's email package. See mailscripts.go. + "python-email": pythonEmail, } // DecodedFrames is how many frames the libaom oracle decoded, read from what @@ -541,6 +544,9 @@ func StrictKnows(formatID string) bool { // From its first day, because GDAL alone took files this one refuses // (docs/GEOJSON-2026-10-07.md section 3). "geojson", + // The same reason for mail: four readers took a message with no From + // line (docs/EML-2026-10-07.md section 5.1). + "eml", "bmp", "gif", "ico", "jpg", "tiff", "webp", "avif", "jxl", "docx", "xlsx", "pptx", // The two text formats joined on 2026-09-07, when they gained an // encoding. Before that there was nothing here to check against diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index d24b4ae0..1cf389bd 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -2550,6 +2550,268 @@ def geo_collection_box(doc, boxes, located, settings): geo_bbox("the collection", doc["bbox"], lo, hi, settings["precision"]) +EML_INTERNATIONAL = "Zażółć gęślą jaźń. 日本語のテキストです。" +EML_WIDE_PREFIX = "Załącznik_日本語_" +EML_BASE64_LINE = re.compile(rb"^[A-Za-z0-9+/]+={0,2}$") +EML_NAME = re.compile(r"^(?:" + EML_WIDE_PREFIX + r")?[a-z0-9]+_\d{4}\.[a-z0-9.]+$") + + +def check_eml(data, settings=None): + """A mail message to RFC 5322 and MIME, made the way it was ordered. + + Measured 2026-10-07 (docs/EML-2026-10-07.md section 5.1): Python's email + package, mailparser, MimeKit and Go's standard library took a message with + no From line, a header line of 2000 characters and a line of base64 13 336 + long without a word, and two of them returned the wrong bytes of a broken + attachment in silence. What only this layer asks: the header lines in their + order and once each, no line past 998 characters and none past 78 but a + media type too long to fold, every line ending the way line_endings says, + the boundaries nowhere inside a part, the text in the encoding and the + character set ordered with the same words in both parts of alternative, + base64 in full lines of 76, and every name written where and how + filename_style and headers say. + """ + given = settings or {} + s = {k: given.get(k, v) for k, v in ( + ("body", "plain"), ("text_encoding", "7bit"), ("headers", "ascii"), + ("line_endings", "crlf"), ("filename_style", "rfc2231"), ("attachments", "0"))} + eol = b"\r\n" if s["line_endings"] == "crlf" else b"\n" + eml_line_endings(data, eol) + for number, line in enumerate(data.split(eol), start=1): + eml_line_length(number, line) + head, body = eml_split(data, eol, "the message") + fields = eml_fields(head, eol, "the message") + eml_headers(fields, s) + leaves = [] + eml_entity(dict(fields), body, eol, s, leaves, top=True) + texts = [leaf for leaf in leaves if leaf["kind"] == "text"] + files = [leaf for leaf in leaves if leaf["kind"] == "file"] + want_texts = {"plain": ["text/plain"], "html": ["text/html"], "alternative": ["text/plain", "text/html"]}[s["body"]] + if [leaf["type"] for leaf in texts] != want_texts: + fail(f"the text parts are {[leaf['type'] for leaf in texts]}, and body={s['body']} makes {want_texts}") + if len(texts) == 2 and eml_words(texts[0]["text"]) != eml_words(re.sub(r"<[^>]*>", " ", texts[1]["text"])): + fail("the two parts of alternative do not carry the same words") + if len(files) != int(s["attachments"]): + fail(f"{len(files)} attached files, and {s['attachments']} were ordered") + ok(f"{len(files)} attached files, {sum(len(f['bytes']) for f in files)} B of them, body {s['body']}, " + f"text {s['text_encoding']}, headers {s['headers']}, names {s['filename_style']}") + + +def eml_line_endings(data, eol): + """Every line ends in eol and in nothing else - no lone carriage return, and + no line feed without its return when the endings are crlf.""" + if eol == b"\n": + if b"\r" in data: + fail("a carriage return in a message whose lines end in lf") + return + if re.search(rb"\r(?!\n)|(? 998: + fail(f"line {number} is {len(text)} characters, past the 998 RFC 5322 allows") + if len(text) > 78 and not re.fullmatch(r"Content-Type: [a-z]+/[a-z0-9.+-]+;?", text): + fail(f"line {number} is {len(text)} characters, past the 78 RFC 5322 asks for: {text[:60]!r}") + + +def eml_split(data, eol, where): + """The header lines of an entity and what follows the blank line after them.""" + cut = data.find(eol + eol) + if cut < 0: + fail(f"{where} has no blank line after its header lines") + return data[:cut], data[cut + 2 * len(eol):] + + +def eml_fields(head, eol, where): + """The header fields, unfolded, as (name, value) in their order.""" + out = [] + for line in head.split(eol): + if line[:1] in (b" ", b"\t"): + if not out: + fail(f"{where} starts with a folded line") + out[-1] = (out[-1][0], out[-1][1] + line.decode("utf-8")) + continue + name, sep, value = line.decode("utf-8").partition(": ") + if not sep: + fail(f"{where} has a header line with no name: {line[:40]!r}") + out.append((name, value)) + return out + + +def eml_headers(fields, s): + """The lines a message opens with, in order and once each - From and Date + are the two RFC 5322 requires - and written the way headers says.""" + import email.utils + + names = [name for name, _ in fields] + want = ["From", "To", "Date", "Message-ID", "Subject", "MIME-Version", "Content-Type"] + if names[:7] != want or names[7:] not in ([], ["Content-Transfer-Encoding"]): + fail(f"the message opens with {names}, not {want}") + values = dict(fields) + if values["MIME-Version"] != "1.0": + fail(f"MIME-Version is {values['MIME-Version']!r}") + if not re.fullmatch(r"<[0-9a-f]{16}@example\.com>", values["Message-ID"]): + fail(f"the Message-ID {values['Message-ID']!r} is not the one this tool writes") + if email.utils.parsedate_to_datetime(values["Date"]) is None: + fail(f"the Date {values['Date']!r} is not an RFC 5322 date") + people = " ".join(values[k] for k in ("From", "To", "Subject")) + wide = any(ord(c) > 127 for c in people) + words = "=?UTF-8?B?" in people + if (s["headers"], wide, words) not in (("ascii", False, False), ("encoded", False, True), ("utf8", True, False)): + fail(f"headers={s['headers']} and the From, To and Subject lines hold " + f"{'letters outside ASCII' if wide else 'ASCII only'}{' and encoded words' if words else ''}") + for key, address in (("From", "alice@example.com"), ("To", "bob@example.org")): + if not values[key].endswith(" <" + address + ">"): + fail(f"{key} is {values[key]!r}, not an address in a domain RFC 2606 keeps for examples") + + +def eml_params(value): + """A header value as its first word and its parameters, quotes kept.""" + parts = re.findall(r'(?:[^;"]|"[^"]*")+', value) + params = {} + for p in parts[1:]: + key, _, v = p.strip().partition("=") + params[key] = v + return parts[0].strip(), params + + +def eml_entity(headers, body, eol, s, leaves, top): + """One entity: a multipart one walked part by part, a leaf judged as text or + as an attached file.""" + kind, params = eml_params(headers["Content-Type"]) + if not kind.startswith("multipart/"): + leaves.append(eml_text(kind, params, headers, body, s) if "Content-Disposition" not in headers + else eml_file(kind, params, headers, body, eol, s)) + return + boundary = params.get("boundary", "").strip('"').encode() + if not boundary.startswith(b"=_"): + fail(f"the boundary {boundary!r} is not one that base64 and quoted-printable cannot produce") + opener, close = b"--" + boundary + eol, eol + b"--" + boundary + b"--" + end = body.rfind(close) + if not body.startswith(opener) or end < 0: + fail(f"{kind} does not open with its boundary or has no closing one") + if body[end + len(close):] != (eol if top else b""): + fail(f"{kind} has something after its closing boundary") + for piece in body[len(opener):end].split(eol + b"--" + boundary + eol): + if any(line.startswith(b"--" + boundary) for line in piece.split(eol)): + fail(f"the boundary of {kind} appears inside one of its parts") + part_head, part_body = eml_split(piece, eol, f"a part of {kind}") + eml_entity(dict(eml_fields(part_head, eol, f"a part of {kind}")), part_body, eol, s, leaves, top=False) + + +def eml_text(kind, params, headers, body, s): + """A text part in the character set and the encoding text_encoding orders, + with the line in Polish and Japanese exactly when it is not 7bit.""" + charset = "us-ascii" if s["text_encoding"] == "7bit" else "utf-8" + if params.get("charset") != charset or headers.get("Content-Transfer-Encoding") != s["text_encoding"]: + fail(f"a {kind} part says charset {params.get('charset')} in {headers.get('Content-Transfer-Encoding')}, " + f"and text_encoding={s['text_encoding']} makes {charset} in {s['text_encoding']}") + eol = "\r\n" if s["line_endings"] == "crlf" else "\n" + if s["text_encoding"] == "quoted-printable": + text = eml_unquote(body, eol.encode()) + else: + text = body.decode("utf-8" if s["text_encoding"] == "8bit" else "ascii") + if (EML_INTERNATIONAL in text) != (s["text_encoding"] != "7bit"): + fail(f"the {kind} part {'lacks' if s['text_encoding'] != '7bit' else 'carries'} the line in Polish and Japanese") + for line in text.split(eol): + if line.startswith(("From ", "--")) or line == ".": + fail(f"a line of the {kind} part starts the way a boundary, an mbox file or SMTP reads: {line[:20]!r}") + return {"kind": "text", "type": kind, "text": text} + + +def eml_unquote(body, eol): + """Quoted-printable decoded, after the lines are checked against RFC 2045 + section 6.7: at most 76 characters, no space at the end, no = that is not + a soft break or two upper case hexadecimal digits.""" + out = bytearray() + for line in body.split(eol): + if len(line) > 76 or line[-1:] in (b" ", b"\t"): + fail(f"a quoted-printable line is {len(line)} characters or ends in white space") + soft = line.endswith(b"=") + line = line[:-1] if soft else line + if re.search(rb"=(?![0-9A-F]{2})", line): + fail(f"a quoted-printable line holds an = that is not =XX: {line[:40]!r}") + out += re.sub(rb"=([0-9A-F]{2})", lambda m: bytes([int(m.group(1), 16)]), line) + if not soft: + out += eol + return bytes(out[: -len(eol)]).decode("utf-8") + + +def eml_words(text): + return re.findall(r"[^\s<>]+", text) + + +def eml_file(kind, params, headers, body, eol, s): + """An attached file: base64 in full lines of 76, and its name where and how + filename_style and headers put it.""" + disposition, dparams = eml_params(headers["Content-Disposition"]) + if disposition != "attachment" or headers.get("Content-Transfer-Encoding") != "base64": + fail(f"an attached {kind} file is {disposition} in {headers.get('Content-Transfer-Encoding')}, not attachment in base64") + lines = body.split(eol) if body else [] + for i, line in enumerate(lines): + if not EML_BASE64_LINE.match(line) or (i < len(lines) - 1 and len(line) != 76) or len(line) > 76: + fail(f"line {i + 1} of an attached {kind} file is not a line of base64 76 long: {line[:40]!r}") + import base64 + + data = base64.b64decode(b"".join(lines), validate=True) + name = eml_file_name(kind, params, dparams, s) + if not EML_NAME.match(name) or name.startswith(EML_WIDE_PREFIX) != (s["headers"] != "ascii"): + fail(f"an attached file is named {name!r}, not the way headers={s['headers']} names one") + return {"kind": "file", "type": kind, "name": name, "bytes": data} + + +def eml_file_name(kind, params, dparams, s): + """The name of an attached file, read from where filename_style says it is + and nowhere else, decoded the way it says it is written.""" + import base64 + from urllib.parse import unquote + + style, encoded = s["filename_style"], s["headers"] == "encoded" + in_type = "name" in params + in_disposition = sorted(k for k in dparams if k.startswith("filename")) + want_type = style != "rfc2231" + if in_type != want_type or bool(in_disposition) == (style == "content-type"): + fail(f"filename_style={style} and the name is {'in' if in_type else 'not in'} Content-Type and " + f"{'in' if in_disposition else 'not in'} Content-Disposition") + names = [] + if in_type: + names.append(eml_quoted(params["name"], encoded)) + if in_disposition == ["filename"]: + names.append(eml_quoted(dparams["filename"], encoded and style == "rfc2047")) + elif in_disposition: + if not encoded or style == "rfc2047": + fail(f"filename_style={style} with headers={s['headers']} writes the name in RFC 2231 form") + joined = "".join(dparams[k] for k in sorted(in_disposition, key=lambda k: int(re.sub(r"\D", "", k) or 0))) + if not joined.startswith("UTF-8''"): + fail(f"an RFC 2231 name does not say UTF-8: {joined[:30]!r}") + names.append(unquote(joined[len("UTF-8''"):], encoding="utf-8", errors="strict")) + if len(set(names)) != 1: + fail(f"the attached {kind} file has two names: {names}") + return names[0] + + +def eml_quoted(value, encoded): + """A name in quotes, as it is or as RFC 2047 words when encoded is true.""" + import base64 + + if not (value.startswith('"') and value.endswith('"')): + fail(f"a name is not in quotes: {value[:40]!r}") + inner = value[1:-1] + words = re.findall(r"=\?UTF-8\?B\?([A-Za-z0-9+/=]+)\?=", inner) + if not encoded: + if words: + fail(f"a name is in encoded words that headers does not ask for: {inner[:40]!r}") + return inner + if not words or re.sub(r"=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=", "", inner).strip(): + fail(f"a name is not encoded words and nothing else: {inner[:40]!r}") + return b"".join(base64.b64decode(w) for w in words).decode("utf-8") + + CHECKS = {"png": check_png, "wav": check_wav, "pdf": check_pdf, "zip": check_zip, "log": check_log, "csv": check_csv, "json": check_json, "xml": check_xml, "svg": check_svg, "html": check_html, "targz": check_targz, @@ -2558,12 +2820,13 @@ def geo_collection_box(doc, boxes, located, settings): "webm": check_webm, "mp4": check_mp4, "docx": check_docx, "xlsx": check_xlsx, "pptx": check_pptx, "txt": check_txt, "md": check_md, - "yaml": check_yaml, "toml": check_toml, "geojson": check_geojson} + "yaml": check_yaml, "toml": check_toml, "geojson": check_geojson, + "eml": check_eml} # Checks that take the shape of the file as well as its bytes. Everything else # is handed the bytes alone, so adding a setting to one check cannot change how # any other one is called. -TAKES_SETTINGS = {"csv", "txt", "md", "json", "xml", "html", "geojson"} +TAKES_SETTINGS = {"csv", "txt", "md", "json", "xml", "html", "geojson", "eml"} if __name__ == "__main__": if len(sys.argv) < 3 or sys.argv[1] not in CHECKS: From 3ec90e1c09a410c5af44583676d737404a3281db Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 21:20:17 +0200 Subject: [PATCH 05/12] eml: a refusal of size that speaks of attached files only when there are some Co-Authored-By: Claude Opus 5.5 --- internal/format/eml/eml.go | 13 ++++++++++--- internal/gui/text/locale/said/en.json | 5 +++++ internal/gui/text/locale/said/pl.json | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/format/eml/eml.go b/internal/format/eml/eml.go index d43e60a8..8f745ab1 100644 --- a/internal/format/eml/eml.go +++ b/internal/format/eml/eml.go @@ -102,13 +102,20 @@ func (generator) Plan(r format.Request) (format.Plan, error) { func fit(m *message, r format.Request) ([]format.Note, error) { bare := m.size() if r.Bytes < bare { + // The reason a tester needs is the base64, when there is anything + // attached - and a sentence about attached files is wrong for a + // message that has none. + reason := core.Says("eml.AMessageHoldsItsHeaderLinesAndText", "a message holds its header lines and the lines that open its text") + if len(m.children) > 0 { + reason = core.Says("eml.AMessageHoldsItsHeaderLines", "a message holds its header lines, its parts and every attached file in base64, "+ + "which is about a third larger than the file itself") + } return nil, &format.BelowMinimumError{ Format: "EML", Requested: r.Bytes, Minimum: bare, - Reason: core.Says("eml.AMessageHoldsItsHeaderLines", "a message holds its header lines, its parts and every attached file in base64, "+ - "which is about a third larger than the file itself"), - Hint: core.Says("format.AskForBOrMore", "Ask for %d B or more.", core.A("Min", bare)), + Reason: reason, + Hint: core.Says("format.AskForBOrMore", "Ask for %d B or more.", core.A("Min", bare)), } } if !r.Label { diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index b6f90ee0..58d116b0 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -526,6 +526,11 @@ "hash": "sha256-5b3dd4df2ace", "other": "a message holds its header lines, its parts and every attached file in base64, which is about a third larger than the file itself" }, + "eml.AMessageHoldsItsHeaderLinesAndText": { + "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-1e727c17f578", + "other": "a message holds its header lines and the lines that open its text" + }, "eml.AMessageThisLargeCannotBe": { "description": "Said by internal/format/eml - a refusal under the box it is about or at the foot of the form, or a note after a run.", "hash": "sha256-ff6a6f0065c8", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index ab103032..151bb59e 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -104,6 +104,7 @@ "damage.UnknownWhy": { "hash": "sha256-f2720ea32a8a", "other": "uszkodzenie musi być znane tej wersji, a nieznane zostawiłoby plik nienaruszony, podczas gdy manifest nazywałby go uszkodzonym" }, "docx.Paragraphs": { "hash": "sha256-45316182b77a", "one": "{{.Count}} akapitem", "few": "{{.Count}} akapitami", "many": "{{.Count}} akapitami", "other": "{{.Count}} akapitu" }, "eml.AMessageHoldsItsHeaderLines": { "hash": "sha256-5b3dd4df2ace", "other": "wiadomość zawiera swoje linie nagłówka, swoje części i każdy załączony plik w base64, który jest o mniej więcej jedną trzecią większy niż sam plik" }, + "eml.AMessageHoldsItsHeaderLinesAndText": { "hash": "sha256-1e727c17f578", "other": "wiadomość zawiera swoje linie nagłówka i linie, którymi zaczyna się jej tekst" }, "eml.AMessageThisLargeCannotBe": { "hash": "sha256-ff6a6f0065c8", "other": "tak dużej wiadomości ta wersja nie potrafi policzyć co do bajta" }, "eml.AskForAttachmentsOrFewer": { "hash": "sha256-2145e05752c5", "other": "Poproś o najwyżej {{.Max}} załączników." }, "eml.AskForSmallerAttachments": { "hash": "sha256-871e78c75c85", "other": "Poproś o mniej albo mniejsze załączniki." }, From 55a7a62e9192dd4044342bcb073b778f54cdf79e Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 21:25:12 +0200 Subject: [PATCH 06/12] eml: golden values, the preset grows by one, the site and the README count thirty Six golden cases, one for each path the bytes take, an odd byte in the HTML of alternative among them - asserted by a guard as well, because the first size picked for it left none. empty-and-minimal gains an eml target. The social card says 30 formats. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 25 ++++++- README.md | 6 +- internal/guard/eml_test.go | 20 ++++++ internal/guard/generatorbytes_test.go | 15 ++++ internal/guard/presetbytes_test.go | 5 +- internal/guard/testdata/generator-golden.json | 30 ++++++++ web/assets/social-preview.png | Bin 163064 -> 163056 bytes web/public/ar/corrupt-test-files/index.html | 2 +- web/public/ar/faq/index.html | 4 +- web/public/ar/formats/index.html | 64 +++++++++++++++--- web/public/ar/index.html | 14 ++-- .../ar/presets/empty-and-minimal/index.html | 16 ++--- web/public/assets/social-preview.png | Bin 163064 -> 163056 bytes web/public/corrupt-test-files/index.html | 2 +- web/public/cs/faq/index.html | 4 +- web/public/cs/formaty/index.html | 64 +++++++++++++++--- web/public/cs/index.html | 14 ++-- .../cs/poskozene-testovaci-soubory/index.html | 2 +- .../cs/predvolby/empty-and-minimal/index.html | 16 ++--- .../de/beschaedigte-testdateien/index.html | 2 +- web/public/de/faq/index.html | 4 +- web/public/de/formate/index.html | 64 +++++++++++++++--- web/public/de/index.html | 14 ++-- .../de/presets/empty-and-minimal/index.html | 16 ++--- .../archivos-de-prueba-corruptos/index.html | 2 +- web/public/es/formatos/index.html | 64 +++++++++++++++--- web/public/es/index.html | 14 ++-- web/public/es/preguntas-frecuentes/index.html | 4 +- .../es/presets/empty-and-minimal/index.html | 16 ++--- web/public/faq/index.html | 4 +- web/public/formats/index.html | 64 +++++++++++++++--- web/public/fr/faq/index.html | 4 +- .../fr/fichiers-de-test-corrompus/index.html | 2 +- web/public/fr/formats/index.html | 64 +++++++++++++++--- web/public/fr/index.html | 14 ++-- .../prereglages/empty-and-minimal/index.html | 16 ++--- web/public/hi/corrupt-test-files/index.html | 2 +- web/public/hi/faq/index.html | 4 +- web/public/hi/formats/index.html | 64 +++++++++++++++--- web/public/hi/index.html | 14 ++-- .../hi/presets/empty-and-minimal/index.html | 16 ++--- web/public/id/faq/index.html | 4 +- web/public/id/file-uji-rusak/index.html | 2 +- web/public/id/format/index.html | 64 +++++++++++++++--- web/public/id/index.html | 14 ++-- .../id/preset/empty-and-minimal/index.html | 16 ++--- web/public/index.html | 14 ++-- web/public/it/domande-frequenti/index.html | 4 +- .../it/file-di-test-corrotti/index.html | 2 +- web/public/it/formati/index.html | 64 +++++++++++++++--- web/public/it/index.html | 14 ++-- .../it/preset/empty-and-minimal/index.html | 16 ++--- web/public/ja/corrupt-test-files/index.html | 2 +- web/public/ja/faq/index.html | 4 +- web/public/ja/formats/index.html | 64 +++++++++++++++--- web/public/ja/index.html | 14 ++-- .../ja/presets/empty-and-minimal/index.html | 16 ++--- web/public/ko/corrupt-test-files/index.html | 2 +- web/public/ko/faq/index.html | 4 +- web/public/ko/formats/index.html | 64 +++++++++++++++--- web/public/ko/index.html | 14 ++-- .../ko/presets/empty-and-minimal/index.html | 16 ++--- .../nl/beschadigde-testbestanden/index.html | 2 +- web/public/nl/formaten/index.html | 64 +++++++++++++++--- web/public/nl/index.html | 14 ++-- .../nl/presets/empty-and-minimal/index.html | 16 ++--- web/public/nl/veelgestelde-vragen/index.html | 4 +- web/public/pl/faq/index.html | 4 +- web/public/pl/formaty/index.html | 58 ++++++++++++++-- web/public/pl/index.html | 14 ++-- .../pl/presety/empty-and-minimal/index.html | 16 ++--- .../pl/uszkodzone-pliki-testowe/index.html | 2 +- .../presets/empty-and-minimal/index.html | 16 ++--- .../arquivos-de-teste-corrompidos/index.html | 2 +- web/public/pt-br/formatos/index.html | 64 +++++++++++++++--- web/public/pt-br/index.html | 14 ++-- .../pt-br/perguntas-frequentes/index.html | 4 +- .../presets/empty-and-minimal/index.html | 16 ++--- .../ro/fisiere-de-test-corupte/index.html | 2 +- web/public/ro/formate/index.html | 64 +++++++++++++++--- web/public/ro/index.html | 14 ++-- web/public/ro/intrebari-frecvente/index.html | 4 +- .../ro/presetari/empty-and-minimal/index.html | 16 ++--- web/public/ru/corrupt-test-files/index.html | 2 +- web/public/ru/faq/index.html | 4 +- web/public/ru/formats/index.html | 64 +++++++++++++++--- web/public/ru/index.html | 14 ++-- .../ru/presets/empty-and-minimal/index.html | 16 ++--- web/public/social.html | 2 +- web/public/th/corrupt-test-files/index.html | 2 +- web/public/th/faq/index.html | 4 +- web/public/th/formats/index.html | 64 +++++++++++++++--- web/public/th/index.html | 14 ++-- .../th/presets/empty-and-minimal/index.html | 16 ++--- web/public/tr/bicimler/index.html | 64 +++++++++++++++--- web/public/tr/bozuk-test-dosyalari/index.html | 2 +- .../empty-and-minimal/index.html | 16 ++--- web/public/tr/index.html | 14 ++-- web/public/tr/sss/index.html | 4 +- web/public/uk/corrupt-test-files/index.html | 2 +- web/public/uk/faq/index.html | 4 +- web/public/uk/formats/index.html | 64 +++++++++++++++--- web/public/uk/index.html | 14 ++-- .../uk/presets/empty-and-minimal/index.html | 16 ++--- web/public/vi/dinh-dang/index.html | 64 +++++++++++++++--- web/public/vi/faq/index.html | 4 +- web/public/vi/index.html | 14 ++-- .../vi/preset/empty-and-minimal/index.html | 16 ++--- web/public/vi/tep-kiem-thu-bi-hong/index.html | 2 +- .../zh-hans/corrupt-test-files/index.html | 2 +- web/public/zh-hans/faq/index.html | 4 +- web/public/zh-hans/formats/index.html | 64 +++++++++++++++--- web/public/zh-hans/index.html | 14 ++-- .../presets/empty-and-minimal/index.html | 16 ++--- .../zh-hant/corrupt-test-files/index.html | 2 +- web/public/zh-hant/faq/index.html | 4 +- web/public/zh-hant/formats/index.html | 64 +++++++++++++++--- web/public/zh-hant/index.html | 14 ++-- .../presets/empty-and-minimal/index.html | 16 ++--- web/social-preview.sha256 | 2 +- 120 files changed, 1722 insertions(+), 577 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8043ecaa..6d4786af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,25 @@ because it turns other people's test suites red. refuses a text value of ten million characters. At precision 3 or less a line has fewer points than that, one for each step of longitude the globe has, and asking for more is refused with the number that fits. +- **Mail: the `eml` format.** A mail message (RFC 5322 with MIME) at the + exact size you ask for, with real files of any other format attached in + base64 - `attachments`, `attachment_format` and `attachment_size`, or + `contains` for files that are not all alike. The size is the message's, + about a third more than the files it carries, and the manifest records + both, with the number of MIME entities and the subject and Message-ID a + test can find the message by. `body` gives a text part (the default), an + HTML part, or both with the same words. `text_encoding` keeps the text to + 7bit, or adds a line in Polish and Japanese in quoted-printable or 8bit. + `headers` puts letters outside ASCII into the subject, the names of the + people and the names of attached files, encoded the way RFC 2047 and RFC + 2231 say or raw as RFC 6532 allows. `filename_style` writes those names + four ways, and readers do not agree on two of them: Go's standard library + reads no name from `content-type` and the encoded text itself from + `rfc2047`. `line_endings` gives `crlf` or `lf`. Read by Python's email + package, mailparser and Go's standard library, and checked line by line + against RFC 5322, which none of the three does. The smallest message at + the default settings is 289 B. mailparser refuses a message of more than + 1000 MIME entities, which 999 attached files and the text reach. - **GeoJSON: holes, features with no place, both types of id, and bbox.** `holes` gives every polygon up to 100 000 holes of four points, each running the other way round to its outline as RFC 7946 asks. Holes need 6 or more @@ -194,10 +213,10 @@ because it turns other people's test suites red. ### Changed -- **`empty-and-minimal` now includes the films and GeoJSON.** The preset builds the +- **`empty-and-minimal` now includes the films, GeoJSON and mail.** The preset builds the smallest file of every format this build has, so with `formats` left at - `all` it gains a `webm`, an `mp4` and a `geojson` target, and the recipe - `tfg preset eject empty-and-minimal` prints is three targets longer. A manifest from a run of that preset records the + `all` it gains a `webm`, an `mp4`, a `geojson` and an `eml` target, and the recipe + `tfg preset eject empty-and-minimal` prints is four targets longer. A manifest from a run of that preset records the hash of that recipe, so its `recipe_hash` differs from one written before. No generated file changes, and a run that names its `formats` is untouched. - **An empty text box in the window reads "not set"** instead of "worked out diff --git a/README.md b/README.md index 670dd75f..99ea9f69 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **Testing Files Generator** is a tool for QA engineers and developers who need real files to test against - an upload form, a parser, anything that takes a file and -has an opinion about it. You pick one of its 29 formats and the size you want, +has an opinion about it. You pick one of its 30 formats and the size you want, and you get **exactly that**: ask for a 10 MB PDF and you get a PDF that a reader will open, at 10 MB to the byte. Every run also leaves a manifest saying **what your system should do with each file**, which is the part other generators leave @@ -23,7 +23,7 @@ needs it finds out it exists. - **Hit an exact size, to the byte** - ask for 10485761 bytes and get exactly that, never a silently rounded file. -- **Write 29 real formats** - a generated PNG opens in an image viewer, a DOCX +- **Write 30 real formats** - a generated PNG opens in an image viewer, a DOCX opens in Word, a ZIP extracts. Not padded zeros with an extension. - **Say what should happen to each file** - the manifest carries an expected outcome, so your test reads the assertion instead of you writing it out. @@ -166,6 +166,7 @@ owns it, at the exact size you asked for: | 📝 **Text and markup** | `txt`, `md`, `csv`, `json`, `xml`, `html`, `log` | | ⚙️ **Configuration** | `yaml`, `toml` | | 🗺️ **Maps** | `geojson` (RFC 7946) | +| ✉️ **Mail** | `eml` (RFC 5322 with MIME, real files attached) | | 🗜️ **Archives** | `zip`, `targz` (`.tar.gz`) | | 🔊 **Audio** | `wav` | | 🎞️ **Video** | `webm`, `mp4` (both AV1, no sound) | @@ -618,6 +619,7 @@ recipe. `tfg formats ` prints the allowed range or list for each: | `txt`, `md`, `xml` | `encoding`, `bom` | | `json` | `formatting` | | `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` | +| `eml` | `body`, `text_encoding`, `headers`, `line_endings`, `attachments`, `attachment_format`, `attachment_size`, `filename_style` | | `svg` | `width`, `height` | | `html` | `structure` | | `yaml`, `toml` | none in this build - the document is a fixed shape, and a size is the only thing to ask for | diff --git a/internal/guard/eml_test.go b/internal/guard/eml_test.go index d748e953..5d677d2b 100644 --- a/internal/guard/eml_test.go +++ b/internal/guard/eml_test.go @@ -174,6 +174,26 @@ func TestEMLRefusesASizeBelowItsAttachmentsInBase64AndItselfInside(t *testing.T) } } +// alternative carries the same words in both parts, so its text grows two +// bytes at a time, and an odd byte left over becomes one space in the HTML, +// where it changes nothing a person sees. Two neighbouring sizes have to land +// in both states - asserted rather than assumed, because a golden case named +// for the odd byte was first pinned at a size that left none (2026-10-07). +func TestAlternativePutsTheOddByteInTheHTML(t *testing.T) { + d, err := format.Get("eml") + if err != nil { + t.Fatal(err) + } + reached := map[bool]bool{} + for size := int64(4000); size < 4002; size++ { + b, _ := writeEML(t, d, size, 9, map[string]string{eml.Body: eml.Alternative}) + reached[bytes.Contains(b, []byte(" "))] = true + } + if !reached[true] || !reached[false] { + t.Errorf("two neighbouring sizes did not reach both an even and an odd byte: %v", reached) + } +} + // withAttachments is a case with the attachment settings added. func withAttachments(base map[string]string, count, id, size string) map[string]string { out := map[string]string{eml.Attachments: count, eml.AttachmentFormat: id, eml.AttachmentSize: size} diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 869d63a4..4c262bec 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -366,6 +366,21 @@ func goldenCases() map[string]engine.Target { Properties: map[string]string{"antimeridian": "true", "winding": "reversed", "altitude": "true", "precision": "15"}}, "geojson_capped_note": {ID: "g", Format: "geojson", Sizes: engine.Uniform(1, 3900000), Label: true, Properties: map[string]string{"geometry": "polygon", "vertices": "100000"}}, + + // EML, one case for each path the bytes take: the plain default, HTML in + // quoted-printable, alternative at a size that leaves an odd byte (the + // space the HTML takes for what the two parts cannot split) with raw UTF-8 headers and + // LF, and attached files under every style of name - encoded words, the + // RFC 2231 form continued over lines, and the name in Content-Type only. + "eml_8kib": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8192), Label: true}, + "eml_8kib_html_quoted": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8192), Label: true, Properties: map[string]string{"body": "html", "text_encoding": "quoted-printable"}}, + "eml_8kib_alternative_odd_utf8_lf": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 8192), Label: true, Properties: map[string]string{"body": "alternative", "text_encoding": "8bit", "headers": "utf8", "line_endings": "lf"}}, + "eml_64kib_attached_both": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"attachments": "2", "attachment_format": "txt", "attachment_size": "8kb", "headers": "encoded", "filename_style": "both"}}, + "eml_64kib_attached_rfc2047": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"attachments": "1", "attachment_format": "pdf", "attachment_size": "8kb", "headers": "encoded", "filename_style": "rfc2047"}}, + "eml_64kib_attached_content_type": {ID: "m", Format: "eml", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"attachments": "3", "attachment_format": "png", "attachment_size": "4kb", "filename_style": "content-type"}}, // The second set of settings, one case for each new path: outlines // with holes, a box on every feature and on the collection across the // antimeridian with a height and indented, features with no place and diff --git a/internal/guard/presetbytes_test.go b/internal/guard/presetbytes_test.go index a1b16eea..fc5474ae 100644 --- a/internal/guard/presetbytes_test.go +++ b/internal/guard/presetbytes_test.go @@ -94,7 +94,10 @@ func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { // Moved a fifth time on 2026-10-07, under the same decision: geojson is // a format more, so the set has its smallest collection, 875 B // (docs/GEOJSON-2026-10-07.md). - {id: "empty-and-minimal", args: preset.Args{}, bytes: 9885, sum: "2a02ad1ec2e2c2ff7f19b87906ecf3ca910e1af5e806b135467824c28fb4fc24"}, + // Moved a sixth time on 2026-10-07, under the same decision: eml is a + // format more, so the set has its smallest message, 289 B + // (docs/EML-2026-10-07.md). + {id: "empty-and-minimal", args: preset.Args{}, bytes: 10190, sum: "9530e0e5427d2d92cf05f47a5dcd4fc1755d67a9155ea65482e296f4d571adbe"}, {id: "empty-and-minimal", args: preset.Args{"formats": "jpg,png,txt"}, bytes: 1472, sum: "19b12800372395ad3bf5cba6407ca654d7ed51eba3a5bc5436f76a94bc09253c"}, // The preset of unusual file names, measured 2026-09-25 on its first // build: the default, and a format whose extension is a byte longer, diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index e9f4b85b..9ecb169e 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -431,6 +431,36 @@ "bytes": 65536, "sha256": "5c01a4d2461685ed35c3e431da8452742d241e54f7b9e9d9158e87694e70a1dc", "measured_on": "2026-10-07" + }, + "eml_8kib": { + "bytes": 8192, + "sha256": "cd441a55e0fad36d2b6408cdbbaa5813061649ad7436b3bc6cbec57250e995f1", + "measured_on": "2026-10-07" + }, + "eml_8kib_html_quoted": { + "bytes": 8192, + "sha256": "f6d591ecbacca1fe33b7213419939ebd6f72a66d01605358c4885ad1d561b771", + "measured_on": "2026-10-07" + }, + "eml_8kib_alternative_odd_utf8_lf": { + "bytes": 8192, + "sha256": "aee420e64729d5fe3b60163040c464fe3a5489ea650e168252f4abd2c84bb81f", + "measured_on": "2026-10-07" + }, + "eml_64kib_attached_both": { + "bytes": 65536, + "sha256": "f5fd01b6a934e0956d25aef31727e792948b693f398a1af0f47cd9a9fda71133", + "measured_on": "2026-10-07" + }, + "eml_64kib_attached_rfc2047": { + "bytes": 65536, + "sha256": "0fad092a621a172e67f01347418aa401e1a94b6ed40da8e7a9032a09b5beaa71", + "measured_on": "2026-10-07" + }, + "eml_64kib_attached_content_type": { + "bytes": 65536, + "sha256": "425f762165582768bafa0c687a33f030cc8fc88d3ecd1631716819ebce65c546", + "measured_on": "2026-10-07" } }, "remeasured": [ diff --git a/web/assets/social-preview.png b/web/assets/social-preview.png index 341a793bcf4caef4ed0d6985da8eb79924e9761c..88c924b13e621980f163f38a5917489b79e0facd 100644 GIT binary patch delta 64524 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 delta 64532 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الملف التالف يبقى بالحجم الذي طلبته tfg generate --format pdf --size 1mb --count 5 --damage zero-head:bytes=16 --out ./broken

تُكتب الإعدادات بعد النقطتين. يمكن تكرار الخيار، وتُطبَّق أنواع التلف بالترتيب الذي تكتبه. ويعمل مع - كل واحدة من 29 صيغة. + كل واحدة من 30 صيغة.

diff --git a/web/public/ar/faq/index.html b/web/public/ar/faq/index.html index d5bfeb74..a1568437 100644 --- a/web/public/ar/faq/index.html +++ b/web/public/ar/faq/index.html @@ -205,7 +205,7 @@

الأسئلة الشائعة

ما الصيغ القادمة؟

-

7z وmp3. تعمل اليوم 29 صيغة من البداية إلى النهاية.

+

7z وmp3. تعمل اليوم 30 صيغة من البداية إلى النهاية.

@@ -276,7 +276,7 @@

الأسئلة الشائعة

{ "@type": "Question", "name": "ما الصيغ القادمة؟", - "acceptedAnswer": { "@type": "Answer", "text": "7z وmp3. تعمل اليوم 29 صيغة من البداية إلى النهاية." } + "acceptedAnswer": { "@type": "Answer", "text": "7z وmp3. تعمل اليوم 30 صيغة من البداية إلى النهاية." } }, { "@type": "Question", diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index d5f11500..a90ded10 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -4,8 +4,8 @@ -29 صيغة ملف مدعومة - PDF وDOCX وPNG وZIP وغيرها - +30 صيغة ملف مدعومة - PDF وDOCX وPNG وZIP وغيرها + @@ -33,8 +33,8 @@ - - + + @@ -62,8 +62,8 @@ - - + + @@ -144,7 +144,7 @@
-

29 صيغة ملف، كلٌّ منها يُولَّد بحجم دقيق

+

30 صيغة ملف، كلٌّ منها يُولَّد بحجم دقيق

كلٌّ منها ملف حقيقي من تلك الصيغة. يُفتح في البرنامج الذي يملكه، وعدد بايتاته هو ما طلبته بالضبط. ولا واحد منها أصفار حشوية أُلصق بها امتداد. @@ -195,6 +195,14 @@

29 صيغة ملف، كلٌّ منها يُولَّد بحجم دقيق

full libreoffice + + eml + Email message + .eml + 289 + full + python-email + geojson GeoJSON @@ -503,6 +511,46 @@

الإعدادات التي تقبلها كل صيغة

paragraphs 1 - 50000 فقرات + + eml + body + alternative, html, plain + + + + text_encoding + 7bit, 8bit, quoted-printable + + + + headers + ascii, encoded, utf8 + + + + line_endings + crlf, lf + + + + attachments + 0 - 10000 + + + + attachment_format + معرّف صيغة، كما يسردها tfg formats + + + + attachment_size + حجم مثل 2mb + + + + filename_style + both, content-type, rfc2047, rfc2231 + geojson geometry @@ -1004,7 +1052,7 @@

الإعدادات التي تقبلها كل صيغة

الأرشيفات تحتوي ملفات حقيقية

- targz وzip يمكن + eml وtargz وzip يمكن ملؤها بعناصر بدل تركها قشرة فارغة. الأرشيف المولَّد يحتوي فعلًا على المستندات التي يدّعي احتواءها، فكل ما يفكّه أثناء اختبار يجد بداخله ملفات حقيقية.

diff --git a/web/public/ar/index.html b/web/public/ar/index.html index e69c2b01..bf807768 100644 --- a/web/public/ar/index.html +++ b/web/public/ar/index.html @@ -4,7 +4,7 @@ -مولّد ملفات الاختبار لضمان الجودة - حجم دقيق، 29 صيغة حقيقية +مولّد ملفات الاختبار لضمان الجودة - حجم دقيق، 30 صيغة حقيقية @@ -33,7 +33,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -76,7 +76,7 @@ "@type": "SoftwareApplication", "name": "Testing Files Generator", "alternateName": "tfg", - "description": "مولّد ملفات اختبار مجاني ومفتوح المصدر لضمان الجودة. ينتج ملفات حقيقية بـ 29 صيغة وبحجم دقيق، ويكتب بيانًا يحدد كيف يجب أن يتفاعل النظام قيد الاختبار مع كل ملف.", + "description": "مولّد ملفات اختبار مجاني ومفتوح المصدر لضمان الجودة. ينتج ملفات حقيقية بـ 30 صيغة وبحجم دقيق، ويكتب بيانًا يحدد كيف يجب أن يتفاعل النظام قيد الاختبار مع كل ملف.", "url": "https:\/\/testingfilesgenerator.donislawdev.com/", "applicationCategory": "DeveloperApplication", "applicationSubCategory": "Software Testing", @@ -157,7 +157,7 @@

ولّد ملفات اختبار حقيقية بحجم دقيق

- PDF وPNG وDOCX وZIP، 29 صيغة في المجمل، وكلٌّ منها ملف حقيقي + PDF وPNG وDOCX وZIP، 30 صيغة في المجمل، وكلٌّ منها ملف حقيقي يُفتح في البرنامج الذي يملكه، وبـالحجم الذي طلبته تمامًا. كما يدوّن كل تشغيل ما يجب أن يفعله تطبيقك بكل ملف. سطر أوامر ونافذة سطح مكتب، مجاني ومفتوح المصدر، ويعمل بالكامل على جهازك. @@ -179,7 +179,7 @@

ولّد ملفات اختبار حقيقية بحجم دقيق

diff --git a/web/public/cs/faq/index.html b/web/public/cs/faq/index.html index fdb042d2..b8d75221 100644 --- a/web/public/cs/faq/index.html +++ b/web/public/cs/faq/index.html @@ -206,7 +206,7 @@

Často kladené otázky

Které formáty přijdou dál?

-

7z a mp3. Dnes funguje od začátku do konce 29 formátů.

+

7z a mp3. Dnes funguje od začátku do konce 30 formátů.

@@ -277,7 +277,7 @@

Často kladené otázky

{ "@type": "Question", "name": "Které formáty přijdou dál?", - "acceptedAnswer": { "@type": "Answer", "text": "7z a mp3. Dnes funguje od začátku do konce 29 formátů." } + "acceptedAnswer": { "@type": "Answer", "text": "7z a mp3. Dnes funguje od začátku do konce 30 formátů." } }, { "@type": "Question", diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index 9328d60f..4a2f41ae 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -4,8 +4,8 @@ -29 podporovaných formátů souborů - PDF, DOCX, PNG, ZIP a další - +30 podporovaných formátů souborů - PDF, DOCX, PNG, ZIP a další + @@ -33,8 +33,8 @@ - - + + @@ -62,8 +62,8 @@ - - + + @@ -144,7 +144,7 @@
-

29 formátů souborů, každý generovaný v přesné velikosti

+

30 formátů souborů, každý generovaný v přesné velikosti

Každý z nich je skutečný soubor toho formátu. Otevře se v programu, kam patří, a má přesně tolik bajtů, kolik jste požádali. Žádný není vycpávka z nul s přilepenou příponou. @@ -195,6 +195,14 @@

29 formátů souborů, každý generovaný v přesné velikosti

full libreoffice + + eml + Email message + .eml + 289 + full + python-email + geojson GeoJSON @@ -504,6 +512,46 @@

Nastavení, která každý formát přijímá

paragraphs 1 - 50000 odstavců + + eml + body + alternative, html, plain + + + + text_encoding + 7bit, 8bit, quoted-printable + + + + headers + ascii, encoded, utf8 + + + + line_endings + crlf, lf + + + + attachments + 0 - 10000 + + + + attachment_format + id formátu tak, jak je vypisuje tfg formats + + + + attachment_size + velikost, například 2mb + + + + filename_style + both, content-type, rfc2047, rfc2231 + geojson geometry @@ -1006,7 +1054,7 @@

Nastavení, která každý formát přijímá

Archivy obsahují skutečné soubory

- targz a zip lze + eml a targz a zip lze naplnit položkami, místo aby zůstaly prázdnou skořápkou. Vygenerovaný archiv skutečně obsahuje dokumenty, které tvrdí, že obsahuje, takže cokoli ho během testu rozbalí, uvnitř najde skutečné soubory. diff --git a/web/public/cs/index.html b/web/public/cs/index.html index 582bd83b..0c16aad0 100644 --- a/web/public/cs/index.html +++ b/web/public/cs/index.html @@ -4,7 +4,7 @@ -Generátor testovacích souborů - přesná velikost, 29 formátů +Generátor testovacích souborů - přesná velikost, 30 formátů @@ -33,7 +33,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -76,7 +76,7 @@ "@type": "SoftwareApplication", "name": "Testing Files Generator", "alternateName": "tfg", - "description": "Bezplatný open source generátor testovacích souborů pro QA. Vytváří skutečné soubory v 29 formátech v přesné velikosti a zapisuje manifest, který říká, jak má testovaný systém na každý z nich reagovat.", + "description": "Bezplatný open source generátor testovacích souborů pro QA. Vytváří skutečné soubory v 30 formátech v přesné velikosti a zapisuje manifest, který říká, jak má testovaný systém na každý z nich reagovat.", "url": "https:\/\/testingfilesgenerator.donislawdev.com/", "applicationCategory": "DeveloperApplication", "applicationSubCategory": "Software Testing", @@ -157,7 +157,7 @@

Generujte skutečné testovací soubory v přesné velikosti

- PDF, PNG, DOCX, ZIP - celkem 29 formátů a každý je skutečný + PDF, PNG, DOCX, ZIP - celkem 30 formátů a každý je skutečný soubor, který se otevře v programu, kam patří, v přesně té velikosti, o kterou jste požádali. Každý běh také zapisuje, co má vaše aplikace s každým souborem udělat. Příkazový řádek a desktopové okno, zdarma a open source, celé na vašem počítači. @@ -179,7 +179,7 @@

Generujte skutečné testovací soubory v přesné velikosti

  • - 29 + 30

    skutečných formátů, každý se otevře v programu, kam patří

  • @@ -341,7 +341,7 @@

    Vytvořeno pro sadu testů, která běží bez dozoru

    Požádejte o 10485761 bajtů a dostanete přesně tolik. Velikost, které formát nedosáhne, je chyba s důvodem, nikdy soubor špatné velikosti.

  • -

    29 skutečných formátů

    +

    30 skutečných formátů

    Ne vycpávkové nuly s příponou. Vygenerované PNG se otevře v prohlížeči obrázků, DOCX ve Wordu, ZIP se rozbalí. Každý formát je před vydáním ověřen nezávislými čtečkami.

  • diff --git a/web/public/cs/poskozene-testovaci-soubory/index.html b/web/public/cs/poskozene-testovaci-soubory/index.html index e88cf87a..0c9691a0 100644 --- a/web/public/cs/poskozene-testovaci-soubory/index.html +++ b/web/public/cs/poskozene-testovaci-soubory/index.html @@ -200,7 +200,7 @@

    Poškozený soubor má stále velikost, kterou jste chtěli

    tfg generate --format pdf --size 1mb --count 5 --damage zero-head:bytes=16 --out ./broken

    Nastavení se píše za dvojtečku. Přepínač lze opakovat a poškození se použijí v pořadí, v jakém je - zapíšete. Funguje s každým z 29 formátů. + zapíšete. Funguje s každým z 30 formátů.

diff --git a/web/public/cs/predvolby/empty-and-minimal/index.html b/web/public/cs/predvolby/empty-and-minimal/index.html index 7bd23693..9f49306b 100644 --- a/web/public/cs/predvolby/empty-and-minimal/index.html +++ b/web/public/cs/predvolby/empty-and-minimal/index.html @@ -5,7 +5,7 @@ Nejmenší platné a prázdné testovací soubory v každém formátu - + @@ -34,7 +34,7 @@ - + @@ -63,7 +63,7 @@ - + @@ -178,10 +178,10 @@

Co je v sadě?

- - - - + + + +
Soubory31
Targety v receptu31
Celková velikost40 138 B
Formátyavif, bmp, csv, docx, geojson, gif, html, ico, jpg, json, jxl, log, md, mp4, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip
Soubory32
Targety v receptu32
Celková velikost40 427 B
Formátyavif, bmp, csv, docx, eml, geojson, gif, html, ico, jpg, json, jxl, log, md, mp4, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip
@@ -192,7 +192,7 @@

Co je v sadě?

OčekávánoVýznamSoubory - acceptVáš systém má soubor přijmout.29 + acceptVáš systém má soubor přijmout.30 unspecifiedZáleží na pravidlech vašeho systému. Rozhodnete vy, a pak ověříte, že to, co se stane, je to, co jste chtěli.2 diff --git a/web/public/de/beschaedigte-testdateien/index.html b/web/public/de/beschaedigte-testdateien/index.html index c2d28324..cd61d4eb 100644 --- a/web/public/de/beschaedigte-testdateien/index.html +++ b/web/public/de/beschaedigte-testdateien/index.html @@ -203,7 +203,7 @@

Eine beschädigte Datei hat trotzdem die verlangte Größe

Einstellungen stehen nach einem Doppelpunkt. Die Option lässt sich wiederholen, und die Beschädigungen werden in der Reihenfolge angewendet, in der Sie sie schreiben. Es funktioniert - mit allen 29 Formaten. + mit allen 30 Formaten.

diff --git a/web/public/de/faq/index.html b/web/public/de/faq/index.html index 4a95c26f..62890443 100644 --- a/web/public/de/faq/index.html +++ b/web/public/de/faq/index.html @@ -206,7 +206,7 @@

Häufig gestellte Fragen

Welche Formate kommen als Nächstes?

-

7z und mp3. 29 Formate funktionieren heute durchgängig.

+

7z und mp3. 30 Formate funktionieren heute durchgängig.

@@ -277,7 +277,7 @@

Häufig gestellte Fragen

{ "@type": "Question", "name": "Welche Formate kommen als Nächstes?", - "acceptedAnswer": { "@type": "Answer", "text": "7z und mp3. 29 Formate funktionieren heute durchgängig." } + "acceptedAnswer": { "@type": "Answer", "text": "7z und mp3. 30 Formate funktionieren heute durchgängig." } }, { "@type": "Question", diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index 50b74c19..06b6edec 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -4,8 +4,8 @@ -29 unterstützte Dateiformate - PDF, DOCX, PNG, ZIP und mehr - +30 unterstützte Dateiformate - PDF, DOCX, PNG, ZIP und mehr + @@ -33,8 +33,8 @@ - - + + @@ -62,8 +62,8 @@ - - + + @@ -144,7 +144,7 @@
-

29 Dateiformate, jedes in exakter Größe erzeugt

+

30 Dateiformate, jedes in exakter Größe erzeugt

Jedes davon ist eine echte Datei dieses Formats. Sie öffnet sich in der Software, zu der sie gehört, und hat genau die Byte-Zahl, die du verlangt hast. Keines davon besteht aus @@ -196,6 +196,14 @@

29 Dateiformate, jedes in exakter Größe erzeugt

full libreoffice + + eml + Email message + .eml + 289 + full + python-email + geojson GeoJSON @@ -506,6 +514,46 @@

Einstellungen, die jedes Format kennt

paragraphs 1 - 50000 Absätze + + eml + body + alternative, html, plain + + + + text_encoding + 7bit, 8bit, quoted-printable + + + + headers + ascii, encoded, utf8 + + + + line_endings + crlf, lf + + + + attachments + 0 - 10000 + + + + attachment_format + die ID eines Formats, wie tfg formats sie auflistet + + + + attachment_size + eine Größe wie 2mb + + + + filename_style + both, content-type, rfc2047, rfc2231 + geojson geometry @@ -1010,7 +1058,7 @@

Einstellungen, die jedes Format kennt

Archive enthalten echte Dateien

- targz und zip + eml und targz und zip lassen sich mit Einträgen füllen, statt als leere Hülle zu bleiben. Ein erzeugtes Archiv enthält wirklich die Dokumente, die es zu enthalten vorgibt, sodass alles, was es während eines Tests entpackt, echte Dateien darin findet. diff --git a/web/public/de/index.html b/web/public/de/index.html index c130da0e..3e5f1832 100644 --- a/web/public/de/index.html +++ b/web/public/de/index.html @@ -4,7 +4,7 @@ -Testdatei-Generator für QA - exakte Größe, 29 echte Formate +Testdatei-Generator für QA - exakte Größe, 30 echte Formate @@ -33,7 +33,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -76,7 +76,7 @@ "@type": "SoftwareApplication", "name": "Testing Files Generator", "alternateName": "tfg", - "description": "Ein kostenloser Open-Source-Generator für Testdateien in der QA. Er erzeugt echte Dateien in 29 Formaten in jeder exakten Größe und schreibt ein Manifest, das beschreibt, wie das getestete System auf jede Datei reagieren soll.", + "description": "Ein kostenloser Open-Source-Generator für Testdateien in der QA. Er erzeugt echte Dateien in 30 Formaten in jeder exakten Größe und schreibt ein Manifest, das beschreibt, wie das getestete System auf jede Datei reagieren soll.", "url": "https:\/\/testingfilesgenerator.donislawdev.com/", "applicationCategory": "DeveloperApplication", "applicationSubCategory": "Software Testing", @@ -157,7 +157,7 @@

Echte Testdateien in jeder exakten Größe erzeugen

- PDF, PNG, DOCX, ZIP - insgesamt 29 Formate, und jedes ist + PDF, PNG, DOCX, ZIP - insgesamt 30 Formate, und jedes ist eine echte Datei, die sich in der Software öffnet, zu der sie gehört, in genau der Größe, die du verlangt hast. Jeder Lauf hält außerdem fest, was deine Anwendung mit jeder Datei tun soll. Kommandozeile und Desktop-Fenster, kostenlos und Open Source, vollständig @@ -180,7 +180,7 @@

Echte Testdateien in jeder exakten Größe erzeugen

  • - 29 + 30

    echte Formate, jedes öffnet sich in der Software, zu der es gehört

  • @@ -345,7 +345,7 @@

    Gebaut für eine Testsuite, die unbeaufsichtigt läuft

    Verlange 10485761 Bytes und bekomme genau das. Eine Größe, die ein Format nicht erreichen kann, ist ein Fehler mit Begründung, nie eine Datei in der falschen Größe.

  • -

    29 echte Formate

    +

    30 echte Formate

    Keine aufgefüllten Nullen mit einer Endung. Ein erzeugtes PNG öffnet sich im Bildbetrachter, ein DOCX in Word, ein ZIP lässt sich entpacken. Jedes wird vor der Auslieferung mit unabhängigen Readern geprüft.

  • diff --git a/web/public/de/presets/empty-and-minimal/index.html b/web/public/de/presets/empty-and-minimal/index.html index ac25dc11..81fb5dbf 100644 --- a/web/public/de/presets/empty-and-minimal/index.html +++ b/web/public/de/presets/empty-and-minimal/index.html @@ -5,7 +5,7 @@ Kleinste gültige und leere Testdateien in jedem Format - + @@ -34,7 +34,7 @@ - + @@ -63,7 +63,7 @@ - + @@ -178,10 +178,10 @@

    Was steckt im Set?

    - - - - + + + +
    Dateien31
    Targets in seinem Rezept31
    Gesamtgröße40 138 B
    Formateavif, bmp, csv, docx, geojson, gif, html, ico, jpg, json, jxl, log, md, mp4, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip
    Dateien32
    Targets in seinem Rezept32
    Gesamtgröße40 427 B
    Formateavif, bmp, csv, docx, eml, geojson, gif, html, ico, jpg, json, jxl, log, md, mp4, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip
    @@ -192,7 +192,7 @@

    Was steckt im Set?

    ErwartetBedeutungDateien - acceptDein System soll die Datei annehmen.29 + acceptDein System soll die Datei annehmen.30 unspecifiedDas hängt von den Regeln deines Systems ab. Du entscheidest, dann prüfst du, ob das Ergebnis dem entspricht, was du gemeint hast.2 diff --git a/web/public/es/archivos-de-prueba-corruptos/index.html b/web/public/es/archivos-de-prueba-corruptos/index.html index c93e4676..e41dfb87 100644 --- a/web/public/es/archivos-de-prueba-corruptos/index.html +++ b/web/public/es/archivos-de-prueba-corruptos/index.html @@ -201,7 +201,7 @@

    Un archivo dañado conserva el tamaño que pediste

    tfg generate --format pdf --size 1mb --count 5 --damage zero-head:bytes=16 --out ./broken

    Los ajustes van después de dos puntos. La opción se puede repetir, y los daños se aplican en el - orden en que los escribes. Funciona con cada uno de los 29 formatos. + orden en que los escribes. Funciona con cada uno de los 30 formatos.

diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index 068887ec..f23bc473 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -4,8 +4,8 @@ -29 formatos de archivo - PDF, DOCX, PNG, ZIP y más - +30 formatos de archivo - PDF, DOCX, PNG, ZIP y más + @@ -33,8 +33,8 @@ - - + + @@ -62,8 +62,8 @@ - - + + @@ -144,7 +144,7 @@
-

29 formatos de archivo, cada uno generado a un tamaño exacto

+

30 formatos de archivo, cada uno generado a un tamaño exacto

Cada uno es un archivo real de ese formato. Se abre en el programa que le corresponde y tiene exactamente el número de bytes que pediste. Ninguno es relleno de ceros con @@ -196,6 +196,14 @@

29 formatos de archivo, cada uno generado a un tamaño exacto

full libreoffice + + eml + Email message + .eml + 289 + full + python-email + geojson GeoJSON @@ -507,6 +515,46 @@

Ajustes que admite cada formato

paragraphs 1 - 50000 párrafos + + eml + body + alternative, html, plain + + + + text_encoding + 7bit, 8bit, quoted-printable + + + + headers + ascii, encoded, utf8 + + + + line_endings + crlf, lf + + + + attachments + 0 - 10000 + + + + attachment_format + el id de un formato, tal como lo lista tfg formats + + + + attachment_size + un tamaño como 2mb + + + + filename_style + both, content-type, rfc2047, rfc2231 + geojson geometry @@ -1010,7 +1058,7 @@

Ajustes que admite cada formato

Los archivos comprimidos contienen archivos reales

- targz y zip se + eml y targz y zip se pueden llenar de entradas en lugar de quedar como un cascarón vacío. Un archivo comprimido generado contiene de verdad los documentos que dice contener, así que todo lo que lo descomprima durante una prueba encuentra archivos reales dentro. diff --git a/web/public/es/index.html b/web/public/es/index.html index 07078c21..be15f7c0 100644 --- a/web/public/es/index.html +++ b/web/public/es/index.html @@ -4,7 +4,7 @@ -Generador de archivos de prueba - tamaño exacto, 29 formatos +Generador de archivos de prueba - tamaño exacto, 30 formatos @@ -33,7 +33,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -76,7 +76,7 @@ "@type": "SoftwareApplication", "name": "Testing Files Generator", "alternateName": "tfg", - "description": "Un generador gratuito y de código abierto de archivos de prueba para QA. Produce archivos reales de 29 formatos al tamaño exacto y escribe un manifiesto que dice cómo debe reaccionar el sistema bajo prueba a cada uno.", + "description": "Un generador gratuito y de código abierto de archivos de prueba para QA. Produce archivos reales de 30 formatos al tamaño exacto y escribe un manifiesto que dice cómo debe reaccionar el sistema bajo prueba a cada uno.", "url": "https:\/\/testingfilesgenerator.donislawdev.com/", "applicationCategory": "DeveloperApplication", "applicationSubCategory": "Software Testing", @@ -157,7 +157,7 @@

Genera archivos de prueba reales al tamaño exacto

- PDF, PNG, DOCX, ZIP - 29 formatos en total, y cada uno es un + PDF, PNG, DOCX, ZIP - 30 formatos en total, y cada uno es un archivo real que se abre en el programa que le corresponde, con exactamente el tamaño que pediste. Cada ejecución también anota qué debe hacer tu aplicación con cada archivo. Línea de comandos y ventana de escritorio, gratis y de código abierto, funcionando por @@ -180,7 +180,7 @@

Genera archivos de prueba reales al tamaño exacto