diff --git a/src/archive/zip/writer.go b/src/archive/zip/writer.go index 0a310054e37678..b55b5165eda663 100644 --- a/src/archive/zip/writer.go +++ b/src/archive/zip/writer.go @@ -446,6 +446,34 @@ func (w *Writer) CreateRaw(fh *FileHeader) (io.Writer, error) { fh.CompressedSize = uint32(min(fh.CompressedSize64, uint32max)) fh.UncompressedSize = uint32(min(fh.UncompressedSize64, uint32max)) + // If Modified is set, this takes precedence over MS-DOS timestamp fields. + if !fh.Modified.IsZero() { + // Contrary to the FileHeader.SetModTime method, we intentionally + // do not convert to UTC, because we assume the user intends to encode + // the date using the specified timezone. A user may want this control + // because many legacy ZIP readers interpret the timestamp according + // to the local timezone. + // + // The timezone is only non-UTC if a user directly sets the Modified + // field directly themselves. All other approaches sets UTC. + fh.ModifiedDate, fh.ModifiedTime = timeToMsDosTime(fh.Modified) + + // Use "extended timestamp" format since this is what Info-ZIP uses. + // Nearly every major ZIP implementation uses a different format, + // but at least most seem to be able to understand the other formats. + // + // This format happens to be identical for both local and central header + // if modification time is the only timestamp being encoded. + var mbuf [9]byte // 2*SizeOf(uint16) + SizeOf(uint8) + SizeOf(uint32) + mt := uint32(fh.Modified.Unix()) + eb := writeBuf(mbuf[:]) + eb.uint16(extTimeExtraID) + eb.uint16(5) // Size: SizeOf(uint8) + SizeOf(uint32) + eb.uint8(1) // Flags: ModTime + eb.uint32(mt) // ModTime + fh.Extra = append(fh.Extra, mbuf[:]...) + } + h := &header{ FileHeader: fh, offset: uint64(w.cw.count), diff --git a/src/archive/zip/writer_test.go b/src/archive/zip/writer_test.go index 44592ce8318826..41255022ec0b42 100644 --- a/src/archive/zip/writer_test.go +++ b/src/archive/zip/writer_test.go @@ -533,6 +533,49 @@ func TestWriterCreateRaw(t *testing.T) { } } +func TestWriterCreateRawTime(t *testing.T) { + // Test that CreateRaw honors the Modified field in FileHeader. + // See https://go.dev/issue/76741 + content := []byte("test content") + modified := time.Date(2023, 6, 15, 10, 30, 45, 0, time.UTC) + + // Write a zip file using CreateRaw with Modified set. + var buf bytes.Buffer + w := NewWriter(&buf) + + h := &FileHeader{ + Name: "test.txt", + Method: Store, + CRC32: crc32.ChecksumIEEE(content), + CompressedSize64: uint64(len(content)), + UncompressedSize64: uint64(len(content)), + Modified: modified, + } + raw, err := w.CreateRaw(h) + if err != nil { + t.Fatalf("CreateRaw: %v", err) + } + if _, err := raw.Write(content); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // Read it back and verify the Modified time. + r, err := NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + if len(r.File) != 1 { + t.Fatalf("got %d files; want 1", len(r.File)) + } + got := r.File[0] + if !got.Modified.Equal(modified) { + t.Errorf("Modified = %v; want %v", got.Modified, modified) + } +} + func testCreate(t *testing.T, w *Writer, wt *WriteTest) { header := &FileHeader{ Name: wt.Name,