Skip to content

Commit 0435008

Browse files
authored
add support for Info-ZIP timestamp extra field (#86)
1 parent 09a1dfd commit 0435008

File tree

5 files changed

+116
-16
lines changed

5 files changed

+116
-16
lines changed

README.md

+22-5
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ After UTF-8 encoding, `metadataPath` must be at most `0xffff` bytes in length.
6565
compress: true,
6666
compressionLevel: 6,
6767
forceZip64Format: false,
68+
forceDosTimestamp: false,
6869
fileComment: "", // or a UTF-8 Buffer
6970
}
7071
```
@@ -86,6 +87,14 @@ If `forceZip64Format` is `true`, yazl will use ZIP64 format in this entry's Data
8687
and Central Directory Record even if not needed (this may be useful for testing.).
8788
Otherwise, yazl will use ZIP64 format where necessary.
8889

90+
Since yazl version 3.3.0, yazl includes the Info-ZIP "universal timestamp" extended field (`0x5455` aka `"UT"`) to encode the `mtime`.
91+
The Info-ZIP timestamp is a more modern encoding for the mtime and is generally recommended.
92+
Set `forceDosTimestamp` to `true` to revert to the pre-3.3.0 behvior, disabling this extended field.
93+
The DOS encoding is always included regardless of this option, because it is required in the fixed-size metadata of every archive entry.
94+
The benefits of the Info-ZIP encoding include: timezone is specified as always UTC, which is better for cloud environments and any teams working in multiple timezones; capable of encoding "time 0", the unix epoch in 1970, which is better for some package managers; the precision is 1-second accurate rather than rounded to the nearest even second. The disadvantages of including this field are: it requires an extra 9 bytes of metadata per entry added to the archive.
95+
96+
When attempting to encode an `mtime` outside the supported range for either format, such as the year 1970 in the DOS format or the year 2039 for the modern format, the time will clamped to the closest supported time.
97+
8998
If `fileComment` is a `string`, it will be encoded with UTF-8.
9099
If `fileComment` is a `Buffer`, it should be a UTF-8 encoded string.
91100
In UTF-8, `fileComment` must be at most `0xffff` bytes in length.
@@ -126,12 +135,13 @@ See `addFile()` for the meaning of the `metadataPath` parameter.
126135
compress: true,
127136
compressionLevel: 6,
128137
forceZip64Format: false,
138+
forceDosTimestamp: false,
129139
fileComment: "", // or a UTF-8 Buffer
130140
size: 12345, // example value
131141
}
132142
```
133143

134-
See `addFile()` for the meaning of `mtime`, `mode`, `compress`, `compressionLevel`, `forceZip64Format`, and `fileComment`.
144+
See `addFile()` for the meaning of `mtime`, `mode`, `compress`, `compressionLevel`, `forceZip64Format`, `forceDosTimestamp`, and `fileComment`.
135145
If `size` is given, it will be checked against the actual number of bytes in the `readStream`,
136146
and an error will be emitted if there is a mismatch.
137147
See the documentation on `calculatedTotalSizeCallback` for why the `size` option exists.
@@ -162,11 +172,12 @@ See `addFile()` for info about the `metadataPath` parameter.
162172
compress: true,
163173
compressionLevel: 6,
164174
forceZip64Format: false,
175+
forceDosTimestamp: false,
165176
fileComment: "", // or a UTF-8 Buffer
166177
}
167178
```
168179

169-
See `addFile()` for the meaning of `mtime`, `mode`, `compress`, `compressionLevel`, `forceZip64Format`, and `fileComment`.
180+
See `addFile()` for the meaning of `mtime`, `mode`, `compress`, `compressionLevel`, `forceZip64Format`, `forceDosTimestamp`, and `fileComment`.
170181

171182
This method has the unique property that General Purpose Bit `3` will not be used in the Local File Header.
172183
This doesn't matter for unzip implementations that conform to the Zip File Spec.
@@ -210,10 +221,11 @@ If `metadataPath` does not end with a `"/"`, a `"/"` will be appended.
210221
{
211222
mtime: new Date(),
212223
mode: 040775,
224+
forceDosTimestamp: false,
213225
}
214226
```
215227

216-
See `addFile()` for the meaning of `mtime` and `mode`.
228+
See `addFile()` for the meaning of `mtime`, `mode`, and `forceDosTimestamp`.
217229

218230
#### end([options], [calculatedTotalSizeCallback])
219231

@@ -285,8 +297,13 @@ In certain versions of node, you cannot use both `.on('data')` and `.pipe()` suc
285297

286298
### dateToDosDateTime(jsDate)
287299

288-
`jsDate` is a `Date` instance.
289-
Returns `{date: date, time: time}`, where `date` and `time` are unsigned 16-bit integers.
300+
*Deprecated* since yazl 3.3.0.
301+
302+
This function only remains exported in order to maintain compatibility with older versions of yazl.
303+
It will be removed in yazl 4.0.0 unless someone asks for it to remain supported.
304+
If you ever have a use case for calling this function directly please
305+
[open an issue against yazl](https://github.com/thejoshwolfe/yazl/issues/new)
306+
requesting that this function be properly supported again.
290307

291308
## Regarding ZIP64 Support
292309

index.js

+42-6
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ function calculateTotalSize(self) {
279279
}
280280

281281
centralDirectorySize += CENTRAL_DIRECTORY_RECORD_FIXED_SIZE + entry.utf8FileName.length + entry.fileComment.length;
282+
if (!entry.forceDosTimestamp) {
283+
centralDirectorySize += INFO_ZIP_UNIVERSAL_TIMESTAMP_EXTRA_FIELD_SIZE;
284+
}
282285
if (useZip64Format) {
283286
centralDirectorySize += ZIP64_EXTENDED_INFORMATION_EXTRA_FIELD_SIZE;
284287
}
@@ -427,6 +430,7 @@ function Entry(metadataPath, isDirectory, options) {
427430
this.isDirectory = isDirectory;
428431
this.state = Entry.WAITING_FOR_METADATA;
429432
this.setLastModDate(options.mtime != null ? options.mtime : new Date());
433+
this.forceDosTimestamp = !!options.forceDosTimestamp;
430434
if (options.mode != null) {
431435
this.setFileAttributesMode(options.mode);
432436
} else {
@@ -469,6 +473,7 @@ Entry.READY_TO_PUMP_FILE_DATA = 1;
469473
Entry.FILE_DATA_IN_PROGRESS = 2;
470474
Entry.FILE_DATA_DONE = 3;
471475
Entry.prototype.setLastModDate = function(date) {
476+
this.mtime = date;
472477
var dosDateTime = dateToDosDateTime(date);
473478
this.lastModFileTime = dosDateTime.time;
474479
this.lastModFileDate = dosDateTime.date;
@@ -575,17 +580,42 @@ Entry.prototype.getDataDescriptor = function() {
575580
}
576581
};
577582
var CENTRAL_DIRECTORY_RECORD_FIXED_SIZE = 46;
583+
var INFO_ZIP_UNIVERSAL_TIMESTAMP_EXTRA_FIELD_SIZE = 9;
578584
var ZIP64_EXTENDED_INFORMATION_EXTRA_FIELD_SIZE = 28;
579585
Entry.prototype.getCentralDirectoryRecord = function() {
580586
var fixedSizeStuff = bufferAlloc(CENTRAL_DIRECTORY_RECORD_FIXED_SIZE);
581587
var generalPurposeBitFlag = FILE_NAME_IS_UTF8;
582588
if (!this.crcAndFileSizeKnown) generalPurposeBitFlag |= UNKNOWN_CRC32_AND_FILE_SIZES;
583589

590+
var izutefBuffer = EMPTY_BUFFER;
591+
if (!this.forceDosTimestamp) {
592+
// Here is one specification for this: https://commons.apache.org/proper/commons-compress/apidocs/org/apache/commons/compress/archivers/zip/X5455_ExtendedTimestamp.html
593+
// See also the Info-ZIP source code unix/unix.c:set_extra_field() and zipfile.c:ef_scan_ut_time().
594+
izutefBuffer = bufferAlloc(INFO_ZIP_UNIVERSAL_TIMESTAMP_EXTRA_FIELD_SIZE);
595+
// 0x5455 Short tag for this extra block type ("UT")
596+
izutefBuffer.writeUInt16LE(0x5455, 0);
597+
// TSize Short total data size for this block
598+
izutefBuffer.writeUInt16LE(INFO_ZIP_UNIVERSAL_TIMESTAMP_EXTRA_FIELD_SIZE - 4, 2);
599+
// See Info-ZIP source code zip.h for these constant values:
600+
var EB_UT_FL_MTIME = (1 << 0);
601+
var EB_UT_FL_ATIME = (1 << 1);
602+
// Note that we set the atime flag despite not providing the atime field.
603+
// The central directory version of this extra field is specified to never contain the atime field even when the flag is set.
604+
// We set it to match the Info-ZIP behavior in order to minimize incompatibility with zip file readers that may have rigid input expectations.
605+
// Flags Byte info bits
606+
izutefBuffer.writeUInt8(EB_UT_FL_MTIME | EB_UT_FL_ATIME, 4);
607+
// (ModTime) Long time of last modification (UTC/GMT)
608+
var timestamp = Math.floor(this.mtime.getTime() / 1000);
609+
if (timestamp < -0x80000000) timestamp = -0x80000000; // 1901-12-13T20:45:52.000Z
610+
if (timestamp > 0x7fffffff) timestamp = 0x7fffffff; // 2038-01-19T03:14:07.000Z
611+
izutefBuffer.writeUInt32LE(timestamp, 5);
612+
}
613+
584614
var normalCompressedSize = this.compressedSize;
585615
var normalUncompressedSize = this.uncompressedSize;
586616
var normalRelativeOffsetOfLocalHeader = this.relativeOffsetOfLocalHeader;
587-
var versionNeededToExtract;
588-
var zeiefBuffer;
617+
var versionNeededToExtract = VERSION_NEEDED_TO_EXTRACT_UTF8;
618+
var zeiefBuffer = EMPTY_BUFFER;
589619
if (this.useZip64Format()) {
590620
normalCompressedSize = 0xffffffff;
591621
normalUncompressedSize = 0xffffffff;
@@ -606,9 +636,6 @@ Entry.prototype.getCentralDirectoryRecord = function() {
606636
writeUInt64LE(zeiefBuffer, this.relativeOffsetOfLocalHeader, 20);
607637
// Disk Start Number 4 bytes Number of the disk on which this file starts
608638
// (omit)
609-
} else {
610-
versionNeededToExtract = VERSION_NEEDED_TO_EXTRACT_UTF8;
611-
zeiefBuffer = EMPTY_BUFFER;
612639
}
613640

614641
// central file header signature 4 bytes (0x02014b50)
@@ -634,7 +661,7 @@ Entry.prototype.getCentralDirectoryRecord = function() {
634661
// file name length 2 bytes
635662
fixedSizeStuff.writeUInt16LE(this.utf8FileName.length, 28);
636663
// extra field length 2 bytes
637-
fixedSizeStuff.writeUInt16LE(zeiefBuffer.length, 30);
664+
fixedSizeStuff.writeUInt16LE(izutefBuffer.length + zeiefBuffer.length, 30);
638665
// file comment length 2 bytes
639666
fixedSizeStuff.writeUInt16LE(this.fileComment.length, 32);
640667
// disk number start 2 bytes
@@ -651,6 +678,7 @@ Entry.prototype.getCentralDirectoryRecord = function() {
651678
// file name (variable size)
652679
this.utf8FileName,
653680
// extra field (variable size)
681+
izutefBuffer,
654682
zeiefBuffer,
655683
// file comment (variable size)
656684
this.fileComment,
@@ -662,7 +690,15 @@ Entry.prototype.getCompressionMethod = function() {
662690
return this.compressionLevel === 0 ? NO_COMPRESSION : DEFLATE_COMPRESSION;
663691
};
664692

693+
// These are intentionally computed in the current system timezone
694+
// to match how the DOS encoding operates in this library.
695+
var minDosDate = new Date(1980, 0, 1);
696+
var maxDosDate = new Date(2107, 11, 31, 23, 59, 58);
665697
function dateToDosDateTime(jsDate) {
698+
// Clamp out of bounds timestamps.
699+
if (jsDate < minDosDate) jsDate = minDosDate;
700+
else if (jsDate > maxDosDate) jsDate = maxDosDate;
701+
666702
var date = 0;
667703
date |= jsDate.getDate() & 0x1f; // 1-31
668704
date |= ((jsDate.getMonth() + 1) & 0xf) << 5; // 0-11, 1-12

package-lock.json

+4-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"buffer-crc32": "^1.0.0"
2727
},
2828
"devDependencies": {
29-
"yauzl": "^3.1.3"
29+
"yauzl": "^3.2.0"
3030
},
3131
"files": [
3232
"index.js"

test/test.js

+47
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,53 @@ var BufferList = require("./bl-minimal.js");
8787
});
8888
})();
8989

90+
// Test:
91+
// * specifying mtime outside the bounds of dos format but in bounds for unix format.
92+
// * forceDosTimestamp, and verifying the lower clamping for dos format.
93+
// * specifying mtime after 2038, and verifying the clamping for unix format.
94+
(function() {
95+
var options = {
96+
mtime: new Date(0), // unix epoch
97+
mode: 0o100664,
98+
compress: false,
99+
};
100+
var zipfile = new yazl.ZipFile();
101+
zipfile.addFile(__filename, "modern-1970.txt", options);
102+
options.forceDosTimestamp = true;
103+
zipfile.addFile(__filename, "dos-1970.txt", options);
104+
options.forceDosTimestamp = false;
105+
options.mtime = new Date(2080, 1, 1); // year 2080 is beyond the unix range.
106+
zipfile.addFile(__filename, "2080.txt", options);
107+
zipfile.end(function(calculatedTotalSize) {
108+
if (calculatedTotalSize === -1) throw new Error("calculatedTotalSize should be known");
109+
zipfile.outputStream.pipe(new BufferList(function(err, data) {
110+
if (err) throw err;
111+
if (data.length !== calculatedTotalSize) throw new Error("calculatedTotalSize prediction is wrong. " + calculatedTotalSize + " !== " + data.length);
112+
yauzl.fromBuffer(data, function(err, zipfile) {
113+
if (err) throw err;
114+
zipfile.on("entry", function(entry) {
115+
switch (entry.fileName) {
116+
case "modern-1970.txt":
117+
if (entry.getLastModDate().getTime() !== 0) throw new Error("expected unix epoch to be encodable. found: " + entry.getLastModDate());
118+
break;
119+
case "dos-1970.txt":
120+
var year = entry.getLastModDate().getFullYear();
121+
if (!(1979 <= year && year <= 1981)) throw new Error("expected dos format year to be clamped to 1980ish. found: " + entry.getLastModDate());
122+
break;
123+
case "2080.txt":
124+
if (entry.getLastModDate().getUTCFullYear() !== 2038) throw new Error("expected timestamp clamped down to year 2038. found: " + entry.getLastModDate());
125+
break;
126+
default: throw new Error(entry.fileName);
127+
}
128+
});
129+
zipfile.on("end", function() {
130+
console.log("timestamp encodings: pass");
131+
});
132+
});
133+
}));
134+
});
135+
})();
136+
90137
// Test:
91138
// * forceZip64Format for various subsets of entries.
92139
// * specifying size for addReadStream.

0 commit comments

Comments
 (0)