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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ because it turns other people's test suites red.
removes 90% of the picture's content, so a viewer that shows what is left
shows the part of the picture that arrived, the way an interrupted upload
leaves it. Files nothing cuts are unchanged.
- **`truncate` cuts the files inside a ZIP, and the parts of a Word, Excel or
PowerPoint document, by the percentage you set.** `--damage
truncate:missing=50` on a 1 MB ZIP used to cut only padding. Now it removes
half of the files' content, so `tar -xf` gets back the first half of the
file inside. In a small file a cut of a few percent can fall in the index at
its end, and then every file inside is kept - the manifest says `in: end`.
Files nothing cuts are unchanged.

- **tfg is on npm, for tests in Node.**
`npm install --save-dev testing-files-generator` gives Playwright, Vitest
Expand Down
20 changes: 14 additions & 6 deletions internal/format/archive/streammap.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ func (m *StreamMap) Pos() int64 {
return m.out + int64(len(m.held)) + int64(m.Buffer.Buffered())
}

// MarkNext marks the region of role, length bytes long or -1 for up to the
// next mark, that starts with the next byte archive/zip writes.
func (m *StreamMap) MarkNext(role format.Role, length int64) {
format.Mark(m.w, role, m.Pos(), length)
}

func (m *StreamMap) mapped() bool {
_, ok := m.w.(format.Mapper)
return ok
Expand Down Expand Up @@ -121,11 +127,13 @@ func (m *StreamMap) MarkLast(role format.Role, n int64) error {
return nil
}

// MarkEntry marks the region of role that starts with the local header of the
// entry named name, which archive/zip has just written: the last thing held.
// The header is found by its own fields rather than worked out, because
// archive/zip adds an extra field of its own to a header that names a time.
func (m *StreamMap) MarkEntry(role format.Role, name string) error {
// MarkEntry marks the region of role, length bytes long or -1 for up to the
// next mark, that starts with the local header of the entry named name, which
// archive/zip has just written: the last thing held. The header is found by its
// own fields rather than worked out, because archive/zip adds an extra field of
// its own to a header that names a time, and finishes the entry before it with
// a data descriptor only as this one opens.
func (m *StreamMap) MarkEntry(role format.Role, name string, length int64) error {
if !m.holding {
return nil
}
Expand All @@ -138,7 +146,7 @@ func (m *StreamMap) MarkEntry(role format.Role, name string) error {
// the guard of the map reads back as a defect.
return nil
}
format.Mark(m.w, role, m.out+int64(at), -1)
format.Mark(m.w, role, m.out+int64(at), length)
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion internal/format/docx/docx.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
return format.Plan{}, err
}

pkg, err := opc.Settle(parts, shape, r.Bytes)
pkg, err := opc.SettleForACut(parts, shape, r.Bytes, r.CutAt)
if err != nil {
return format.Plan{}, refusal(err, r.Bytes, shape, paragraphs)
}
Expand Down
111 changes: 111 additions & 0 deletions internal/format/opc/cut.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package opc

import (
"fmt"

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

// SettleForACut is Settle for a package a damage will cut after its first
// cutAt bytes (format.Request.CutAt): the padding is split into a part in front
// of every other part and what Settle would put behind them, so that the cut
// keeps the share of the parts it keeps of the whole package.
//
// With all of the padding behind the parts, as a package nothing cuts has it,
// a large package cut at any share kept every part and lost only padding.
// ECMA-376 part 2 names no order of parts and a reader finds them through the
// central directory: Python's zipfile, 7-Zip, bsdtar, python-docx, openpyxl,
// python-pptx and LibreOffice read a package whose padding part comes first as
// they read it today (docs/TRUNCATE-2026-11-11.md section 10.3). The map
// already counts every part as content from the first byte, so nothing of the
// content stands in front of the padding.
//
// The part in front costs its header before any padding, so the front is
// nothing or at least that, and grows where it cannot land exactly - the cut
// then takes a little more of the parts, the "at least" missing promises.
// Where the padding is too short for the parts the cut should take, all of it
// goes in front and the cut keeps more of them by the share of what stands
// behind (section 10.4). The manifest's cut reports what was kept either way.
func SettleForACut(parts []Part, shape Shape, want, cutAt int64) (Package, error) {
p, err := Settle(parts, shape, want)
if err != nil || cutAt <= 0 || cutAt >= want {
return p, err
}
c, err := measureCut(parts, shape)
if err != nil {
return Package{}, err
}
pad := want - shape.Bare
front := format.PaddingInFront(0, c.content, cutAt, want)
if front <= 0 || pad < c.frontOverhead {
return p, nil
}
front = max(front, c.frontHead)
// What the part in front costs in all: its header and padding in front,
// and its record in the central directory behind the parts.
cost := c.frontOverhead + front - c.frontHead
if cost > pad {
cost = pad
front = c.frontHead + pad - c.frontOverhead
}
q := Package{Parts: parts, Filler: NoFiller, Front: front - c.frontHead, Content: c.content}
back := pad - cost
switch {
case back == 0:
case back <= CommentLimit:
q.Comment = int(back)
case back >= c.endOverhead:
q.Filler = back - c.endOverhead
default:
return Package{}, core.Defect(fmt.Errorf("opc: %d B behind the parts is more than a comment carries and less than a padding part costs (%d B)", back, c.endOverhead))
}
return q, nil
}

// cutShape is what SettleForACut needs to know of a set of parts beyond Shape,
// measured by writing them rather than added up from header widths, for the
// reason Shape gives.
type cutShape struct {
// content is how long the parts come to in the archive.
content int64
// frontHead is how many bytes an empty part in front puts before the
// parts, and frontOverhead what it costs in all, its record in the central
// directory with it.
frontHead, frontOverhead int64
// endOverhead is what an empty padding part behind the parts costs when
// one stands in front - it carries the longer name.
endOverhead int64
}

func measureCut(parts []Part, shape Shape) (cutShape, error) {
var c cutShape
bare, err := size(Package{Parts: parts, Filler: NoFiller, Front: NoFiller})
if err != nil {
return c, err
}
front, err := size(Package{Parts: parts, Filler: NoFiller, Front: 0})
if err != nil {
return c, err
}
both, err := size(Package{Parts: parts, Filler: 0, Front: 0})
if err != nil {
return c, err
}
c.content = bare.content.Length
c.frontHead = front.content.At
c.frontOverhead = front.size - shape.Bare
c.endOverhead = both.size - front.size
if bare.size != shape.Bare || front.content.Length != c.content {
return c, core.Defect(fmt.Errorf("opc: the parts measured %d B with %d B of them content, then %d B of content behind a padding part, where planning measured %d B",
bare.size, c.content, front.content.Length, shape.Bare))
}
return c, nil
}

// mapped is a package's length and the region its parts take, read off the map
// it marks as it is written (size).
type mapped struct {
size int64
content format.Region
}
141 changes: 98 additions & 43 deletions internal/format/opc/opc.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ const (
FillerName = "tfg/padding.bin"
fillerExtension = "bin"

// FillerEndName is the padding part behind the others in a package laid
// out for a cut, whose first padding part, FillerName, stands in front of
// them (SettleForACut). A package may not hold two parts of one name
// (ECMA-376 part 2, M1.12), and the content types table declares the
// extension, so both are covered.
FillerEndName = "tfg/padding-end.bin"

// NoFiller means the package carries no padding part at all.
NoFiller = -1

Expand Down Expand Up @@ -88,7 +95,15 @@ type Package struct {
Comment int
// Filler is how many bytes the padding part carries, or NoFiller.
Filler int64
Seed uint64
// Front is how many bytes the padding part in front of every other part
// carries, or NoFiller - which is every package nothing cuts. With a front
// part, the one behind is FillerEndName.
Front int64
// Content is how long the parts come to in the archive, their headers
// with them, when it is known (SettleForACut), and 0 otherwise. A cut
// that falls in them reports how much of them it kept.
Content int64
Seed uint64
}

// ContentTypes builds [Content_Types].xml.
Expand Down Expand Up @@ -140,15 +155,15 @@ type Shape struct {

// Measure works out the shape of a set of parts.
func Measure(parts []Part) (Shape, error) {
bare, err := size(Package{Parts: parts, Filler: NoFiller})
bare, err := size(Package{Parts: parts, Filler: NoFiller, Front: NoFiller})
if err != nil {
return Shape{}, err
}
empty, err := size(Package{Parts: parts, Filler: 0})
empty, err := size(Package{Parts: parts, Filler: 0, Front: NoFiller})
if err != nil {
return Shape{}, err
}
return Shape{Bare: bare, FillerOverhead: empty - bare}, nil
return Shape{Bare: bare.size, FillerOverhead: empty.size - bare.size}, nil
}

// Unreachable says a size cannot be produced, and names the two that can.
Expand Down Expand Up @@ -203,7 +218,7 @@ func Settle(parts []Part, shape Shape, want int64) (Package, error) {
if want > Zip32Ceiling {
return Package{}, &TooLarge{Want: want, Ceiling: Zip32Ceiling}
}
p := Package{Parts: parts, Filler: NoFiller}
p := Package{Parts: parts, Filler: NoFiller, Front: NoFiller}
delta := want - shape.Bare
switch {
case delta == 0:
Expand All @@ -223,12 +238,20 @@ func Settle(parts []Part, shape Shape, want int64) (Package, error) {
}
}

func size(p Package) (int64, error) {
// size is a package's length and the region its parts take, read off the map
// it marks as it is written - SettleForACut asks for the region.
func size(p Package) (mapped, error) {
c := &counter{}
if err := write(context.Background(), c, p, false); err != nil {
return 0, err
rec := &format.MapRecorder{W: c}
if err := write(context.Background(), rec, p, false); err != nil {
return mapped{}, err
}
for _, r := range rec.Map(c.n, true) {
if r.Role == format.RoleContent {
return mapped{size: c.n, content: r}, nil
}
}
return c.n, nil
return mapped{}, core.Defect(fmt.Errorf("opc: a package of %d parts marked no content", len(p.Parts)))
}

// Write emits the package.
Expand All @@ -241,8 +264,22 @@ func write(ctx context.Context, w io.Writer, p Package, withPadding bool) error
zw := zip.NewWriter(zm.Buffer)
// The map: every part is content, from the first byte. Then the filler
// part, the central directory and the comment, each marked as
// archive/zip writes it (archive.StreamMap).
format.Mark(w, format.RoleContent, 0, -1)
// archive/zip writes it (archive.StreamMap). A package laid out for a cut
// opens with a padding part, and the content starts behind it.
behind := FillerName
if p.Front == NoFiller {
format.Mark(w, format.RoleContent, 0, -1)
} else {
if err := writePaddingPart(ctx, zw, zm, paddingPart{FillerName, p.Seed, p.Front, withPadding}); err != nil {
return err
}
content := p.Content
if content <= 0 {
content = -1
}
format.Mark(w, format.RoleContent, zm.Pos(), content)
behind = FillerEndName
}
if p.Comment > 0 {
if p.Comment > CommentLimit {
return core.Defect(fmt.Errorf("opc: a comment of %d B is past the %d B a reader accepts", p.Comment, CommentLimit))
Expand All @@ -268,45 +305,63 @@ func write(ctx context.Context, w io.Writer, p Package, withPadding bool) error
}

if p.Filler != NoFiller {
// Stored rather than compressed, so a byte of padding is a byte of
// file. Deflating it would make the size depend on how well random
// bytes happen to compress, which is not a thing to promise.
//
// The checksum has to be known before the header is written, and the
// padding is never held in memory, so it is generated twice: once to
// check it and once to write it. Both passes are the same arithmetic
// from the same seed, which is what makes them agree.
head := &zip.FileHeader{Name: FillerName, Method: zip.Store, Modified: fixedTime}
sum, err := fillerChecksum(ctx, p.Seed, p.Filler)
if err != nil {
if err := writePaddingPart(ctx, zw, zm, paddingPart{behind, p.Seed, p.Filler, withPadding}); err != nil {
return err
}
head.CRC32 = sum
head.UncompressedSize64 = uint64(p.Filler)
head.CompressedSize64 = uint64(p.Filler)
if err := zm.Hold(); err != nil {
return err
}
entry, err := zw.CreateRaw(head)
if err != nil {
return err
}
if err := zm.MarkEntry(format.RolePadding, FillerName); err != nil {
return err
}
if err := zm.Release(); err != nil {
return err
}
if withPadding {
if err := writeFiller(ctx, entry, p.Seed, p.Filler); err != nil {
return err
}
}
}

return zm.Close(zw)
}

// paddingPart is one padding part: its name, the seed its bytes come from,
// how many it carries, and whether they are written - not while a package is
// only being measured.
type paddingPart struct {
name string
seed uint64
n int64
withPadding bool
}

// writePaddingPart writes a padding part, marked as padding where its header
// starts.
//
// Stored rather than compressed, so a byte of padding is a byte of file.
// Deflating it would make the size depend on how well random bytes happen to
// compress, which is not a thing to promise.
//
// The checksum has to be known before the header is written, and the padding
// is never held in memory, so it is generated twice: once to check it and once
// to write it. Both passes are the same arithmetic from the same seed, which is
// what makes them agree.
func writePaddingPart(ctx context.Context, zw *zip.Writer, zm *archive.StreamMap, part paddingPart) error {
head := &zip.FileHeader{Name: part.name, Method: zip.Store, Modified: fixedTime}
sum, err := fillerChecksum(ctx, part.seed, part.n)
if err != nil {
return err
}
head.CRC32 = sum
head.UncompressedSize64 = uint64(part.n)
head.CompressedSize64 = uint64(part.n)
if err := zm.Hold(); err != nil {
return err
}
entry, err := zw.CreateRaw(head)
if err != nil {
return err
}
if err := zm.MarkEntry(format.RolePadding, part.name, -1); err != nil {
return err
}
if err := zm.Release(); err != nil {
return err
}
if !part.withPadding {
return nil
}
return writeFiller(ctx, entry, part.seed, part.n)
}

// writePart writes one part with its sizes and checksum in the header.
//
// CreateRaw rather than CreateHeader, and this is measured rather than tidy.
Expand Down
2 changes: 1 addition & 1 deletion internal/format/pptx/pptx.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
return format.Plan{}, err
}

pkg, err := opc.Settle(built, shape, r.Bytes)
pkg, err := opc.SettleForACut(built, shape, r.Bytes, r.CutAt)
if err != nil {
return format.Plan{}, refusal(err, r.Bytes, shape, slides)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/format/xlsx/xlsx.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
return format.Plan{}, err
}

pkg, err := opc.Settle(built, shape, r.Bytes)
pkg, err := opc.SettleForACut(built, shape, r.Bytes, r.CutAt)
if err != nil {
return format.Plan{}, refusal(err, r.Bytes, shape, rows, columns)
}
Expand Down
Loading
Loading