Skip to content

Commit 831b43f

Browse files
committed
gh-121021: Support writing extended timestamps for zipfile
Introduce `with_ext_timestamps` parameter to `ZipFile` and `ZipInfo.from_file()` to fill extra data with extended timestamps.
1 parent 65585ca commit 831b43f

4 files changed

Lines changed: 133 additions & 5 deletions

File tree

Doc/library/zipfile.rst

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ ZipFile objects
178178

179179
.. class:: ZipFile(file, mode='r', compression=ZIP_STORED, allowZip64=True, \
180180
compresslevel=None, *, strict_timestamps=True, \
181-
metadata_encoding=None)
181+
with_ext_timestamps=False, metadata_encoding=None)
182182
183183
Open a ZIP file, where *file* can be a path to a file (a string), a
184184
file-like object or a :term:`path-like object`.
@@ -227,6 +227,9 @@ ZipFile objects
227227
Similar behavior occurs with files newer than 2107-12-31,
228228
the timestamp is also set to the limit.
229229

230+
The *with_ext_timestamps* controls whether to fill the extended timestamps
231+
when writing files to the archive.
232+
230233
When mode is ``'r'``, *metadata_encoding* may be set to the name of a codec,
231234
which will be used to decode metadata such as the names of members and ZIP
232235
comments.
@@ -285,6 +288,9 @@ ZipFile objects
285288
Added support for specifying member name encoding for reading
286289
metadata in the zipfile's directory and file headers.
287290

291+
.. versionchanged:: next
292+
Added the *with_ext_timestamps* keyword-only parameter.
293+
288294

289295
.. method:: ZipFile.close()
290296

@@ -885,7 +891,8 @@ There is one classmethod to make a :class:`ZipInfo` instance for a filesystem
885891
file:
886892

887893
.. classmethod:: ZipInfo.from_file(filename, arcname=None, *, \
888-
strict_timestamps=True)
894+
strict_timestamps=True, \
895+
with_ext_timestamps=False)
889896

890897
Construct a :class:`ZipInfo` instance for a file on the filesystem, in
891898
preparation for adding it to a zip file.
@@ -902,6 +909,10 @@ file:
902909
Similar behavior occurs with files newer than 2107-12-31,
903910
the timestamp is also set to the limit.
904911

912+
Setting ``with_ext_timestamps=True`` fills the file's extended timestamps
913+
to the extra data, which allows other ZIP tools to recover the timestamp
914+
more accurately when extracting the file.
915+
905916
.. versionadded:: 3.6
906917

907918
.. versionchanged:: 3.6.2
@@ -910,6 +921,9 @@ file:
910921
.. versionchanged:: 3.8
911922
Added the *strict_timestamps* keyword-only parameter.
912923

924+
.. versionchanged:: next
925+
Added the *with_ext_timestamps* keyword-only parameter.
926+
913927

914928
Instances have the following methods and attributes:
915929

Lib/test/test_zipfile/test_core.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,91 @@ def test_add_file_after_2107(self):
676676
zinfo = zipfp.getinfo(TESTFN)
677677
self.assertEqual(zinfo.date_time, (2107, 12, 31, 23, 59, 59))
678678

679+
def test_add_file_with_ext_timestamp(self):
680+
"""Check that calling ZipFile.write() sets extra data according to
681+
with_ext_timestamps parameter."""
682+
mtime = 946684800.123456
683+
mtime_ns = 946684800_123456789
684+
atime_ns = 946684800_987654321
685+
ctime_ns = 946684800_555555555
686+
687+
with mock.patch('os.stat_result.st_mtime', mtime), \
688+
mock.patch('os.stat_result.st_mtime_ns', mtime_ns), \
689+
mock.patch('os.stat_result.st_atime_ns', atime_ns), \
690+
mock.patch('os.stat_result.st_ctime_ns', ctime_ns):
691+
692+
# with_ext_timestamps=False (default)
693+
with zipfile.ZipFile(TESTFN2, "w") as zipfp:
694+
zipfp.write(TESTFN)
695+
696+
with zipfile.ZipFile(TESTFN2) as zipfp:
697+
zinfo = zipfp.infolist()[0]
698+
699+
self.assertEqual(zinfo.extra, b'')
700+
701+
# with_ext_timestamps=True
702+
with zipfile.ZipFile(TESTFN2, "w", with_ext_timestamps=True) as zipfp:
703+
zipfp.write(TESTFN)
704+
705+
with zipfile.ZipFile(TESTFN2) as zipfp:
706+
zinfo = zipfp.infolist()[0]
707+
708+
self.assertEqual(zinfo.date_time[0], 2000)
709+
710+
# NTFS Extra Field (0x000a)
711+
delta = 116444736000000000
712+
ntfs_field = struct.unpack_from('<HHLHHQQQ', zinfo.extra)
713+
self.assertEqual(ntfs_field, (
714+
0x000a, 32,
715+
0, 0x0001, 24,
716+
mtime_ns // 100 + delta,
717+
atime_ns // 100 + delta,
718+
ctime_ns // 100 + delta,
719+
))
720+
721+
# Extended timestamp (0x5455)
722+
ut_field = struct.unpack_from('<HHBL', zinfo.extra, struct.calcsize('<HHLHHQQQ'))
723+
self.assertEqual(ut_field, (0x5455, 5, 1, int(mtime)))
724+
725+
def test_add_file_with_ext_timestamp_after_2038(self):
726+
"""Extended timestamp field should exist for a timestamp after
727+
2038-01-19T03:14:07Z."""
728+
mtime = 2147483648.123456 # 2038-01-19T03:14:08.123456Z
729+
730+
with mock.patch('os.stat_result.st_mtime', mtime):
731+
with zipfile.ZipFile(TESTFN2, "w", strict_timestamps=False,
732+
with_ext_timestamps=True) as zipfp:
733+
zipfp.write(TESTFN)
734+
735+
with zipfile.ZipFile(TESTFN2) as zipfp:
736+
zinfo = zipfp.infolist()[0]
737+
738+
self.assertEqual(zinfo.date_time[0], 2038)
739+
740+
# Extended timestamp (0x5455)
741+
ntfs_field_len = struct.calcsize('<HHLHHQQQ')
742+
ut_field = struct.unpack_from('<HHBL', zinfo.extra, ntfs_field_len)
743+
self.assertEqual(ut_field, (0x5455, 5, 1, int(mtime)))
744+
745+
def test_add_file_with_ext_timestamp_after_2106(self):
746+
"""Extended timestamp field should not exist for a timestamp after
747+
2106-02-07T06:28:15Z."""
748+
mtime = 4294967296.123456 # 2106-02-07T06:28:16.123456Z
749+
750+
with mock.patch('os.stat_result.st_mtime', mtime):
751+
with zipfile.ZipFile(TESTFN2, "w", strict_timestamps=False,
752+
with_ext_timestamps=True) as zipfp:
753+
zipfp.write(TESTFN)
754+
755+
with zipfile.ZipFile(TESTFN2) as zipfp:
756+
zinfo = zipfp.infolist()[0]
757+
758+
self.assertEqual(zinfo.date_time[0], 2106)
759+
760+
# Only an NTFS Extra Field (0x000a) exists
761+
ntfs_field_len = struct.calcsize('<HHLHHQQQ')
762+
self.assertEqual(len(zinfo.extra), ntfs_field_len)
763+
679764

680765
@requires_zlib()
681766
class DeflateTestsWithSourceFile(AbstractTestsWithSourceFile,

Lib/zipfile/__init__.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,8 @@ def _decodeExtra(self, filename_crc):
629629
extra = extra[ln+4:]
630630

631631
@classmethod
632-
def from_file(cls, filename, arcname=None, *, strict_timestamps=True):
632+
def from_file(cls, filename, arcname=None, *, strict_timestamps=True,
633+
with_ext_timestamps=False):
633634
"""Construct an appropriate ZipInfo for a file on the filesystem.
634635
635636
filename should be the path to a file or directory on the
@@ -665,6 +666,27 @@ def from_file(cls, filename, arcname=None, *, strict_timestamps=True):
665666
else:
666667
zinfo.file_size = st.st_size
667668

669+
if with_ext_timestamps:
670+
extras = {}
671+
672+
# NTFS Extra Field (0x000a)
673+
delta = 116444736000000000
674+
ft_mtime = st.st_mtime_ns // 100 + delta
675+
ft_atime = st.st_atime_ns // 100 + delta
676+
ft_ctime = st.st_ctime_ns // 100 + delta
677+
ntfs_tag = struct.pack('<LHHQQQ', 0, 0x0001, 24, ft_mtime, ft_atime, ft_ctime)
678+
extras[0x000a] = struct.pack('<HH', 0x000a, len(ntfs_tag)) + ntfs_tag
679+
680+
# Extended timestamp (0x5455)
681+
# According to libzip's doc, the timestamps should be 4-byte
682+
# unsigned integers:
683+
# https://libzip.org/specifications/extrafld.txt
684+
mtime = int(st.st_mtime)
685+
if 0 <= mtime <= 0xFFFF_FFFF:
686+
extras[0x5455] = struct.pack('<HHBL', 0x5455, 5, 0x01, mtime)
687+
688+
zinfo.extra = b''.join(extras.values()) + _Extra.strip(zinfo.extra, extras)
689+
668690
return zinfo
669691

670692
def _for_archive(self, archive):
@@ -1896,7 +1918,8 @@ class ZipFile:
18961918
_ignore_invalid_names = False
18971919

18981920
def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True,
1899-
compresslevel=None, *, strict_timestamps=True, metadata_encoding=None):
1921+
compresslevel=None, *, strict_timestamps=True,
1922+
with_ext_timestamps=False, metadata_encoding=None):
19001923
"""Open the ZIP file with mode read 'r', write 'w', exclusive create
19011924
'x', or append 'a'."""
19021925
if mode not in ('r', 'w', 'x', 'a'):
@@ -1915,6 +1938,7 @@ def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True,
19151938
self.pwd = None
19161939
self._comment = b''
19171940
self._strict_timestamps = strict_timestamps
1941+
self._with_ext_timestamps = with_ext_timestamps
19181942
self.metadata_encoding = metadata_encoding
19191943

19201944
# Check that we don't try to write with nonconforming codecs
@@ -2526,7 +2550,8 @@ def write(self, filename, arcname=None,
25262550
)
25272551

25282552
zinfo = ZipInfo.from_file(filename, arcname,
2529-
strict_timestamps=self._strict_timestamps)
2553+
strict_timestamps=self._strict_timestamps,
2554+
with_ext_timestamps=self._with_ext_timestamps)
25302555

25312556
if zinfo.is_dir():
25322557
zinfo.compress_size = 0
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Introduce ``with_ext_timestamps`` parameter to
2+
:class:`~zipfile.ZipFile` and :meth:`~zipfile.ZipInfo.from_file` to
3+
fill extra data with the file's extended timestamps when writing a
4+
file to the archive.

0 commit comments

Comments
 (0)