diff --git a/build.gradle b/build.gradle index 89a615a2c..6d2346dd9 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ dependencies { testImplementation 'org.mockito:mockito-core:4.3.1' testImplementation 'org.assertj:assertj-core:3.22.0' testImplementation 'com.tngtech.archunit:archunit-junit5:0.23.0' + testImplementation 'org.junit-pioneer:junit-pioneer:1.6.1' } group = 'com.github.junrar' diff --git a/src/main/java/com/github/junrar/rarfile/FileHeader.java b/src/main/java/com/github/junrar/rarfile/FileHeader.java index df28ba3f4..ec677be96 100644 --- a/src/main/java/com/github/junrar/rarfile/FileHeader.java +++ b/src/main/java/com/github/junrar/rarfile/FileHeader.java @@ -22,8 +22,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.file.attribute.FileTime; +import java.time.Instant; import java.util.Calendar; import java.util.Date; +import java.util.concurrent.TimeUnit; /** @@ -40,6 +43,8 @@ public class FileHeader extends BlockHeader { private static final byte NEWLHD_SIZE = 32; + private static final long NANOS_PER_UNIT = 100L; // 100ns units + private final long unpSize; private final HostSystem hostOS; @@ -65,13 +70,13 @@ public class FileHeader extends BlockHeader { private final byte[] salt = new byte[SALT_SIZE]; - private Date mTime; + private FileTime mTime; - private Date cTime; + private FileTime cTime; - private Date aTime; + private FileTime aTime; - private Date arcTime; + private FileTime arcTime; private long fullPackSize; @@ -180,9 +185,71 @@ public FileHeader(BlockHeader bh, byte[] fileHeader) { position++; } } - mTime = getDateDos(fileTime); - // TODO rartime -> extended + mTime = FileTime.fromMillis(getDateDos(fileTime)); + + if (hasExtTime()) { + short extTimeFlags = Raw.readShortLittleEndian(fileHeader, position); + position += 2; + + TimePositionTuple mTimeTuple = parseExtTime(12, extTimeFlags, fileHeader, position, mTime); + mTime = mTimeTuple.time; + position = mTimeTuple.position; + + TimePositionTuple cTimeTuple = parseExtTime(8, extTimeFlags, fileHeader, position); + cTime = cTimeTuple.time; + position = cTimeTuple.position; + + TimePositionTuple aTimeTuple = parseExtTime(4, extTimeFlags, fileHeader, position); + aTime = aTimeTuple.time; + position = aTimeTuple.position; + + TimePositionTuple arcTimeTuple = parseExtTime(0, extTimeFlags, fileHeader, position); + arcTime = arcTimeTuple.time; + position = arcTimeTuple.position; + } + } + + private static final class TimePositionTuple { + private final int position; + private final FileTime time; + + private TimePositionTuple(int position, FileTime time) { + this.position = position; + this.time = time; + } + } + + private static TimePositionTuple parseExtTime(int shift, short flags, byte[] fileHeader, int position) { + return parseExtTime(shift, flags, fileHeader, position, null); + } + + private static TimePositionTuple parseExtTime(int shift, short flags, byte[] fileHeader, int position, FileTime baseTime) { + int flag = flags >>> shift; + if ((flag & 0x8) != 0) { + long seconds; + if (baseTime != null) { + seconds = baseTime.to(TimeUnit.SECONDS); + } else { + seconds = TimeUnit.MILLISECONDS.toSeconds(getDateDos(Raw.readIntLittleEndian(fileHeader, position))); + position += 4; + } + int count = flag & 0x3; + long remainder = 0; + for (int i = 0; i < count; i++) { + int b = fileHeader[position] & 0xff; + remainder = (b << 16) | (remainder >>> 8); + position++; + } + long nanos = remainder * NANOS_PER_UNIT; + if ((flag & 0x4) != 0) { + nanos += TimeUnit.SECONDS.toNanos(1); + } + FileTime time = FileTime.from(Instant.ofEpochSecond(seconds, nanos)); + return new TimePositionTuple(position, time); + } else { + return new TimePositionTuple(position, baseTime); + } } @Override @@ -192,7 +259,10 @@ public void print() { StringBuilder str = new StringBuilder(); str.append("unpSize: ").append(getUnpSize()); str.append("\nHostOS: ").append(hostOS.name()); - str.append("\nMDate: ").append(mTime); + str.append("\nMTime: ").append(mTime); + str.append("\nCTime: ").append(cTime); + str.append("\nATime: ").append(aTime); + str.append("\nArcTime: ").append(arcTime); str.append("\nFileName: ").append(fileName); str.append("\nFileNameW: ").append(fileNameW); str.append("\nunpMethod: ").append(Integer.toHexString(getUnpMethod())); @@ -216,7 +286,7 @@ public void print() { } } - private Date getDateDos(int time) { + private static long getDateDos(int time) { Calendar cal = Calendar.getInstance(); cal.set(Calendar.YEAR, (time >>> 25) + 1980); cal.set(Calendar.MONTH, ((time >>> 21) & 0x0f) - 1); @@ -225,33 +295,134 @@ private Date getDateDos(int time) { cal.set(Calendar.MINUTE, (time >>> 5) & 0x3f); cal.set(Calendar.SECOND, (time & 0x1f) * 2); cal.set(Calendar.MILLISECOND, 0); - return cal.getTime(); + return cal.getTimeInMillis(); } - public Date getArcTime() { + private static Date toDate(FileTime time) { + return time != null ? new Date(time.toMillis()) : null; + } + + private static FileTime toFileTime(Date time) { + return time != null ? FileTime.fromMillis(time.getTime()) : null; + } + + /** + * The time in which the file was archived. + * Corresponds to te {@link FileHeader#arcTime} field. + * + * @return the timestamp, or null if absent. + */ + public FileTime getArchivalTime() { return arcTime; } + /** + * Sets the time in which the file was archived. + * Corresponds to te {@link FileHeader#arcTime} field. + * + * @param archivalTime the timestamp, or null to clear it. + */ + public void setArchivalTime(FileTime archivalTime) { + this.arcTime = archivalTime; + } + + /** + * Gets {@link FileHeader#getArchivalTime()} as a {@link Date}. + * The maximum granularity is reduced from microseconds to milliseconds. + * + * @return the date, or null if absent. + */ + public Date getArcTime() { + return toDate(getArchivalTime()); + } + + /** + * Sets {@link FileHeader#setArchivalTime(FileTime)} from a {@link Date}. + * + * @param arcTime the date, or null to clear it. + */ public void setArcTime(Date arcTime) { - this.arcTime = arcTime; + setArchivalTime(toFileTime(arcTime)); } - public Date getATime() { + /** + * The time in which the file was last accessed. + * Corresponds to te {@link FileHeader#aTime} field. + * + * @return the timestamp, or null if absent. + */ + public FileTime getLastAccessTime() { return aTime; } - public void setATime(Date time) { + /** + * Sets the time in which the file was last accessed. + * Corresponds to te {@link FileHeader#aTime} field. + * + * @param time the timestamp, or null to clear it. + */ + public void setLastAccessTime(FileTime time) { aTime = time; } - public Date getCTime() { + /** + * Gets {@link FileHeader#getLastAccessTime()} as a {@link Date}. + * The maximum granularity is reduced from microseconds to milliseconds. + * + * @return the date, or null if absent. + */ + public Date getATime() { + return toDate(getLastAccessTime()); + } + + /** + * Sets {@link FileHeader#setLastAccessTime(FileTime)} from a {@link Date}. + * + * @param time the date, or null to clear it. + */ + public void setATime(Date time) { + setLastAccessTime(toFileTime(time)); + } + + /** + * The time in which the file was created. + * Corresponds to te {@link FileHeader#cTime} field. + * + * @return the timestamp, or null if absent. + */ + public FileTime getCreationTime() { return cTime; } - public void setCTime(Date time) { + /** + * Sets the time in which the file was created. + * Corresponds to te {@link FileHeader#cTime} field. + * + * @param time the timestamp, or null to clear it. + */ + public void setCreationTime(FileTime time) { cTime = time; } + /** + * Gets {@link FileHeader#getCreationTime()} as a {@link Date}. + * The maximum granularity is reduced from microseconds to milliseconds. + * + * @return the date, or null if absent. + */ + public Date getCTime() { + return toDate(getCreationTime()); + } + + /** + * Sets {@link FileHeader#setCreationTime(FileTime)} from a {@link Date}. + * + * @param time the date, or null to clear it. + */ + public void setCTime(Date time) { + setCreationTime(toFileTime(time)); + } + public int getFileAttr() { return fileAttr; } @@ -310,14 +481,45 @@ public HostSystem getHostOS() { return hostOS; } - public Date getMTime() { + /** + * The time in which the file was last modified. + * Corresponds to te {@link FileHeader#mTime} field. + * + * @return the timestamp, or null if absent. + */ + public FileTime getLastModifiedTime() { return mTime; } - public void setMTime(Date time) { + /** + * Sets the time in which the file was last modified. + * Corresponds to te {@link FileHeader#mTime} field. + * + * @param time the timestamp, or null to clear it. + */ + public void setLastModifiedTime(FileTime time) { mTime = time; } + /** + * Gets {@link FileHeader#getLastModifiedTime()} as a {@link Date}. + * The maximum granularity is reduced from microseconds to milliseconds. + * + * @return the date, or null if absent. + */ + public Date getMTime() { + return toDate(getLastModifiedTime()); + } + + /** + * Sets {@link FileHeader#setLastModifiedTime(FileTime)} from a {@link Date}. + * + * @param time the date, or null to clear it. + */ + public void setMTime(Date time) { + setLastModifiedTime(toFileTime(time)); + } + public short getNameSize() { return nameSize; } @@ -416,6 +618,10 @@ public boolean hasSalt() { return (flags & LHD_SALT) != 0; } + public boolean hasExtTime() { + return (flags & LHD_EXTTIME) != 0; + } + public boolean isLargeBlock() { return (flags & LHD_LARGE) != 0; } diff --git a/src/test/java/com/github/junrar/ArchiveTest.java b/src/test/java/com/github/junrar/ArchiveTest.java index 851007e6f..d8efe9aec 100644 --- a/src/test/java/com/github/junrar/ArchiveTest.java +++ b/src/test/java/com/github/junrar/ArchiveTest.java @@ -17,21 +17,32 @@ package com.github.junrar; import com.github.junrar.exception.CrcErrorException; +import com.github.junrar.exception.RarException; import com.github.junrar.exception.UnsupportedRarV5Exception; import com.github.junrar.rarfile.FileHeader; import com.github.junrar.rarfile.HostSystem; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.DefaultTimeZone; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Calendar; +import java.util.Date; import java.util.List; +import java.util.TimeZone; import java.util.stream.Collectors; +import static java.util.Calendar.FEBRUARY; +import static java.util.Calendar.MARCH; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @@ -127,6 +138,84 @@ public void testAudioDecompression(String fileName) throws Exception { } } + /* + The file is shifted by 1-hour because it was created in Europe/Amsterdam. + Times in a RAR file are stored in the MS-DOS style, so the fields are always the same but the resulting timestamp is not. + + Original timestamps: + MTime: 2022-02-23T09:24:19.191543300Z + CTime: 2022-02-23T09:34:59.759754700Z + ATime: 2022-03-02T17:45:18.694091100Z + + Ensure the fields remain constant across timezones. + */ + @Nested + class ExtendedTimeTest { + @Test + @DefaultTimeZone("America/Los_Angeles") + public void testArchiveExtTimes_LosAngeles() throws Exception { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("America/Los_Angeles")); + testArchiveExtTimes(); + } + + @Test + @DefaultTimeZone("America/Sao_Paulo") + public void testArchiveExtTimes_SaoPaulo() throws Exception { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("America/Sao_Paulo")); + testArchiveExtTimes(); + } + + @Test + @DefaultTimeZone("Europe/Amsterdam") + public void testArchiveExtTimes_Amsterdam() throws Exception { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("Europe/Amsterdam")); + testArchiveExtTimes(); + } + + @Test + @DefaultTimeZone("Asia/Kolkata") + public void testArchiveExtTimes_Kolkata() throws Exception { + assertThat(TimeZone.getDefault()).isEqualTo(TimeZone.getTimeZone("Asia/Kolkata")); + testArchiveExtTimes(); + } + + private void testArchiveExtTimes() throws IOException, RarException { + try (InputStream is = getClass().getResourceAsStream("rar4-ext_time.rar")) { + try (Archive archive = new Archive(is)) { + assertThat(archive.getMainHeader().isSolid()).isFalse(); + + FileHeader fileHeader = archive.getFileHeaders().stream() + .filter(FileHeader::isFileHeader) + .findFirst() + .orElse(null); + assertThat(fileHeader).isNotNull(); + assertThat(fileHeader.getFileName()).isEqualTo("files\\test\\short-text.txt"); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + archive.extractFile(fileHeader, baos); + assertThat(baos.toString()).isEqualTo("Short text for example"); + } + assertThat(fileHeader.getMTime()).isEqualTo(toDate(2022, FEBRUARY, 23, 10, 24, 19, 191)); + assertThat(fileHeader.getLastModifiedTime()).isEqualTo(toFileTime(fileHeader.getMTime(), 543300)); + assertThat(fileHeader.getCTime()).isEqualTo(toDate(2022, FEBRUARY, 23, 10, 34, 59, 759)); + assertThat(fileHeader.getCreationTime()).isEqualTo(toFileTime(fileHeader.getCTime(), 754700)); + assertThat(fileHeader.getATime()).isEqualTo(toDate(2022, MARCH, 2, 18, 45, 18, 694)); + assertThat(fileHeader.getLastAccessTime()).isEqualTo(toFileTime(fileHeader.getATime(), 91100)); + } + } + } + + private Date toDate(int year, int month, int day, int hour, int minute, int second, int millis) { + Calendar calendar = Calendar.getInstance(); + calendar.set(year, month, day, hour, minute, second); + calendar.set(Calendar.MILLISECOND, millis); + return calendar.getTime(); + } + + private FileTime toFileTime(Date date, long nanos) { + return FileTime.from(Instant.ofEpochMilli(date.getTime()).plus(nanos, ChronoUnit.NANOS)); + } + } + @Nested class Solid { @Test diff --git a/src/test/resources/com/github/junrar/rar4-ext_time.rar b/src/test/resources/com/github/junrar/rar4-ext_time.rar new file mode 100644 index 000000000..e48e38148 Binary files /dev/null and b/src/test/resources/com/github/junrar/rar4-ext_time.rar differ