From 22183b523240e7a2f03945ff21b4befc513e49a9 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 31 Aug 2026 17:02:53 -0400 Subject: [PATCH] feat(sdk): make the zipstream clock injectable for deterministic ZIP output zipstream stamped ZIP header and segment-metadata timestamps straight from time.Now, so archive bytes could never be compared byte-for-byte across runs. Config gains a Now func() time.Time, defaulted to time.Now and overridable via WithClock; NewSegmentMetadata takes the time source explicitly and SegmentEntry.Written stamps from it. Now is an exported field on an exported Config and Option is a bare func(*Config), so an option is free to nil it out even though WithClock will not. applyOptions restores the default rather than letting the first header stamp panic. Injecting a clock also makes the MS-DOS date encoder reachable with years it cannot represent. The year is a 7-bit offset from 1980, so an out-of-range value wrapped through the uint16 conversion into a plausible but wrong date, silently and with no error: the zero time.Time landed on 2049-01-01 and the Unix epoch on 2098-01-01. The two copies of the encoder, one for the local file header and one for the central directory, are now a single msDosTimeDate that clamps to 1980..2107, so fixing one copy cannot leave the other wrapped. No behavior change for existing callers: sdk/tdf.go constructs the writer without WithClock and keeps time.Now, which is always in range. Signed-off-by: David Mihalcik --- sdk/internal/zipstream/segment_writer.go | 21 +--- sdk/internal/zipstream/segment_writer_test.go | 108 ++++++++++++++++++ sdk/internal/zipstream/writer.go | 25 ++++ sdk/internal/zipstream/zip_headers.go | 3 + sdk/internal/zipstream/zip_primitives.go | 51 ++++++++- 5 files changed, 187 insertions(+), 21 deletions(-) diff --git a/sdk/internal/zipstream/segment_writer.go b/sdk/internal/zipstream/segment_writer.go index 2c2500be8d..b6f1d7191f 100644 --- a/sdk/internal/zipstream/segment_writer.go +++ b/sdk/internal/zipstream/segment_writer.go @@ -10,7 +10,6 @@ import ( "hash/crc32" "sort" "sync" - "time" ) // segmentWriter implements the SegmentWriter interface for out-of-order segment writing @@ -36,12 +35,12 @@ func NewSegmentTDFWriter(expectedSegments int, opts ...Option) SegmentWriter { return &segmentWriter{ baseWriter: base, - metadata: NewSegmentMetadata(expectedSegments), + metadata: NewSegmentMetadata(expectedSegments, cfg.Now), centralDir: NewCentralDirectory(), payloadEntry: &FileEntry{ Name: TDFPayloadFileName, Offset: 0, - ModTime: time.Now(), + ModTime: cfg.Now(), IsStreaming: true, // Use data descriptor pattern }, finalized: false, @@ -191,7 +190,7 @@ func (sw *segmentWriter) Finalize(ctx context.Context, manifest []byte) ([]byte, Size: uint64(len(manifest)), CompressedSize: uint64(len(manifest)), CRC32: crc32.ChecksumIEEE(manifest), - ModTime: time.Now(), + ModTime: sw.config.Now(), IsStreaming: false, } @@ -264,7 +263,7 @@ func (sw *segmentWriter) writeDataDescriptor(buf *bytes.Buffer, zip64 bool) erro // writeManifestFile writes the manifest as a complete file entry func (sw *segmentWriter) writeManifestFile(buf *bytes.Buffer, manifest []byte, entry FileEntry) error { - fileTime, fileDate := sw.getTimeDateInMSDosFormat(entry.ModTime) + fileTime, fileDate := msDosTimeDate(entry.ModTime) // Write local file header for manifest header := LocalFileHeader{ @@ -298,19 +297,9 @@ func (sw *segmentWriter) writeManifestFile(buf *bytes.Buffer, manifest []byte, e return nil } -// getTimeDateInMSDosFormat converts time to MS-DOS format -func (sw *segmentWriter) getTimeDateInMSDosFormat(t time.Time) (uint16, uint16) { - const monthShift = 5 - - timeInDos := t.Hour()<<11 | t.Minute()<<5 | t.Second()>>1 - dateInDos := (t.Year()-zipBaseYear)<<9 | int((t.Month())<>1 + dosDate := (t.Year()-zipBaseYear)<<9 | int(t.Month())<<5 | t.Day() + return uint16(dosTime), uint16(dosDate) +} + +// TestWriterClampsOutOfRangeClock drives a full archive with an epoch +// clock and reads the timestamps back through archive/zip, covering the +// WithClock -> local header -> central directory path end to end. +func TestWriterClampsOutOfRangeClock(t *testing.T) { + ctx := t.Context() + writer := NewSegmentTDFWriter(1, WithClock(func() time.Time { return time.Unix(0, 0).UTC() })) + + payload := []byte("payload") + segmentBytes, err := writer.WriteSegment(ctx, 0, uint64(len(payload)), crc32.ChecksumIEEE(payload)) + require.NoError(t, err) + + var allBytes []byte + allBytes = append(allBytes, segmentBytes...) + allBytes = append(allBytes, payload...) + + finalBytes, err := writer.Finalize(ctx, []byte(`{"manifest":true}`)) + require.NoError(t, err) + allBytes = append(allBytes, finalBytes...) + + zipReader, err := zip.NewReader(bytes.NewReader(allBytes), int64(len(allBytes))) + require.NoError(t, err, "epoch clock should still produce a readable ZIP") + require.Len(t, zipReader.File, 2) + + for _, f := range zipReader.File { + assert.Equal(t, zipBaseYear, f.Modified.Year(), + "%s should be clamped to the base year, not wrapped into the future", f.Name) + } +} diff --git a/sdk/internal/zipstream/writer.go b/sdk/internal/zipstream/writer.go index bebd50fcc8..11eea8a5b5 100644 --- a/sdk/internal/zipstream/writer.go +++ b/sdk/internal/zipstream/writer.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "sync" + "time" ) const ( @@ -60,6 +61,10 @@ type Config struct { Zip64 Zip64Mode MaxSegments int EnableLogging bool + // Now returns the current time for header/metadata timestamps. + // Defaults to time.Now; tests inject a pinned clock for + // deterministic ZIP output. + Now func() time.Time } // Option is a functional option for configuring writers @@ -100,12 +105,24 @@ func WithLogging() Option { } } +// WithClock overrides the time source used to stamp ZIP headers and +// segment metadata. Tests inject a pinned clock to make output byte- +// for-byte deterministic. +func WithClock(now func() time.Time) Option { + return func(c *Config) { + if now != nil { + c.Now = now + } + } +} + // defaultConfig returns default configuration func defaultConfig() *Config { return &Config{ Zip64: Zip64Auto, MaxSegments: defaultMaxSegments, EnableLogging: false, + Now: time.Now, } } @@ -115,6 +132,14 @@ func applyOptions(opts []Option) *Config { for _, opt := range opts { opt(cfg) } + // Now is an exported field on an exported Config and Option is a + // bare func(*Config), so an option is free to clear it even though + // WithClock will not. Restore the default rather than let the + // writers panic on the first header stamp; NewSegmentMetadata + // already defends the same way. + if cfg.Now == nil { + cfg.Now = time.Now + } return cfg } diff --git a/sdk/internal/zipstream/zip_headers.go b/sdk/internal/zipstream/zip_headers.go index 028a823975..f6ad2bde76 100644 --- a/sdk/internal/zipstream/zip_headers.go +++ b/sdk/internal/zipstream/zip_headers.go @@ -22,6 +22,9 @@ const ( const ( zipBaseYear = 1980 // ZIP file format base year for date calculations + // zipMaxYear is the newest year an MS-DOS date can hold: the year is a + // 7-bit offset from zipBaseYear. + zipMaxYear = zipBaseYear + 127 ) const ( diff --git a/sdk/internal/zipstream/zip_primitives.go b/sdk/internal/zipstream/zip_primitives.go index b695889625..b783954e11 100644 --- a/sdk/internal/zipstream/zip_primitives.go +++ b/sdk/internal/zipstream/zip_primitives.go @@ -42,15 +42,23 @@ type SegmentMetadata struct { // Order, when set, defines the exact logical order of segments for // completeness checks and CRC computation. Indices may be sparse. Order []int + // now is the injected time source used to stamp SegmentEntry.Written. + now func() time.Time } -// NewSegmentMetadata creates metadata for tracking segments using combine-based CRC. -func NewSegmentMetadata(expectedCount int) *SegmentMetadata { +// NewSegmentMetadata creates metadata for tracking segments using +// combine-based CRC. now stamps SegmentEntry.Written; pass time.Now +// for production or a pinned clock for deterministic tests. +func NewSegmentMetadata(expectedCount int, now func() time.Time) *SegmentMetadata { + if now == nil { + now = time.Now + } return &SegmentMetadata{ ExpectedCount: expectedCount, Segments: make(map[int]*SegmentEntry), presentCount: 0, TotalCRC32: 0, + now: now, } } @@ -69,7 +77,7 @@ func (sm *SegmentMetadata) AddSegment(index int, originalSize uint64, originalCR Index: index, Size: originalSize, CRC32: originalCRC32, - Written: time.Now(), + Written: sm.now(), } sm.TotalSize += originalSize @@ -234,16 +242,49 @@ func (cd *CentralDirectory) GenerateBytes(isZip64 bool) ([]byte, error) { return buf.Bytes(), nil } +// msDosTimeDate encodes t as the (time, date) pair ZIP headers carry. +// +// The date packs the year as a 7-bit offset from zipBaseYear, so only +// zipBaseYear..zipMaxYear is representable. An out-of-range year would +// wrap through the uint16 conversion into a plausible but wrong date -- +// the zero time.Time lands on 2049-01-01 and the Unix epoch on +// 2098-01-01 -- so clamp to the endpoints instead. time.Now is always in +// range; a clock injected via WithClock is the only way to get here. +func msDosTimeDate(t time.Time) (uint16, uint16) { + const ( + hourShift = 11 + minuteShift = 5 + monthShift = 5 + yearShift = 9 + ) + + // Compare on calendar fields, not instants: DOS timestamps are local + // wall-clock with no zone, and those fields are what get encoded. + switch { + case t.Year() < zipBaseYear: + t = time.Date(zipBaseYear, time.January, 1, 0, 0, 0, 0, t.Location()) + case t.Year() > zipMaxYear: + t = time.Date(zipMaxYear, time.December, 31, 23, 59, 58, 0, t.Location()) + } + + timeInDos := t.Hour()<>1 + dateInDos := (t.Year()-zipBaseYear)<>1), - LastModifiedDate: uint16((entry.ModTime.Year()-zipBaseYear)<<9 | int(entry.ModTime.Month())<<5 | entry.ModTime.Day()), + LastModifiedTime: lastModifiedTime, + LastModifiedDate: lastModifiedDate, Crc32: entry.CRC32, CompressedSize: uint32(entry.CompressedSize), UncompressedSize: uint32(entry.Size),