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
21 changes: 5 additions & 16 deletions sdk/internal/zipstream/segment_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"hash/crc32"
"sort"
"sync"
"time"
)

// segmentWriter implements the SegmentWriter interface for out-of-order segment writing
Expand All @@ -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,
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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())<<monthShift) | t.Day()

return uint16(timeInDos), uint16(dateInDos)
}

// writeLocalFileHeader writes the ZIP local file header for the payload
func (sw *segmentWriter) writeLocalFileHeader(buf *bytes.Buffer) error {
fileTime, fileDate := sw.getTimeDateInMSDosFormat(sw.payloadEntry.ModTime)
fileTime, fileDate := msDosTimeDate(sw.payloadEntry.ModTime)

header := LocalFileHeader{
Signature: fileHeaderSignature,
Expand Down
108 changes: 108 additions & 0 deletions sdk/internal/zipstream/segment_writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"hash/crc32"
"io"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -518,3 +519,110 @@ func benchmarkSegmentWriter(b *testing.B, name string, writeOrder []int) {
}
})
}

// TestApplyOptionsRestoresNilClock covers an option clearing Config.Now.
// Now is exported on an exported Config and Option is a bare
// func(*Config), so nothing stops an option from doing this; without a
// restore the first header stamp panics in NewSegmentTDFWriter.
func TestApplyOptionsRestoresNilClock(t *testing.T) {
clearClock := func(c *Config) { c.Now = nil }

cfg := applyOptions([]Option{clearClock})
require.NotNil(t, cfg.Now)
assert.False(t, cfg.Now().IsZero())

assert.NotPanics(t, func() {
NewSegmentTDFWriter(1, clearClock)
})
}

// TestMSDosTimeDateClampsOutOfRangeYears pins the clamping behaviour for
// years the MS-DOS date cannot hold. The "wraps to" column is what the
// unclamped encoder produced, and is the reason clamping exists: every
// one of those is a silently wrong date, not a failure.
func TestMSDosTimeDateClampsOutOfRangeYears(t *testing.T) {
for _, tc := range []struct {
name string
in time.Time
want time.Time
wrapsTo string
}{
{
name: "unix epoch",
in: time.Unix(0, 0).UTC(),
want: time.Date(zipBaseYear, time.January, 1, 0, 0, 0, 0, time.UTC),
wrapsTo: "2098-01-01",
},
{
name: "zero time",
in: time.Time{},
want: time.Date(zipBaseYear, time.January, 1, 0, 0, 0, 0, time.UTC),
wrapsTo: "2049-01-01",
},
{
name: "one second before the base year",
in: time.Date(zipBaseYear-1, time.December, 31, 23, 59, 59, 0, time.UTC),
want: time.Date(zipBaseYear, time.January, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "past the last representable year",
in: time.Date(zipMaxYear+1, time.January, 1, 0, 0, 0, 0, time.UTC),
want: time.Date(zipMaxYear, time.December, 31, 23, 59, 58, 0, time.UTC),
},
{
name: "in range is untouched",
in: time.Date(2024, time.June, 1, 12, 30, 8, 0, time.UTC),
want: time.Date(2024, time.June, 1, 12, 30, 8, 0, time.UTC),
},
{
name: "first representable instant",
in: time.Date(zipBaseYear, time.January, 1, 0, 0, 0, 0, time.UTC),
want: time.Date(zipBaseYear, time.January, 1, 0, 0, 0, 0, time.UTC),
},
} {
t.Run(tc.name, func(t *testing.T) {
dosTime, dosDate := msDosTimeDate(tc.in)

wantTime, wantDate := encodeMSDos(tc.want)
assert.Equal(t, wantDate, dosDate, "date; unclamped this wrapped to %s", tc.wrapsTo)
assert.Equal(t, wantTime, dosTime, "time")
})
}
}

// encodeMSDos packs t without any clamping, so the expectations above are
// derived independently of the code under test.
func encodeMSDos(t time.Time) (uint16, uint16) {
dosTime := t.Hour()<<11 | t.Minute()<<5 | t.Second()>>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)
}
}
25 changes: 25 additions & 0 deletions sdk/internal/zipstream/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"sync"
"time"
)

const (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions sdk/internal/zipstream/zip_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
51 changes: 46 additions & 5 deletions sdk/internal/zipstream/zip_primitives.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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()<<hourShift | t.Minute()<<minuteShift | t.Second()>>1
dateInDos := (t.Year()-zipBaseYear)<<yearShift | int(t.Month())<<monthShift | t.Day()

return uint16(timeInDos), uint16(dateInDos)
}

// writeCDFileHeader writes a central directory file header
func (cd *CentralDirectory) writeCDFileHeader(buf *bytes.Buffer, entry FileEntry, isZip64 bool) error {
lastModifiedTime, lastModifiedDate := msDosTimeDate(entry.ModTime)

header := CDFileHeader{
Signature: centralDirectoryHeaderSignature,
VersionCreated: zipVersion,
VersionNeeded: zipVersion,
GeneralPurposeBitFlag: 0,
CompressionMethod: 0, // No compression
LastModifiedTime: uint16(entry.ModTime.Hour()<<11 | entry.ModTime.Minute()<<5 | entry.ModTime.Second()>>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),
Expand Down
Loading