From 9720d072b53fd35d30c175749570a6d8cf564394 Mon Sep 17 00:00:00 2001 From: Reed von Redwitz Date: Fri, 17 Jul 2026 21:33:27 +0200 Subject: [PATCH] feat(base-tar): add TarInputStream with GNU/PAX compatibility, plus hardlink and sparse file support --- .../main/java/build/base/tar/TarEntry.java | 3 +- .../main/java/build/base/tar/TarHeader.java | 747 +++++++++++++++++- .../java/build/base/tar/TarInputStream.java | 518 ++++++++++++ .../java/build/base/tar/TarOutputStream.java | 99 ++- .../java/build/base/tar/TarHeaderTests.java | 232 +++++- .../build/base/tar/TarInputStreamTests.java | 474 +++++++++++ .../build/base/tar/TarRoundTripTests.java | 361 +++++++++ 7 files changed, 2405 insertions(+), 29 deletions(-) create mode 100644 base-tar/src/main/java/build/base/tar/TarInputStream.java create mode 100644 base-tar/src/test/java/build/base/tar/TarInputStreamTests.java create mode 100644 base-tar/src/test/java/build/base/tar/TarRoundTripTests.java diff --git a/base-tar/src/main/java/build/base/tar/TarEntry.java b/base-tar/src/main/java/build/base/tar/TarEntry.java index 4359ea5..3c60c62 100644 --- a/base-tar/src/main/java/build/base/tar/TarEntry.java +++ b/base-tar/src/main/java/build/base/tar/TarEntry.java @@ -26,4 +26,5 @@ * @author reed.vonredwitz * @since Apr-2026 */ -public record TarEntry(TarHeader header) {} +public record TarEntry(TarHeader header) { +} diff --git a/base-tar/src/main/java/build/base/tar/TarHeader.java b/base-tar/src/main/java/build/base/tar/TarHeader.java index 732a140..d6fd70e 100644 --- a/base-tar/src/main/java/build/base/tar/TarHeader.java +++ b/base-tar/src/main/java/build/base/tar/TarHeader.java @@ -21,15 +21,24 @@ */ import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; /** * Represents a POSIX ustar tar entry header (512 bytes). + *

+ * {@link #sparseChunks()} is non-empty only for entries read from, or intended to be written + * as, a GNU old-style sparse ({@code 'S'} typeflag) entry: {@link #size()} is then the entry's + * full logical (expanded) size, and the chunks describe which byte ranges of that logical size + * are actually stored in the archive, with the remaining ranges implicitly zero-filled holes. * * @author reed.vonredwitz * @since Apr-2026 */ -public record TarHeader(String name, long size, long modTime, boolean directory, int permissions) { +public record TarHeader(String name, long size, long modTime, boolean directory, int permissions, String linkName, + int uid, int gid, String userName, String groupName, boolean hardlink, + List sparseChunks) { /** * The size of a tar header/block in bytes. @@ -46,6 +55,44 @@ public record TarHeader(String name, long size, long modTime, boolean directory, */ private static final String USTAR_VERSION = "00"; + /** + * The number of sparse (offset, length) chunk entries that fit directly in a GNU + * old-style sparse main header block. + */ + private static final int SPARSE_CHUNKS_IN_MAIN_BLOCK = 4; + + /** + * The number of sparse (offset, length) chunk entries that fit in each GNU old-style + * sparse extension block. + */ + private static final int SPARSE_CHUNKS_PER_EXTENSION_BLOCK = 21; + + /** + * Canonical constructor; defensively copies {@link #sparseChunks()}. + */ + public TarHeader { + sparseChunks = List.copyOf(sparseChunks); + } + + /** + * A contiguous range of a GNU old-style sparse entry's logical (expanded) content that is + * actually stored in the archive. + *

+ * Nothing in the format requires {@link #offset()}/{@link #length()} to be aligned to any + * boundary, but GNU tar's own extractor has been observed to silently drop chunk data at + * arbitrary byte offsets it did not write itself, while chunks aligned to a filesystem block + * size (e.g. 4096) round-trip correctly. Since any real sparse-file detector (POSIX + * {@code SEEK_HOLE}/{@code SEEK_DATA}, {@code FIEMAP}, etc.) already reports block- or + * extent-aligned ranges, this is not a practical limitation, but hand-built chunks for tests + * or synthetic archives should stick to block-aligned offsets and lengths to extract cleanly + * with real tar. + * + * @param offset the byte offset of this chunk within the entry's logical content + * @param length the number of stored bytes in this chunk + */ + public record SparseChunk(long offset, long length) { + } + /** * Creates a {@link TarHeader} for the specified entry. * @@ -61,15 +108,222 @@ public static TarHeader createHeader(final String name, final long modTime, final boolean directory, final int permissions) { - return new TarHeader(name, size, modTime, directory, permissions); + return new TarHeader(name, size, modTime, directory, permissions, "", 0, 0, "", "", false, List.of()); + } + + /** + * Creates a {@link TarHeader} for the specified entry, with explicit ownership. + * + * @param name the entry name + * @param size the entry size in bytes (0 for directories) + * @param modTime the modification time in seconds since epoch + * @param directory whether the entry is a directory + * @param permissions the POSIX permissions (e.g. 0755) + * @param uid the numeric owner id + * @param gid the numeric group id + * @param userName the owner name + * @param groupName the group name + * @return a new {@link TarHeader} + */ + public static TarHeader createHeader(final String name, + final long size, + final long modTime, + final boolean directory, + final int permissions, + final int uid, + final int gid, + final String userName, + final String groupName) { + return new TarHeader(name, size, modTime, directory, permissions, "", uid, gid, userName, groupName, false, List.of()); + } + + /** + * Creates a {@link TarHeader} for a symbolic link entry. + * + * @param name the entry name + * @param linkTarget the path the symlink points to + * @param modTime the modification time in seconds since epoch + * @param permissions the POSIX permissions (e.g. 0777) + * @return a new {@link TarHeader} + */ + public static TarHeader createSymlinkHeader(final String name, + final String linkTarget, + final long modTime, + final int permissions) { + return new TarHeader(name, 0, modTime, false, permissions, linkTarget, 0, 0, "", "", false, List.of()); + } + + /** + * Creates a {@link TarHeader} for a hard link entry. + * + * @param name the entry name + * @param linkTarget the archive-relative path of the file this entry is linked to + * @param modTime the modification time in seconds since epoch + * @param permissions the POSIX permissions (e.g. 0644) + * @return a new {@link TarHeader} + */ + public static TarHeader createHardlinkHeader(final String name, + final String linkTarget, + final long modTime, + final int permissions) { + return new TarHeader(name, 0, modTime, false, permissions, linkTarget, 0, 0, "", "", true, List.of()); + } + + /** + * Creates a {@link TarHeader} for a GNU old-style sparse file entry. + * + * @param name the entry name + * @param realSize the full logical (expanded) size of the file + * @param sparseChunks the stored byte ranges within the logical content; any range not + * covered is an implicit zero-filled hole + * @param modTime the modification time in seconds since epoch + * @param permissions the POSIX permissions (e.g. 0644) + * @return a new {@link TarHeader} + */ + public static TarHeader createSparseHeader(final String name, + final long realSize, + final List sparseChunks, + final long modTime, + final int permissions) { + return new TarHeader(name, realSize, modTime, false, permissions, "", 0, 0, "", "", false, sparseChunks); + } + + /** + * Whether this entry is a GNU old-style sparse file, i.e. has a non-empty + * {@link #sparseChunks()}. + * + * @return {@code true} if this entry is sparse + */ + boolean isSparse() { + return !sparseChunks.isEmpty(); } /** - * Serializes this header into a 512-byte ustar header block. + * Serializes this header into a 512-byte ustar (or, for sparse entries, GNU old-style + * sparse) header block. * * @return the 512-byte header block + * @throws IllegalArgumentException if {@link #name()} exceeds 100 bytes with no valid + * prefix/name split point (see {@link #nameNeedsPaxExtension()}) */ byte[] toBytes() { + if (isSparse()) { + final var storedSize = sparseChunks.stream().mapToLong(SparseChunk::length).sum(); + return buildSparseBlock(name, permissions, uid, gid, storedSize, modTime, userName, groupName, + chunksWithTerminator(), size); + } + final var typeFlag = directory ? '5' : hardlink ? '1' : linkName.isEmpty() ? '0' : '2'; + return buildBlock(name, permissions, uid, gid, directory ? 0 : size, modTime, typeFlag, linkName, + userName, groupName); + } + + /** + * Returns {@link #sparseChunks()} with a trailing zero-length terminator entry appended at + * {@link #size()} (the logical end of the entry). GNU tar writes this terminator itself and + * some extractors (including GNU tar) rely on it, rather than the {@code realsize} field + * alone, to know to truncate the extracted file out to its full logical size when the last + * real chunk does not already reach the end. + * + * @return the sparse chunks to serialize, including the terminator + */ + private List chunksWithTerminator() { + final var augmented = new ArrayList<>(sparseChunks); + augmented.add(new SparseChunk(size, 0)); + return augmented; + } + + /** + * Returns the GNU old-style sparse extension blocks needed to convey this entry's sparse + * map (including its terminator entry, see {@link #chunksWithTerminator()}) beyond the + * first {@value #SPARSE_CHUNKS_IN_MAIN_BLOCK} entries embedded in the main header block. + * + * @return the extension blocks, in the order they must be written after the main header block + */ + List sparseExtensionBlocks() { + final var augmented = chunksWithTerminator(); + if (augmented.size() <= SPARSE_CHUNKS_IN_MAIN_BLOCK) { + return List.of(); + } + final var remaining = augmented.subList(SPARSE_CHUNKS_IN_MAIN_BLOCK, augmented.size()); + final var blocks = new ArrayList(); + var offset = 0; + while (offset < remaining.size()) { + final var block = new byte[BLOCK_SIZE]; + Arrays.fill(block, (byte) 0); + final var count = Math.min(SPARSE_CHUNKS_PER_EXTENSION_BLOCK, remaining.size() - offset); + for (int i = 0; i < count; i++) { + final var chunk = remaining.get(offset + i); + final var base = i * 24; + writeOctal(block, base, 12, chunk.offset()); + writeOctal(block, base + 12, 12, chunk.length()); + } + offset += count; + final var hasMore = offset < remaining.size(); + block[SPARSE_CHUNKS_PER_EXTENSION_BLOCK * 24] = (byte) (hasMore ? 1 : 0); + blocks.add(block); + } + return blocks; + } + + /** + * Whether {@link #name()} is too long to fit in the ustar name/prefix fields, requiring + * a PAX {@code path} extended header record (or GNU {@code L} long-name record) to convey + * the full name. + * + * @return {@code true} if no ustar-compatible prefix/name split exists + */ + boolean nameNeedsPaxExtension() { + return utf8Length(name) > 100 && findPrefixSplitIndex(name) < 0; + } + + /** + * Whether {@link #linkName()} is too long to fit in the ustar linkname field, requiring + * a PAX {@code linkpath} extended header record (or GNU {@code K} long-link record) to + * convey the full target. + * + * @return {@code true} if the link name exceeds 100 bytes + */ + boolean linkNameNeedsPaxExtension() { + return utf8Length(linkName) > 100; + } + + /** + * Returns a copy of this header with {@link #name()} and/or {@link #linkName()} truncated + * to fit the ustar fixed-width fields, for use as the placeholder ustar header that + * accompanies a PAX extended header carrying the real values. + * + * @return the truncated header + */ + TarHeader toUstarSafeHeader() { + final var safeName = nameNeedsPaxExtension() ? truncateToUtf8Bytes(name, 100) : name; + final var safeLinkName = linkNameNeedsPaxExtension() ? truncateToUtf8Bytes(linkName, 100) : linkName; + return new TarHeader(safeName, size, modTime, directory, permissions, safeLinkName, uid, gid, userName, groupName, hardlink, sparseChunks); + } + + /** + * Builds a 512-byte ustar header block for a PAX ({@code x}/{@code g}) or GNU + * long-name/long-link ({@code L}/{@code K}) extension record, whose data is the + * following {@code dataSize} bytes in the stream. + * + * @param name the extension entry's own name (conventionally ignored by readers) + * @param dataSize the size of the extension data that follows this block + * @param typeFlag the extension typeflag ({@code 'x'}, {@code 'g'}, {@code 'L'}, or {@code 'K'}) + * @return the 512-byte header block + */ + static byte[] createExtensionHeaderBlock(final String name, final long dataSize, final char typeFlag) { + return buildBlock(name, 0, 0, 0, dataSize, 0, typeFlag, "", "", ""); + } + + private static byte[] buildBlock(final String name, + final int permissions, + final int uid, + final int gid, + final long size, + final long modTime, + final char typeFlag, + final String linkName, + final String userName, + final String groupName) { final var header = new byte[BLOCK_SIZE]; Arrays.fill(header, (byte) 0); @@ -77,14 +331,12 @@ byte[] toBytes() { final String entryName; final String prefix; - if (name.length() > 100) { - // split into prefix (up to 155) and name (up to 100) - final int splitIndex = name.length() - 100; - if (splitIndex > 155) { - throw new IllegalArgumentException("Entry name too long: " + name); - } + if (utf8Length(name) > 100) { + // POSIX ustar: prefix and name are joined by an implicit '/' that is not + // itself stored, so the split must land exactly on a '/' in the original path. + final int splitIndex = prefixSplitIndex(name); prefix = name.substring(0, splitIndex); - entryName = name.substring(splitIndex); + entryName = name.substring(splitIndex + 1); } else { entryName = name; prefix = ""; @@ -97,13 +349,13 @@ byte[] toBytes() { writeOctal(header, 100, 8, permissions); // uid (offset 108, 8 bytes) - writeOctal(header, 108, 8, 0); + writeOctal(header, 108, 8, uid); // gid (offset 116, 8 bytes) - writeOctal(header, 116, 8, 0); + writeOctal(header, 116, 8, gid); // size (offset 124, 12 bytes) - writeOctal(header, 124, 12, directory ? 0 : size); + writeOctal(header, 124, 12, size); // mtime (offset 136, 12 bytes) writeOctal(header, 136, 12, modTime); @@ -112,22 +364,22 @@ byte[] toBytes() { Arrays.fill(header, 148, 156, (byte) ' '); // typeflag (offset 156, 1 byte) - header[156] = directory ? (byte) '5' : (byte) '0'; + header[156] = (byte) typeFlag; - // linkname (offset 157, 100 bytes) - empty + // linkname (offset 157, 100 bytes) + writeString(header, 157, 100, linkName); // magic (offset 257, 6 bytes) writeString(header, 257, 6, USTAR_MAGIC); // version (offset 263, 2 bytes) - header[263] = '0'; - header[264] = '0'; + writeString(header, 263, 2, USTAR_VERSION); // uname (offset 265, 32 bytes) - writeString(header, 265, 32, ""); + writeString(header, 265, 32, userName); // gname (offset 297, 32 bytes) - writeString(header, 297, 32, ""); + writeString(header, 297, 32, groupName); // devmajor (offset 329, 8 bytes) writeOctal(header, 329, 8, 0); @@ -151,6 +403,423 @@ byte[] toBytes() { return header; } + /** + * Builds a 512-byte GNU old-style sparse ({@code 'S'}) main header block, embedding up to + * {@value #SPARSE_CHUNKS_IN_MAIN_BLOCK} sparse chunks directly and setting the extended + * flag if more follow in {@link #sparseExtensionBlocks()}. + * + * @param name the entry name (already known to fit the 100-byte ustar name field) + * @param permissions the POSIX permissions + * @param uid the numeric owner id + * @param gid the numeric group id + * @param storedSize the sum of all chunk lengths, i.e. the number of data bytes actually + * written to the archive for this entry + * @param modTime the modification time in seconds since epoch + * @param userName the owner name + * @param groupName the group name + * @param sparseChunks the full sparse map + * @param realSize the entry's full logical (expanded) size + * @return the 512-byte header block + */ + private static byte[] buildSparseBlock(final String name, + final int permissions, + final int uid, + final int gid, + final long storedSize, + final long modTime, + final String userName, + final String groupName, + final List sparseChunks, + final long realSize) { + final var header = new byte[BLOCK_SIZE]; + Arrays.fill(header, (byte) 0); + + // name (offset 0, 100 bytes) + writeString(header, 0, 100, name); + + // mode (offset 100, 8 bytes) + writeOctal(header, 100, 8, permissions); + + // uid (offset 108, 8 bytes) + writeOctal(header, 108, 8, uid); + + // gid (offset 116, 8 bytes) + writeOctal(header, 116, 8, gid); + + // size (offset 124, 12 bytes) - bytes actually stored in the archive, not the real size + writeOctal(header, 124, 12, storedSize); + + // mtime (offset 136, 12 bytes) + writeOctal(header, 136, 12, modTime); + + // checksum placeholder (offset 148, 8 bytes) - fill with spaces for calculation + Arrays.fill(header, 148, 156, (byte) ' '); + + // typeflag (offset 156, 1 byte) + header[156] = (byte) 'S'; + + // linkname (offset 157, 100 bytes) - unused for sparse entries, left zero + + // GNU magic (offset 257, 6 bytes: "ustar ") and version (offset 263, 2 bytes: " \0"), + // distinct from the POSIX ustar magic/version, as required for GNU sparse extensions + writeString(header, 257, 6, "ustar "); + header[263] = ' '; + + // uname (offset 265, 32 bytes) + writeString(header, 265, 32, userName); + + // gname (offset 297, 32 bytes) + writeString(header, 297, 32, groupName); + + // devmajor (offset 329, 8 bytes) + writeOctal(header, 329, 8, 0); + + // devminor (offset 337, 8 bytes) + writeOctal(header, 337, 8, 0); + + // atime (345, 12), ctime (357, 12), multivolume offset (369, 12), longnames (381, 4), + // and one unused byte (385) are left zero + + // sparse chunk entries (offset 386, 24 bytes each: 12-byte offset + 12-byte numbytes) + for (int i = 0; i < SPARSE_CHUNKS_IN_MAIN_BLOCK; i++) { + if (i < sparseChunks.size()) { + final var chunk = sparseChunks.get(i); + final var base = 386 + i * 24; + writeOctal(header, base, 12, chunk.offset()); + writeOctal(header, base + 12, 12, chunk.length()); + } + } + + // isextended (offset 482, 1 byte) + header[482] = (byte) (sparseChunks.size() > SPARSE_CHUNKS_IN_MAIN_BLOCK ? 1 : 0); + + // realsize (offset 483, 12 bytes) - the full logical (expanded) size + writeOctal(header, 483, 12, realSize); + + // compute checksum + long checksum = 0; + for (final byte b : header) { + checksum += (b & 0xFF); + } + final var checksumStr = String.format("%06o\0 ", checksum); + final var checksumBytes = checksumStr.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(checksumBytes, 0, header, 148, Math.min(checksumBytes.length, 8)); + + return header; + } + + /** + * Reads the typeflag byte from a raw 512-byte header block, without validating + * its checksum. Used to detect GNU long-name/long-link and PAX extended header + * blocks before they are fully parsed. + * + * @param block the 512-byte header block + * @return the typeflag character + */ + static char typeFlag(final byte[] block) { + return (char) block[156]; + } + + /** + * Parses a 512-byte ustar header block into a {@link TarHeader}. + * + * @param block the 512-byte header block + * @return the parsed {@link TarHeader} + * @throws IllegalArgumentException if the header checksum does not match its contents + */ + static TarHeader parseHeader(final byte[] block) { + verifyChecksum(block); + + final var name = readString(block, 0, 100); + final var permissions = (int) parseOctal(block, 100, 8); + final var uid = (int) parseOctal(block, 108, 8); + final var gid = (int) parseOctal(block, 116, 8); + final var size = parseOctal(block, 124, 12); + final var modTime = parseOctal(block, 136, 12); + final var typeFlag = (char) block[156]; + final var linkName = readString(block, 157, 100); + final var userName = readString(block, 265, 32); + final var groupName = readString(block, 297, 32); + final var prefix = readString(block, 345, 155); + + final var fullName = prefix.isEmpty() ? name : prefix + "/" + name; + final var directory = typeFlag == '5' || fullName.endsWith("/"); + final var hardlink = typeFlag == '1'; + + return new TarHeader(fullName, directory ? 0 : size, modTime, directory, permissions, linkName, + uid, gid, userName, groupName, hardlink, List.of()); + } + + /** + * Validates a raw 512-byte header block's checksum field against its actual contents. + * + * @param block the 512-byte header block + * @throws IllegalArgumentException if the header checksum does not match its contents + */ + private static void verifyChecksum(final byte[] block) { + final var storedChecksum = parseOctal(block, 148, 8); + + long computedChecksum = 0; + for (int i = 0; i < BLOCK_SIZE; i++) { + computedChecksum += (i >= 148 && i < 156) ? ' ' : (block[i] & 0xFF); + } + if (storedChecksum != computedChecksum) { + throw new IllegalArgumentException( + "Invalid tar header checksum: expected " + storedChecksum + " but computed " + computedChecksum); + } + } + + /** + * Parses the fields of a GNU old-style sparse ({@code 'S'}) main header block (and, if its + * {@code isextended} flag is set, any following extension blocks are the caller's + * responsibility to read and pass via {@code additionalSparseBlocks}). + * + * @param block the 512-byte sparse main header block + * @return the parsed {@link TarHeader}, with {@link #size()} set to the entry's logical + * (expanded) size and {@link #sparseChunks()} containing only the chunks embedded + * directly in this block; the caller must append any chunks from extension blocks + * @throws IllegalArgumentException if the header checksum does not match its contents + */ + static TarHeader parseSparseMainBlock(final byte[] block) { + verifyChecksum(block); + + final var name = readString(block, 0, 100); + final var permissions = (int) parseOctal(block, 100, 8); + final var uid = (int) parseOctal(block, 108, 8); + final var gid = (int) parseOctal(block, 116, 8); + final var modTime = parseOctal(block, 136, 12); + final var userName = readString(block, 265, 32); + final var groupName = readString(block, 297, 32); + final var realSize = parseOctal(block, 483, 12); + + final var chunks = new ArrayList(); + for (int i = 0; i < SPARSE_CHUNKS_IN_MAIN_BLOCK; i++) { + final var base = 386 + i * 24; + final var offset = parseOctal(block, base, 12); + final var length = parseOctal(block, base + 12, 12); + if (length > 0) { + chunks.add(new SparseChunk(offset, length)); + } + } + + return new TarHeader(name, realSize, modTime, false, permissions, "", uid, gid, userName, groupName, false, chunks); + } + + /** + * Whether a GNU old-style sparse main header block's {@code isextended} flag is set, + * indicating that a sparse extension block immediately follows. + * + * @param block the 512-byte sparse main header block just read + * @return {@code true} if a sparse extension block follows + */ + static boolean sparseMainBlockIsExtended(final byte[] block) { + return block[482] != 0; + } + + /** + * Whether a GNU old-style sparse extension block's {@code isextended} flag is set, + * indicating that another sparse extension block immediately follows. + * + * @param block the 512-byte sparse extension block just read + * @return {@code true} if another sparse extension block follows + */ + static boolean sparseExtensionBlockIsExtended(final byte[] block) { + return block[SPARSE_CHUNKS_PER_EXTENSION_BLOCK * 24] != 0; + } + + /** + * Parses the sparse chunk entries from a GNU old-style sparse extension block. + * + * @param block the 512-byte sparse extension block + * @return the chunks found in this block + */ + static List parseSparseExtensionBlock(final byte[] block) { + final var chunks = new ArrayList(); + for (int i = 0; i < SPARSE_CHUNKS_PER_EXTENSION_BLOCK; i++) { + final var base = i * 24; + final var offset = parseOctal(block, base, 12); + final var length = parseOctal(block, base + 12, 12); + if (length > 0) { + chunks.add(new SparseChunk(offset, length)); + } + } + return chunks; + } + + /** + * Returns a copy of this header with additional {@link #sparseChunks()} appended, and + * {@link #size()} unchanged (it already holds the logical/expanded size). + * + * @param additionalChunks the chunks to append, from sparse extension blocks + * @return the derived {@link TarHeader} + */ + TarHeader withAdditionalSparseChunks(final List additionalChunks) { + final var merged = new ArrayList<>(sparseChunks); + merged.addAll(additionalChunks); + return new TarHeader(name, size, modTime, directory, permissions, linkName, uid, gid, userName, groupName, hardlink, merged); + } + + /** + * Returns a copy of this header with a different {@link #name()}, recomputing + * {@link #directory()} in case the new name ends with {@code '/'}. + * + * @param newName the replacement name + * @return the derived {@link TarHeader} + */ + TarHeader withName(final String newName) { + return new TarHeader(newName, size, modTime, directory || newName.endsWith("/"), permissions, linkName, + uid, gid, userName, groupName, hardlink, sparseChunks); + } + + /** + * Returns a copy of this header with a different {@link #linkName()}. + * + * @param newLinkName the replacement link target + * @return the derived {@link TarHeader} + */ + TarHeader withLinkName(final String newLinkName) { + return new TarHeader(name, size, modTime, directory, permissions, newLinkName, uid, gid, userName, groupName, hardlink, sparseChunks); + } + + /** + * Returns a copy of this header with a different {@link #size()}. + * + * @param newSize the replacement size + * @return the derived {@link TarHeader} + */ + TarHeader withSize(final long newSize) { + return new TarHeader(name, newSize, modTime, directory, permissions, linkName, uid, gid, userName, groupName, hardlink, sparseChunks); + } + + /** + * Returns a copy of this header with a different {@link #modTime()}. + * + * @param newModTime the replacement modification time + * @return the derived {@link TarHeader} + */ + TarHeader withModTime(final long newModTime) { + return new TarHeader(name, size, newModTime, directory, permissions, linkName, uid, gid, userName, groupName, hardlink, sparseChunks); + } + + /** + * Returns a copy of this header with different ownership. + * + * @param newUid the replacement owner id + * @param newGid the replacement group id + * @param newUserName the replacement owner name + * @param newGroupName the replacement group name + * @return the derived {@link TarHeader} + */ + TarHeader withOwner(final int newUid, final int newGid, final String newUserName, final String newGroupName) { + return new TarHeader(name, size, modTime, directory, permissions, linkName, newUid, newGid, newUserName, newGroupName, hardlink, sparseChunks); + } + + /** + * Finds the rightmost {@code '/'} in {@code name} that splits it into a prefix + * (at most 155 bytes) and a name (at most 100 bytes), as required by the POSIX + * ustar prefix field. The slash itself is not included in either half. + * + * @param name the entry name, already known to exceed 100 UTF-8 bytes + * @return the index of the separating {@code '/'} + * @throws IllegalArgumentException if no {@code '/'} yields a valid split + */ + private static int prefixSplitIndex(final String name) { + final var splitIndex = findPrefixSplitIndex(name); + if (splitIndex < 0) { + throw new IllegalArgumentException("Entry name too long: " + name); + } + return splitIndex; + } + + /** + * Finds the rightmost {@code '/'} in {@code name} that splits it into a prefix + * (at most 155 bytes) and a name (at most 100 bytes), as required by the POSIX + * ustar prefix field. + * + * @param name the entry name + * @return the index of the separating {@code '/'}, or {@code -1} if no valid split exists + */ + private static int findPrefixSplitIndex(final String name) { + int splitIndex = -1; + for (int i = 0; i < name.length(); i++) { + if (name.charAt(i) == '/') { + final var prefixLength = utf8Length(name.substring(0, i)); + final var suffixLength = utf8Length(name.substring(i + 1)); + if (prefixLength <= 155 && suffixLength > 0 && suffixLength <= 100) { + splitIndex = i; + } + } + } + return splitIndex; + } + + /** + * Truncates {@code value} to at most {@code maxBytes} bytes when encoded as UTF-8, + * trimming whole characters so the result never splits a multi-byte sequence. + * + * @param value the string to truncate + * @param maxBytes the maximum encoded length in bytes + * @return the truncated string + */ + private static String truncateToUtf8Bytes(final String value, final int maxBytes) { + var candidate = value; + while (utf8Length(candidate) > maxBytes) { + candidate = candidate.substring(0, candidate.length() - 1); + } + return candidate; + } + + /** + * Returns the length of {@code value} in bytes when encoded as UTF-8, which is the + * unit the fixed-width ustar header fields are measured in. + * + * @param value the string to measure + * @return the UTF-8 encoded length in bytes + */ + private static int utf8Length(final String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } + + /** + * Reads a null-terminated string from the header at the specified offset. + * + * @param header the header buffer + * @param offset the offset + * @param length the field length + * @return the string value, with trailing NUL bytes stripped + */ + private static String readString(final byte[] header, final int offset, final int length) { + int end = offset; + while (end < offset + length && header[end] != 0) { + end++; + } + return new String(header, offset, end - offset, StandardCharsets.UTF_8); + } + + /** + * Parses an octal field from the header at the specified offset. + * + * @param header the header buffer + * @param offset the offset + * @param length the field length + * @return the parsed numeric value, or 0 if the field is blank + */ + private static long parseOctal(final byte[] header, final int offset, final int length) { + // GNU base-256 extension: a set high bit on the first byte means the remaining bits + // of that field form a big-endian binary number instead of an octal ASCII string. + // This is required for values too large for the field's fixed-width octal encoding + // (e.g. file sizes at or above 8GB, or a size field, since 8^11-1 is ~8GB). + if ((header[offset] & 0x80) != 0) { + long value = header[offset] & 0x7F; + for (int i = offset + 1; i < offset + length; i++) { + value = (value << 8) | (header[i] & 0xFFL); + } + return value; + } + final var text = new String(header, offset, length, StandardCharsets.US_ASCII).trim(); + return text.isEmpty() ? 0L : Long.parseLong(text, 8); + } + /** * Writes a string into the header at the specified offset. * @@ -163,11 +832,24 @@ private static void writeString(final byte[] header, final int offset, final int length, final String value) { - final var bytes = value.getBytes(StandardCharsets.US_ASCII); + final var bytes = value.getBytes(StandardCharsets.UTF_8); final var copyLen = Math.min(bytes.length, length); System.arraycopy(bytes, 0, header, offset, copyLen); } + /** + * Whether {@code value} is too large to fit an octal ustar field of {@code fieldLength} + * bytes and would therefore fall back to the GNU base-256 extension if written there. + * + * @param value the numeric value + * @param fieldLength the field length in bytes, e.g. 12 for the size field or 8 for uid/gid + * @return {@code true} if the field would need base-256 encoding + */ + static boolean exceedsOctalField(final long value, final int fieldLength) { + final var maxOctalValue = 1L << (3 * (fieldLength - 1)); + return value < 0 || value >= maxOctalValue; + } + /** * Writes an octal value into the header at the specified offset. * @@ -180,6 +862,12 @@ private static void writeOctal(final byte[] header, final int offset, final int length, final long value) { + final var maxOctalValue = 1L << (3 * (length - 1)); + if (value < 0 || value >= maxOctalValue) { + // too large for the fixed-width octal field: fall back to the GNU base-256 extension + writeBase256(header, offset, length, value); + return; + } // format as octal string, null-terminated, right-aligned with leading zeros final var octal = String.format("%0" + (length - 1) + "o", value); final var bytes = octal.getBytes(StandardCharsets.US_ASCII); @@ -187,4 +875,23 @@ private static void writeOctal(final byte[] header, System.arraycopy(bytes, 0, header, offset, copyLen); // null terminator is already there from Arrays.fill } + + /** + * Writes a numeric value using the GNU base-256 extension: a big-endian binary number + * filling the field, with the high bit of the first byte set to mark it as binary + * rather than octal ASCII text. + * + * @param header the header buffer + * @param offset the offset + * @param length the field length + * @param value the numeric value + */ + private static void writeBase256(final byte[] header, final int offset, final int length, final long value) { + var remaining = value; + for (int i = offset + length - 1; i > offset; i--) { + header[i] = (byte) remaining; + remaining >>>= 8; + } + header[offset] |= (byte) 0x80; + } } diff --git a/base-tar/src/main/java/build/base/tar/TarInputStream.java b/base-tar/src/main/java/build/base/tar/TarInputStream.java new file mode 100644 index 0000000..70cbedc --- /dev/null +++ b/base-tar/src/main/java/build/base/tar/TarInputStream.java @@ -0,0 +1,518 @@ +package build.base.tar; + +/*- + * #%L + * base.build Tar + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * An {@link InputStream} that reads entries from a POSIX ustar tar archive, including + * the common extensions needed to read archives produced by other tools: the GNU + * long-name/long-link extension ({@code L}/{@code K} typeflags) and POSIX PAX extended + * headers ({@code x}/{@code g} typeflags). + *

+ * Usage: create a {@link TarInputStream} wrapping an existing {@link InputStream}, + * then repeatedly call {@link #getNextEntry()} followed by {@link #read(byte[], int, int)} + * to read the entry's data, until {@link #getNextEntry()} returns {@code null}. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +public final class TarInputStream extends InputStream { + + /** + * The block size for tar archives. + */ + private static final int BLOCK_SIZE = TarHeader.BLOCK_SIZE; + + /** + * The underlying input stream. + */ + private final InputStream in; + + /** + * PAX extended header records set by a {@code g} (global) header block, which apply + * to every entry until cancelled by a later {@code g} record with an empty value. + */ + private final Map globalPaxHeaders = new LinkedHashMap<>(); + + /** + * The number of unread data bytes remaining in the current entry. + */ + private long bytesRemaining; + + /** + * The number of unread padding bytes remaining after the current entry's data. + */ + private long paddingRemaining; + + /** + * The sparse chunk map of the current entry, or empty if it is not a GNU old-style sparse + * entry. When non-empty, {@link #read(byte[], int, int)} reconstructs the entry's logical + * (expanded) content by interleaving stored bytes from {@link #in} with zero-filled holes, + * rather than reading {@link #bytesRemaining} bytes directly. + */ + private List sparseChunks = List.of(); + + /** + * The index into {@link #sparseChunks} of the chunk at or after {@link #sparseLogicalPos}. + */ + private int sparseChunkIndex; + + /** + * The current read position within the current sparse entry's logical (expanded) content. + */ + private long sparseLogicalPos; + + /** + * The number of unread logical (expanded) bytes remaining in the current sparse entry. + */ + private long sparseLogicalRemaining; + + /** + * Constructs a {@link TarInputStream} wrapping the specified {@link InputStream}. + * + * @param in the underlying {@link InputStream} + */ + public TarInputStream(final InputStream in) { + this.in = in; + } + + /** + * Advances to the next entry in the archive, skipping any unread data and padding + * remaining from the current entry. + * + * @return the next {@link TarEntry}, or {@code null} if the end of the archive has been reached + * @throws IOException if an I/O error occurs + */ + public TarEntry getNextEntry() throws IOException { + skipFully(bytesRemaining + paddingRemaining); + bytesRemaining = 0; + paddingRemaining = 0; + sparseChunks = List.of(); + sparseChunkIndex = 0; + sparseLogicalPos = 0; + sparseLogicalRemaining = 0; + + String longName = null; + String longLinkName = null; + Map localPaxHeaders = null; + while (true) { + final var headerBytes = readFully(BLOCK_SIZE); + if (headerBytes == null) { + return null; + } + if (isZeroBlock(headerBytes)) { + return atEndOfArchive() ? null : failEndOfArchive(); + } + + final var typeFlag = TarHeader.typeFlag(headerBytes); + if (typeFlag == 'L') { + // GNU long-name extension: the following data block holds the real entry name + longName = readLongString(TarHeader.parseHeader(headerBytes).size()); + continue; + } + if (typeFlag == 'K') { + // GNU long-link extension: the following data block holds the real link target + longLinkName = readLongString(TarHeader.parseHeader(headerBytes).size()); + continue; + } + if (typeFlag == 'x') { + // PAX extended header: applies only to the entry immediately following + localPaxHeaders = readPaxRecords(TarHeader.parseHeader(headerBytes).size()); + continue; + } + if (typeFlag == 'g') { + // PAX global extended header: applies to every subsequent entry until cancelled + applyPaxRecords(globalPaxHeaders, readPaxRecords(TarHeader.parseHeader(headerBytes).size())); + continue; + } + if (typeFlag == 'S') { + return readSparseEntry(headerBytes, longName, longLinkName, localPaxHeaders); + } + + var header = TarHeader.parseHeader(headerBytes); + if (longName != null) { + header = header.withName(longName); + } + if (longLinkName != null) { + header = header.withLinkName(longLinkName); + } + header = applyPaxOverrides(header, localPaxHeaders); + + bytesRemaining = header.directory() ? 0 : header.size(); + paddingRemaining = computePadding(bytesRemaining); + + return new TarEntry(header); + } + } + + /** + * Finishes parsing a GNU old-style sparse ({@code 'S'}) entry: reads any sparse extension + * blocks the main block's {@code isextended} flag indicates follow, then sets up + * {@link #read(byte[], int, int)} to reconstruct the entry's logical (expanded) content by + * interleaving the stored chunk bytes with zero-filled holes. + * + * @param mainBlock the already-read 512-byte sparse main header block + * @param longName a GNU long-name override from a preceding {@code L} block, or {@code null} + * @param longLinkName a GNU long-link override from a preceding {@code K} block, or {@code null} + * @param localPaxHeaders the PAX headers set by an immediately preceding {@code x} block, or {@code null} + * @return the parsed {@link TarEntry} + * @throws IOException if an I/O error occurs + */ + private TarEntry readSparseEntry(final byte[] mainBlock, + final String longName, + final String longLinkName, + final Map localPaxHeaders) throws IOException { + var header = TarHeader.parseSparseMainBlock(mainBlock); + + if (TarHeader.sparseMainBlockIsExtended(mainBlock)) { + var more = true; + while (more) { + final var extensionBlock = readFully(BLOCK_SIZE); + if (extensionBlock == null) { + throw new IOException("Unexpected end of stream reading GNU sparse extension block"); + } + header = header.withAdditionalSparseChunks(TarHeader.parseSparseExtensionBlock(extensionBlock)); + more = TarHeader.sparseExtensionBlockIsExtended(extensionBlock); + } + } + + if (longName != null) { + header = header.withName(longName); + } + if (longLinkName != null) { + header = header.withLinkName(longLinkName); + } + header = applyPaxOverrides(header, localPaxHeaders); + + final var storedSize = header.sparseChunks().stream().mapToLong(TarHeader.SparseChunk::length).sum(); + bytesRemaining = storedSize; + paddingRemaining = computePadding(storedSize); + sparseChunks = header.sparseChunks(); + sparseChunkIndex = 0; + sparseLogicalPos = 0; + sparseLogicalRemaining = header.size(); + + return new TarEntry(header); + } + + /** + * Applies any applicable PAX overrides (global headers merged with, and overridden + * by, this entry's local headers) to a parsed header. + * + * @param header the header parsed from the entry's own block + * @param localPaxHeaders the PAX headers set by an immediately preceding {@code x} block, or + * {@code null} if none + * @return the header with PAX overrides applied + */ + private TarHeader applyPaxOverrides(final TarHeader header, final Map localPaxHeaders) { + if (globalPaxHeaders.isEmpty() && (localPaxHeaders == null || localPaxHeaders.isEmpty())) { + return header; + } + final var effective = new LinkedHashMap<>(globalPaxHeaders); + if (localPaxHeaders != null) { + effective.putAll(localPaxHeaders); + } + + var result = header; + if (effective.containsKey("path")) { + result = result.withName(effective.get("path")); + } + if (effective.containsKey("linkpath")) { + result = result.withLinkName(effective.get("linkpath")); + } + if (effective.containsKey("size")) { + result = result.withSize(Long.parseLong(effective.get("size"))); + } + if (effective.containsKey("mtime")) { + result = result.withModTime((long) Double.parseDouble(effective.get("mtime"))); + } + if (effective.containsKey("uid") || effective.containsKey("gid") + || effective.containsKey("uname") || effective.containsKey("gname")) { + final var uid = effective.containsKey("uid") ? Integer.parseInt(effective.get("uid")) : result.uid(); + final var gid = effective.containsKey("gid") ? Integer.parseInt(effective.get("gid")) : result.gid(); + final var userName = effective.getOrDefault("uname", result.userName()); + final var groupName = effective.getOrDefault("gname", result.groupName()); + result = result.withOwner(uid, gid, userName, groupName); + } + return result; + } + + /** + * Merges freshly-parsed PAX records into a running set of global headers, honoring the + * PAX convention that a record with an empty value cancels (removes) that key. + * + * @param target the global headers accumulated so far, updated in place + * @param records the records parsed from a {@code g} header block + */ + private static void applyPaxRecords(final Map target, final Map records) { + records.forEach((key, value) -> { + if (value.isEmpty()) { + target.remove(key); + } else { + target.put(key, value); + } + }); + } + + /** + * Checks whether a zero block just read marks the end of the archive, i.e. it is + * followed by a second zero block or immediately by the end of the stream. + * + * @return {@code true} if this is a valid end-of-archive marker + * @throws IOException if an I/O error occurs + */ + private boolean atEndOfArchive() throws IOException { + final var next = readFully(BLOCK_SIZE); + // POSIX requires two consecutive zero-filled records; well-formed archives always + // write both. Immediate EOF after only one is accepted too, as a deliberate leniency + // for archives that omit trailing padding, rather than failing on an otherwise-valid stream. + return next == null || isZeroBlock(next); + } + + /** + * Throws to report a zero block that was not followed by a second zero block or + * the end of the stream, indicating a malformed archive. + * + * @return never returns + * @throws IOException always + */ + private TarEntry failEndOfArchive() throws IOException { + throw new IOException("Malformed tar archive: zero block not followed by a second zero block or end of stream"); + } + + /** + * Reads a GNU long-name or long-link data block: {@code size} bytes of NUL-terminated + * string data, followed by padding to the next block boundary. + * + * @param size the length of the data, as recorded in the preceding 'L' or 'K' header + * @return the long string value + * @throws IOException if an I/O error occurs + */ + private String readLongString(final long size) throws IOException { + final var bytes = readFully((int) size); + if (bytes == null) { + throw new IOException("Unexpected end of stream reading GNU long name/link data"); + } + skipFully(computePadding(size)); + + int end = 0; + while (end < bytes.length && bytes[end] != 0) { + end++; + } + return new String(bytes, 0, end, StandardCharsets.UTF_8); + } + + /** + * Reads and parses a PAX extended header data block into its key/value records. + *

+ * Each record has the form {@code " =\n"}, where {@code length} is + * the decimal ASCII length of the entire record, including itself, the space, and the + * trailing newline. + * + * @param size the length of the PAX data, as recorded in the preceding 'x' or 'g' header + * @return the parsed records, in encounter order + * @throws IOException if an I/O error occurs + */ + private Map readPaxRecords(final long size) throws IOException { + final var data = readFully((int) size); + if (data == null) { + throw new IOException("Unexpected end of stream reading PAX extended header"); + } + skipFully(computePadding(size)); + + final var records = new LinkedHashMap(); + var offset = 0; + while (offset < data.length) { + var spaceIndex = offset; + while (spaceIndex < data.length && data[spaceIndex] != ' ') { + spaceIndex++; + } + if (spaceIndex >= data.length) { + break; + } + final var recordLength = Integer.parseInt(new String(data, offset, spaceIndex - offset, StandardCharsets.US_ASCII)); + final var recordEnd = offset + recordLength; + + final var kvStart = spaceIndex + 1; + var equalsIndex = kvStart; + while (equalsIndex < recordEnd && data[equalsIndex] != '=') { + equalsIndex++; + } + final var key = new String(data, kvStart, equalsIndex - kvStart, StandardCharsets.UTF_8); + final var valueStart = equalsIndex + 1; + final var valueEnd = recordEnd - 1; // exclude trailing '\n' + final var value = new String(data, valueStart, valueEnd - valueStart, StandardCharsets.UTF_8); + records.put(key, value); + + offset = recordEnd; + } + return records; + } + + @Override + public int read() throws IOException { + final var buffer = new byte[1]; + final var n = read(buffer, 0, 1); + return n < 0 ? -1 : buffer[0] & 0xFF; + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + if (!sparseChunks.isEmpty()) { + return readSparse(b, off, len); + } + if (bytesRemaining <= 0) { + return -1; + } + final int toRead = (int) Math.min(len, bytesRemaining); + final int n = in.read(b, off, toRead); + if (n < 0) { + throw new IOException("Unexpected end of stream in tar entry data"); + } + bytesRemaining -= n; + return n; + } + + /** + * Reads from a GNU old-style sparse entry, reconstructing its logical (expanded) content + * by returning zero-filled bytes for holes and, for stored ranges, bytes read from + * {@link #in} (decrementing {@link #bytesRemaining} by exactly what was consumed). + * + * @param b the destination buffer + * @param off the offset into {@code b} to start writing at + * @param len the maximum number of bytes to write + * @return the number of bytes written, or {@code -1} at the end of the entry's logical content + * @throws IOException if an I/O error occurs + */ + private int readSparse(final byte[] b, final int off, final int len) throws IOException { + if (sparseLogicalRemaining <= 0) { + return -1; + } + while (sparseChunkIndex < sparseChunks.size() + && sparseLogicalPos >= sparseChunks.get(sparseChunkIndex).offset() + sparseChunks.get(sparseChunkIndex).length()) { + sparseChunkIndex++; + } + + final var holeEnd = sparseChunkIndex < sparseChunks.size() + ? sparseChunks.get(sparseChunkIndex).offset() + : sparseLogicalPos + sparseLogicalRemaining; + if (sparseLogicalPos < holeEnd) { + final int n = (int) Math.min(len, holeEnd - sparseLogicalPos); + Arrays.fill(b, off, off + n, (byte) 0); + sparseLogicalPos += n; + sparseLogicalRemaining -= n; + return n; + } + + final var chunk = sparseChunks.get(sparseChunkIndex); + final var chunkRemaining = chunk.offset() + chunk.length() - sparseLogicalPos; + final int toRead = (int) Math.min(Math.min(len, chunkRemaining), bytesRemaining); + final int n = in.read(b, off, toRead); + if (n < 0) { + throw new IOException("Unexpected end of stream in sparse tar entry data"); + } + bytesRemaining -= n; + sparseLogicalPos += n; + sparseLogicalRemaining -= n; + return n; + } + + @Override + public void close() throws IOException { + in.close(); + } + + /** + * Skips exactly the specified number of bytes from the underlying stream. + * + * @param count the number of bytes to skip + * @throws IOException if an I/O error occurs or the stream ends prematurely + */ + private void skipFully(final long count) throws IOException { + long remaining = count; + final var buffer = new byte[BLOCK_SIZE]; + while (remaining > 0) { + final int toRead = (int) Math.min(buffer.length, remaining); + final int n = in.read(buffer, 0, toRead); + if (n < 0) { + throw new IOException("Unexpected end of stream while skipping tar entry data"); + } + remaining -= n; + } + } + + /** + * Reads exactly the specified number of bytes from the underlying stream. + * + * @param count the number of bytes to read + * @return the bytes read, or {@code null} if the stream was already at end of file + * @throws IOException if an I/O error occurs or the stream ends prematurely after starting to read + */ + private byte[] readFully(final int count) throws IOException { + final var buffer = new byte[count]; + int offset = 0; + while (offset < count) { + final int n = in.read(buffer, offset, count - offset); + if (n < 0) { + if (offset == 0) { + return null; + } + throw new IOException("Unexpected end of stream reading tar header"); + } + offset += n; + } + return buffer; + } + + /** + * Determines whether a block consists entirely of zero bytes. + * + * @param block the block to check + * @return {@code true} if every byte is zero + */ + private static boolean isZeroBlock(final byte[] block) { + for (final byte b : block) { + if (b != 0) { + return false; + } + } + return true; + } + + /** + * Computes the number of padding bytes needed after {@code size} bytes of data + * to reach a 512-byte block boundary. + * + * @param size the number of data bytes + * @return the number of padding bytes + */ + private static long computePadding(final long size) { + final var remainder = size % BLOCK_SIZE; + return remainder == 0 ? 0 : BLOCK_SIZE - remainder; + } +} diff --git a/base-tar/src/main/java/build/base/tar/TarOutputStream.java b/base-tar/src/main/java/build/base/tar/TarOutputStream.java index 74e5fdf..3d3a56d 100644 --- a/base-tar/src/main/java/build/base/tar/TarOutputStream.java +++ b/base-tar/src/main/java/build/base/tar/TarOutputStream.java @@ -20,11 +20,17 @@ * #L% */ +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; /** - * An {@link OutputStream} that writes entries in POSIX ustar tar format. + * An {@link OutputStream} that writes entries in POSIX ustar tar format, falling back to a + * PAX extended header ({@code x} typeflag) for any entry whose name or link target does not + * fit the fixed-width ustar fields (and cannot be expressed via the ustar prefix field either). *

* Usage: create a {@link TarOutputStream} wrapping an existing {@link OutputStream}, * then repeatedly call {@link #putNextEntry(TarEntry)} followed by {@link #write(byte[], int, int)} @@ -71,14 +77,87 @@ public void putNextEntry(final TarEntry entry) throws IOException { // pad the previous entry to a block boundary if needed padCurrentEntry(); - // write the header block - final var headerBytes = entry.header().toBytes(); - out.write(headerBytes); + var header = entry.header(); + if (header.nameNeedsPaxExtension() || header.linkNameNeedsPaxExtension()) { + writePaxExtendedHeader(header); + header = header.toUstarSafeHeader(); + } + + // write the header block, plus any GNU sparse extension blocks it requires + out.write(header.toBytes()); + for (final var block : header.sparseExtensionBlocks()) { + out.write(block); + } // reset byte counter bytesWritten = 0; } + /** + * Writes a PAX extended header ({@code x} typeflag) block carrying the {@code path} + * and/or {@code linkpath} records needed to convey a name or link target that does not + * fit the ustar fixed-width fields, followed by its data and block padding. + *

+ * Since a PAX header is already being paid for, this also includes {@code size}/{@code uid}/ + * {@code gid} records for any of those fields that would otherwise only be recoverable via + * the GNU base-256 extension, for maximum compatibility with readers that understand PAX + * but not base-256. + * + * @param header the entry header whose name and/or link name require a PAX extension + * @throws IOException if an I/O error occurs + */ + private void writePaxExtendedHeader(final TarHeader header) throws IOException { + final var records = new LinkedHashMap(); + if (header.nameNeedsPaxExtension()) { + records.put("path", header.name()); + } + if (header.linkNameNeedsPaxExtension()) { + records.put("linkpath", header.linkName()); + } + if (TarHeader.exceedsOctalField(header.size(), 12)) { + records.put("size", String.valueOf(header.size())); + } + if (TarHeader.exceedsOctalField(header.uid(), 8)) { + records.put("uid", String.valueOf(header.uid())); + } + if (TarHeader.exceedsOctalField(header.gid(), 8)) { + records.put("gid", String.valueOf(header.gid())); + } + + final var data = encodePaxRecords(records); + out.write(TarHeader.createExtensionHeaderBlock("PaxHeader", data.length, 'x')); + out.write(data); + final var padding = computePadding(data.length); + if (padding > 0) { + out.write(new byte[(int) padding]); + } + } + + /** + * Encodes PAX extended header records in the standard {@code " =\n"} + * form, where {@code length} is the decimal length of the entire record including itself. + * + * @param records the records to encode, in iteration order + * @return the encoded PAX data block + */ + private static byte[] encodePaxRecords(final Map records) { + final var buffer = new ByteArrayOutputStream(); + records.forEach((key, value) -> { + final var keyValueBytes = (key + "=" + value + "\n").getBytes(StandardCharsets.UTF_8); + // the length prefix includes its own digit count, which can grow the total, + // so grow the digit count until it stops changing the total length + var digits = 1; + var recordLength = keyValueBytes.length + 1 + digits; + while (String.valueOf(recordLength).length() != digits) { + digits = String.valueOf(recordLength).length(); + recordLength = keyValueBytes.length + 1 + digits; + } + buffer.writeBytes((recordLength + " ").getBytes(StandardCharsets.US_ASCII)); + buffer.writeBytes(keyValueBytes); + }); + return buffer.toByteArray(); + } + @Override public void write(final int b) throws IOException { out.write(b); @@ -119,4 +198,16 @@ private void padCurrentEntry() throws IOException { } } } + + /** + * Computes the number of padding bytes needed after {@code size} bytes of data + * to reach a 512-byte block boundary. + * + * @param size the number of data bytes + * @return the number of padding bytes + */ + private static long computePadding(final long size) { + final var remainder = size % BLOCK_SIZE; + return remainder == 0 ? 0 : BLOCK_SIZE - remainder; + } } diff --git a/base-tar/src/test/java/build/base/tar/TarHeaderTests.java b/base-tar/src/test/java/build/base/tar/TarHeaderTests.java index dd31da5..0215004 100644 --- a/base-tar/src/test/java/build/base/tar/TarHeaderTests.java +++ b/base-tar/src/test/java/build/base/tar/TarHeaderTests.java @@ -23,6 +23,8 @@ import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -104,10 +106,10 @@ void shouldWriteNonZeroChecksum() { @Test void shouldSplitLongNameIntoPrefixAndNameFields() { - // 150-char name: 50-char prefix + 100-char name - final var prefix = "a".repeat(50); + // 150-char path: 49-char prefix + '/' + 100-char name + final var prefix = "a".repeat(49); final var name = "b".repeat(100); - final var longName = prefix + name; + final var longName = prefix + "/" + name; final var header = TarHeader.createHeader(longName, 0, 0, false, 0644); final var bytes = header.toBytes(); @@ -119,9 +121,231 @@ void shouldSplitLongNameIntoPrefixAndNameFields() { assertThat(nameField).isEqualTo(name); } + @Test + void shouldSplitPrefixAndNameOnPathSeparatorPerPosixSpec() { + // POSIX ustar spec: when the prefix field is used, a compliant reader + // reconstructs the full pathname as `prefix + "/" + name` (the separator is + // implicit and not stored in either field). The split must therefore land + // exactly on a '/' in the original path, not at an arbitrary character offset. + final var name = "x".repeat(30) + "/" + "y".repeat(90); + assertThat(name).hasSize(121); + assertThat(name.charAt(name.length() - 100)).isNotEqualTo('/'); + + final var header = TarHeader.createHeader(name, 0, 0, false, 0644); + final var bytes = header.toBytes(); + + final var nameField = new String(bytes, 0, 100, StandardCharsets.US_ASCII).replace("\0", ""); + final var prefixField = new String(bytes, 345, 155, StandardCharsets.US_ASCII).replace("\0", ""); + + assertThat(prefixField + "/" + nameField).isEqualTo(name); + } + + @Test + void shouldWriteSymlinkTypeflagAndLinkName() { + final var header = TarHeader.createSymlinkHeader("link.txt", "target.txt", 0, 0777); + final var bytes = header.toBytes(); + + assertThat(bytes[156]).isEqualTo((byte) '2'); + final var linkNameField = new String(bytes, 157, 100, StandardCharsets.UTF_8).replace("\0", ""); + assertThat(linkNameField).isEqualTo("target.txt"); + } + + @Test + void shouldWriteHardlinkTypeflagAndLinkName() { + final var header = TarHeader.createHardlinkHeader("link.txt", "target.txt", 0, 0644); + final var bytes = header.toBytes(); + + assertThat(bytes[156]).isEqualTo((byte) '1'); + final var linkNameField = new String(bytes, 157, 100, StandardCharsets.UTF_8).replace("\0", ""); + assertThat(linkNameField).isEqualTo("target.txt"); + } + + @Test + void shouldRoundTripHardlinkThroughParseHeader() { + final var header = TarHeader.createHardlinkHeader("link.txt", "target.txt", 0, 0644); + final var parsed = TarHeader.parseHeader(header.toBytes()); + + assertThat(parsed.name()).isEqualTo("link.txt"); + assertThat(parsed.linkName()).isEqualTo("target.txt"); + assertThat(parsed.hardlink()).isTrue(); + assertThat(parsed.directory()).isFalse(); + } + + @Test + void shouldRoundTripSymlinkThroughParseHeader() { + final var header = TarHeader.createSymlinkHeader("link.txt", "target.txt", 0, 0777); + final var parsed = TarHeader.parseHeader(header.toBytes()); + + assertThat(parsed.name()).isEqualTo("link.txt"); + assertThat(parsed.linkName()).isEqualTo("target.txt"); + assertThat(parsed.directory()).isFalse(); + } + + @Test + void shouldRoundTripNonAsciiNameThroughParseHeader() { + final var header = TarHeader.createHeader("café-ü.txt", 0, 0, false, 0644); + final var parsed = TarHeader.parseHeader(header.toBytes()); + + assertThat(parsed.name()).isEqualTo("café-ü.txt"); + } + + @Test + void shouldRoundTripOwnershipFields() { + final var header = TarHeader.createHeader("file.txt", 0, 0, false, 0644, 1001, 1002, "alice", "staff"); + final var parsed = TarHeader.parseHeader(header.toBytes()); + + assertThat(parsed.uid()).isEqualTo(1001); + assertThat(parsed.gid()).isEqualTo(1002); + assertThat(parsed.userName()).isEqualTo("alice"); + assertThat(parsed.groupName()).isEqualTo("staff"); + } + + @Test + void shouldRoundTripSizeAboveOctalFieldLimitUsingBase256() { + // 11-digit octal size field tops out just under 8GB; this exceeds that + final var hugeSize = 10_000_000_000L; + final var header = TarHeader.createHeader("huge.bin", hugeSize, 0, false, 0644); + final var bytes = header.toBytes(); + + // GNU base-256 extension: high bit of the size field's first byte is set + assertThat(bytes[124] & 0x80).isNotZero(); + + final var parsed = TarHeader.parseHeader(bytes); + assertThat(parsed.size()).isEqualTo(hugeSize); + } + + @Test + void shouldNeedPaxExtensionForNameUnder100CharsButOver100Utf8Bytes() { + // 90 CJK characters: 90 UTF-16 chars (<= 100) but 270 UTF-8 bytes (> 100). + // nameNeedsPaxExtension() must key off byte length, not char length. + final var name = "中".repeat(90); + assertThat(name.length()).isLessThanOrEqualTo(100); + assertThat(name.getBytes(StandardCharsets.UTF_8).length).isGreaterThan(100); + + final var header = TarHeader.createHeader(name, 0, 0, false, 0644); + + assertThat(header.nameNeedsPaxExtension()).isTrue(); + } + + @Test + void shouldRejectRatherThanSilentlyTruncateNameUnder100CharsButOver100Utf8Bytes() { + // Same shape as above, but exercised through toBytes() directly: it must fail loudly + // like any other unsplittable long name, rather than have writeString() truncate the + // encoded bytes to fit the 100-byte name field and silently corrupt the entry name. + final var name = "中".repeat(90); + final var header = TarHeader.createHeader(name, 0, 0, false, 0644); + + assertThatThrownBy(header::toBytes) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("too long"); + } + + @Test + void shouldNeedPaxExtensionForLinkNameUnder100CharsButOver100Utf8Bytes() { + final var target = "中".repeat(90); + assertThat(target.length()).isLessThanOrEqualTo(100); + assertThat(target.getBytes(StandardCharsets.UTF_8).length).isGreaterThan(100); + + final var header = TarHeader.createSymlinkHeader("link.txt", target, 0, 0777); + + assertThat(header.linkNameNeedsPaxExtension()).isTrue(); + } + + @Test + void shouldRejectRatherThanOverflowPrefixSplitNameFieldWithMultibyteCharacters() { + // 60 CJK characters fit the char-based prefix/name split (suffixLength <= 100 chars), + // but encode to 180 UTF-8 bytes, overflowing the 100-byte name field the split assumed. + // No valid byte-respecting split exists here, so this must be rejected like any other + // unsplittable long name rather than silently overflow the name field. + final var prefix = "a".repeat(50); + final var name = "中".repeat(60); + final var longName = prefix + "/" + name; + + final var header = TarHeader.createHeader(longName, 0, 0, false, 0644); + + assertThatThrownBy(header::toBytes) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("too long"); + } + + @Test + void shouldSplitPrefixAndNameOnPathSeparatorRespectingUtf8ByteWidths() { + // The name half is 90 CJK chars (270 bytes) if measured wrong, but split correctly + // here since it's under the 100-byte limit on its own; verifies the split still works + // once it is UTF-8-byte-aware, not just that oversized cases are rejected. + final var prefix = "a".repeat(50); + final var name = "中".repeat(30); + final var longName = prefix + "/" + name; + + final var header = TarHeader.createHeader(longName, 0, 0, false, 0644); + final var bytes = header.toBytes(); + + final var nameField = new String(bytes, 0, 100, StandardCharsets.UTF_8).replace("\0", ""); + final var prefixField = new String(bytes, 345, 155, StandardCharsets.UTF_8).replace("\0", ""); + assertThat(prefixField).isEqualTo(prefix); + assertThat(nameField).isEqualTo(name); + } + + @Test + void shouldWriteSparseTypeflagAndRealSize() { + final var chunks = List.of(new TarHeader.SparseChunk(0, 100), new TarHeader.SparseChunk(1000, 50)); + final var header = TarHeader.createSparseHeader("sparse.bin", 2000, chunks, 0, 0644); + final var bytes = header.toBytes(); + + assertThat(bytes[156]).isEqualTo((byte) 'S'); + // size field (offset 124) holds the stored (chunk) bytes, not the logical size + final var storedSizeField = new String(bytes, 124, 11, StandardCharsets.US_ASCII).trim(); + assertThat(Long.parseLong(storedSizeField, 8)).isEqualTo(150); + // realsize field (offset 483) holds the logical size + final var realSizeField = new String(bytes, 483, 11, StandardCharsets.US_ASCII).trim(); + assertThat(Long.parseLong(realSizeField, 8)).isEqualTo(2000); + // isextended (offset 482) is unset since both chunks fit in the main block + assertThat(bytes[482]).isEqualTo((byte) 0); + assertThat(header.sparseExtensionBlocks()).isEmpty(); + } + + @Test + void shouldRoundTripSparseHeaderWithFewChunksThroughParseHeader() { + final var chunks = List.of(new TarHeader.SparseChunk(0, 100), new TarHeader.SparseChunk(1000, 50)); + final var header = TarHeader.createSparseHeader("sparse.bin", 2000, chunks, 0, 0644); + final var mainBlock = header.toBytes(); + + assertThat(TarHeader.sparseMainBlockIsExtended(mainBlock)).isFalse(); + final var parsed = TarHeader.parseSparseMainBlock(mainBlock); + + assertThat(parsed.name()).isEqualTo("sparse.bin"); + assertThat(parsed.size()).isEqualTo(2000); + assertThat(parsed.sparseChunks()).isEqualTo(chunks); + assertThat(parsed.directory()).isFalse(); + } + + @Test + void shouldRequireExtensionBlocksForMoreThanFourSparseChunks() { + final var chunks = new ArrayList(); + for (int i = 0; i < 30; i++) { + chunks.add(new TarHeader.SparseChunk(i * 1000L, 10)); + } + final var header = TarHeader.createSparseHeader("sparse.bin", 30_000, chunks, 0, 0644); + final var mainBlock = header.toBytes(); + + assertThat(TarHeader.sparseMainBlockIsExtended(mainBlock)).isTrue(); + final var extensionBlocks = header.sparseExtensionBlocks(); + // 30 total chunks - 4 in the main block = 26 remaining, 21 per extension block + assertThat(extensionBlocks).hasSize(2); + + var parsed = TarHeader.parseSparseMainBlock(mainBlock); + for (final var block : extensionBlocks) { + parsed = parsed.withAdditionalSparseChunks(TarHeader.parseSparseExtensionBlock(block)); + } + + assertThat(parsed.sparseChunks()).isEqualTo(chunks); + assertThat(TarHeader.sparseExtensionBlockIsExtended(extensionBlocks.get(0))).isTrue(); + assertThat(TarHeader.sparseExtensionBlockIsExtended(extensionBlocks.get(1))).isFalse(); + } + @Test void shouldRejectNameThatIsTooLong() { - // length > 255: splitIndex would exceed 155 + // no '/' anywhere in the name, so no valid prefix/name split exists final var tooLong = "a".repeat(256); final var header = TarHeader.createHeader(tooLong, 0, 0, false, 0644); assertThatThrownBy(header::toBytes) diff --git a/base-tar/src/test/java/build/base/tar/TarInputStreamTests.java b/base-tar/src/test/java/build/base/tar/TarInputStreamTests.java new file mode 100644 index 0000000..171228d --- /dev/null +++ b/base-tar/src/test/java/build/base/tar/TarInputStreamTests.java @@ -0,0 +1,474 @@ +package build.base.tar; + +/*- + * #%L + * base.build Tar + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link TarInputStream}. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +class TarInputStreamTests { + + private static final long MOD_TIME = 0L; + + @Test + void shouldReturnNullForEmptyArchive() throws IOException { + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + // empty archive — no entries + } + + try (final var tar = new TarInputStream(new ByteArrayInputStream(archive.toByteArray()))) { + assertThat(tar.getNextEntry()).isNull(); + } + } + + @Test + void shouldReadSingleFileEntry() throws IOException { + final var content = "hello, tar\n".getBytes(StandardCharsets.UTF_8); + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + final var header = TarHeader.createHeader("hello.txt", content.length, MOD_TIME, false, 0644); + tar.putNextEntry(new TarEntry(header)); + tar.write(content); + } + + try (final var tar = new TarInputStream(new ByteArrayInputStream(archive.toByteArray()))) { + final var entry = tar.getNextEntry(); + assertThat(entry).isNotNull(); + assertThat(entry.header().name()).isEqualTo("hello.txt"); + assertThat(entry.header().size()).isEqualTo(content.length); + assertThat(entry.header().directory()).isFalse(); + assertThat(entry.header().permissions()).isEqualTo(0644); + + assertThat(tar.readAllBytes()).isEqualTo(content); + assertThat(tar.getNextEntry()).isNull(); + } + } + + @Test + void shouldReadDirectoryEntry() throws IOException { + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + final var header = TarHeader.createHeader("mydir/", 0, MOD_TIME, true, 0755); + tar.putNextEntry(new TarEntry(header)); + } + + try (final var tar = new TarInputStream(new ByteArrayInputStream(archive.toByteArray()))) { + final var entry = tar.getNextEntry(); + assertThat(entry).isNotNull(); + assertThat(entry.header().name()).isEqualTo("mydir/"); + assertThat(entry.header().directory()).isTrue(); + assertThat(entry.header().size()).isEqualTo(0); + } + } + + @Test + void shouldReadMultipleEntriesSkippingUnreadData() throws IOException { + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + for (int i = 1; i <= 3; i++) { + final var bytes = ("content" + i).getBytes(StandardCharsets.UTF_8); + final var header = TarHeader.createHeader("file" + i + ".txt", bytes.length, MOD_TIME, false, 0644); + tar.putNextEntry(new TarEntry(header)); + tar.write(bytes); + } + } + + try (final var tar = new TarInputStream(new ByteArrayInputStream(archive.toByteArray()))) { + for (int i = 1; i <= 3; i++) { + final var entry = tar.getNextEntry(); + assertThat(entry.header().name()).isEqualTo("file" + i + ".txt"); + // deliberately not reading the entry data — next call must skip it + } + assertThat(tar.getNextEntry()).isNull(); + } + } + + @Test + void shouldReadEntryDataAcrossBlockBoundary() throws IOException { + final var data = new byte[513]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) (i % 256); + } + + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + final var header = TarHeader.createHeader("file.bin", data.length, MOD_TIME, false, 0644); + tar.putNextEntry(new TarEntry(header)); + tar.write(data); + } + + try (final var tar = new TarInputStream(new ByteArrayInputStream(archive.toByteArray()))) { + final var entry = tar.getNextEntry(); + assertThat(entry.header().size()).isEqualTo(data.length); + assertThat(tar.readAllBytes()).isEqualTo(data); + } + } + + @Test + void shouldRoundTripArchiveProducedByGnuTar(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + Files.writeString(sourceDir.resolve("a.txt"), "alpha"); + Files.writeString(sourceDir.resolve("b.txt"), "beta"); + + final var archivePath = tempDir.resolve("test.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + final var names = new HashMap(); + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (!entry.header().directory()) { + names.put(entry.header().name(), new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + } + } + } + + assertThat(names).containsEntry("./a.txt", "alpha"); + assertThat(names).containsEntry("./b.txt", "beta"); + } + + @Test + void shouldReadGnuLongNameEntry(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + final var longName = "a".repeat(60) + "/" + "b".repeat(60) + ".txt"; + final var longFile = sourceDir.resolve(longName); + Files.createDirectories(longFile.getParent()); + Files.writeString(longFile, "long name content"); + + final var archivePath = tempDir.resolve("longname.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + final var names = new HashMap(); + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (!entry.header().directory()) { + names.put(entry.header().name(), new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + } + } + } + + assertThat(names).containsEntry("./" + longName, "long name content"); + } + + @Test + void shouldReadSymlinkTarget(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + Files.writeString(sourceDir.resolve("target.txt"), "target contents"); + Files.createSymbolicLink(sourceDir.resolve("link.txt"), Path.of("target.txt")); + + final var archivePath = tempDir.resolve("symlink.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + String linkTarget = null; + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (entry.header().name().equals("./link.txt")) { + linkTarget = entry.header().linkName(); + } + } + } + + assertThat(linkTarget).isEqualTo("target.txt"); + } + + @Test + void shouldReadHardlinkTarget(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + Files.writeString(sourceDir.resolve("target.txt"), "target contents"); + Files.createLink(sourceDir.resolve("link.txt"), sourceDir.resolve("target.txt")); + + final var archivePath = tempDir.resolve("hardlink.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + // GNU tar emits whichever of the two directory entries it visits second as a + // hardlink pointing back at the first, so which name ends up as the hardlink + // depends on directory traversal order rather than the names themselves. + TarHeader hardlinkHeader = null; + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (entry.header().hardlink()) { + hardlinkHeader = entry.header(); + } + } + } + + assertThat(hardlinkHeader).isNotNull(); + assertThat(hardlinkHeader.linkName()).isIn("./target.txt", "./link.txt"); + } + + @Test + void shouldReadGnuSparseFile(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + final var sparsePath = sourceDir.resolve("sparse.bin"); + try (final var raf = new java.io.RandomAccessFile(sparsePath.toFile(), "rw")) { + raf.setLength(1_000_000); + raf.seek(10_000); + raf.write("hello".getBytes(StandardCharsets.UTF_8)); + raf.seek(900_000); + raf.write("world".getBytes(StandardCharsets.UTF_8)); + } + + final var archivePath = tempDir.resolve("sparse.tar"); + final var result = new ProcessBuilder("tar", "--sparse", "-cf", archivePath.toString(), + "-C", sourceDir.toString(), "sparse.bin") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + TarHeader header = null; + byte[] content = null; + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + final var entry = tar.getNextEntry(); + header = entry.header(); + content = tar.readAllBytes(); + } + + assertThat(header.name()).isEqualTo("sparse.bin"); + assertThat(header.size()).isEqualTo(1_000_000); + assertThat(content).hasSize(1_000_000); + assertThat(new String(content, 10_000, 5, StandardCharsets.UTF_8)).isEqualTo("hello"); + assertThat(new String(content, 900_000, 5, StandardCharsets.UTF_8)).isEqualTo("world"); + assertThat(content[0]).isZero(); + assertThat(content[999_999]).isZero(); + } + + @Test + void shouldReadNonAsciiEntryName(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + final var name = "café-ü.txt"; + Files.writeString(sourceDir.resolve(name), "content"); + + final var archivePath = tempDir.resolve("nonascii.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + final var names = new HashMap(); + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (!entry.header().directory()) { + names.put(entry.header().name(), new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + } + } + } + + assertThat(names).containsEntry("./" + name, "content"); + } + + @Test + void shouldReadGnuLongNameEntryWithNonAsciiCharacters(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + // a single path component over 100 bytes with no '/' forces the GNU longname + // extension (typeflag 'L'), rather than a ustar prefix/name split + final var longName = "café-" + "a".repeat(150) + ".txt"; + Files.writeString(sourceDir.resolve(longName), "long name content"); + + final var archivePath = tempDir.resolve("longname-nonascii.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + final var names = new HashMap(); + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (!entry.header().directory()) { + names.put(entry.header().name(), new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + } + } + } + + assertThat(names).containsEntry("./" + longName, "long name content"); + } + + @Test + void shouldReadPaxLongNameEntry(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + // single path component over 100 bytes with no '/', forcing a long-name extension; + // --format=posix makes tar use PAX ('x') headers rather than the GNU 'L' extension + final var longName = "café-" + "a".repeat(150) + ".txt"; + Files.writeString(sourceDir.resolve(longName), "pax content"); + + final var archivePath = tempDir.resolve("pax-longname.tar"); + final var result = new ProcessBuilder("tar", "--format=posix", "-cf", archivePath.toString(), + "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + final var names = new HashMap(); + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (!entry.header().directory()) { + names.put(entry.header().name(), new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + } + } + } + + assertThat(names).containsEntry("./" + longName, "pax content"); + } + + @Test + void shouldReadGnuLongLinkSymlinkTarget(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + final var longTarget = "b".repeat(150) + ".txt"; + Files.createSymbolicLink(sourceDir.resolve("link.txt"), Path.of(longTarget)); + + final var archivePath = tempDir.resolve("longlink.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + String linkTarget = null; + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (entry.header().name().equals("./link.txt")) { + linkTarget = entry.header().linkName(); + } + } + } + + assertThat(linkTarget).isEqualTo(longTarget); + } + + @Test + void shouldReadPaxLongLinkSymlinkTarget(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + final var longTarget = "b".repeat(150) + ".txt"; + Files.createSymbolicLink(sourceDir.resolve("link.txt"), Path.of(longTarget)); + + final var archivePath = tempDir.resolve("pax-longlink.tar"); + final var result = new ProcessBuilder("tar", "--format=posix", "-cf", archivePath.toString(), + "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + String linkTarget = null; + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (entry.header().name().equals("./link.txt")) { + linkTarget = entry.header().linkName(); + } + } + } + + assertThat(linkTarget).isEqualTo(longTarget); + } + + @Test + void shouldReadOwnershipFromRealArchive(@TempDir final Path tempDir) throws IOException, InterruptedException { + final var sourceDir = tempDir.resolve("src"); + Files.createDirectories(sourceDir); + Files.writeString(sourceDir.resolve("owned.txt"), "content"); + + final var idProcess = new ProcessBuilder("id", "-u").start(); + assertThat(idProcess.waitFor()).isEqualTo(0); + final var currentUid = Integer.parseInt(new String(idProcess.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim()); + + final var archivePath = tempDir.resolve("owned.tar"); + final var result = new ProcessBuilder("tar", "-cf", archivePath.toString(), "-C", sourceDir.toString(), ".") + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + TarHeader ownedHeader = null; + try (final var tar = new TarInputStream(Files.newInputStream(archivePath))) { + TarEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (entry.header().name().equals("./owned.txt")) { + ownedHeader = entry.header(); + } + } + } + + assertThat(ownedHeader).isNotNull(); + assertThat(ownedHeader.uid()).isEqualTo(currentUid); + assertThat(ownedHeader.userName()).isNotEmpty(); + } + + @Test + void shouldThrowOnCorruptHeaderChecksum() throws IOException { + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + final var header = TarHeader.createHeader("file.txt", 5, MOD_TIME, false, 0644); + tar.putNextEntry(new TarEntry(header)); + tar.write("hello".getBytes(StandardCharsets.UTF_8)); + } + + final var bytes = archive.toByteArray(); + // corrupt a byte within the name field, invalidating the checksum + bytes[0] = 'X'; + + try (final var tar = new TarInputStream(new ByteArrayInputStream(bytes))) { + assertThatThrownBy(tar::getNextEntry).isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/base-tar/src/test/java/build/base/tar/TarRoundTripTests.java b/base-tar/src/test/java/build/base/tar/TarRoundTripTests.java new file mode 100644 index 0000000..242a402 --- /dev/null +++ b/base-tar/src/test/java/build/base/tar/TarRoundTripTests.java @@ -0,0 +1,361 @@ +package build.base.tar; + +/*- + * #%L + * base.build Tar + * %% + * Copyright (C) 2026 Workday Inc + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end tests that write entries with {@link TarOutputStream} and read them back with + * {@link TarInputStream}, exercising every case that requires the two to agree on wire format: + * plain entries, symlinks, oversized names/link targets that force a PAX extended header, and + * non-ASCII names. These are the tests that would have caught {@link TarOutputStream} throwing + * on an entry that {@link TarInputStream} can otherwise read from third-party archives. + * + * @author reed.vonredwitz + * @since Jul-2026 + */ +class TarRoundTripTests { + + private static final long MOD_TIME = 0L; + + @Test + void shouldRoundTripRegularFile() throws IOException { + final var content = "hello, tar\n".getBytes(StandardCharsets.UTF_8); + final var header = TarHeader.createHeader("hello.txt", content.length, MOD_TIME, false, 0644); + + final var entry = roundTrip(header, content); + + assertThat(entry.header().name()).isEqualTo("hello.txt"); + assertThat(entry.header().directory()).isFalse(); + assertThat(entry.content()).isEqualTo(content); + } + + @Test + void shouldRoundTripDirectory() throws IOException { + final var header = TarHeader.createHeader("mydir/", 0, MOD_TIME, true, 0755); + + final var entry = roundTrip(header, new byte[0]); + + assertThat(entry.header().name()).isEqualTo("mydir/"); + assertThat(entry.header().directory()).isTrue(); + } + + @Test + void shouldRoundTripShortSymlink() throws IOException { + final var header = TarHeader.createSymlinkHeader("link.txt", "target.txt", MOD_TIME, 0777); + + final var entry = roundTrip(header, new byte[0]); + + assertThat(entry.header().name()).isEqualTo("link.txt"); + assertThat(entry.header().linkName()).isEqualTo("target.txt"); + } + + @Test + void shouldRoundTripSparseFileWithFewChunks() throws IOException { + final var chunks = List.of(new TarHeader.SparseChunk(0, 5), new TarHeader.SparseChunk(1000, 6)); + final var header = TarHeader.createSparseHeader("sparse.bin", 2000, chunks, MOD_TIME, 0644); + final var data = new byte[11]; + System.arraycopy("first".getBytes(StandardCharsets.UTF_8), 0, data, 0, 5); + System.arraycopy("second".getBytes(StandardCharsets.UTF_8), 0, data, 5, 6); + + final var entry = roundTrip(header, data); + + assertThat(entry.header().name()).isEqualTo("sparse.bin"); + assertThat(entry.header().size()).isEqualTo(2000); + assertThat(entry.header().sparseChunks()).isEqualTo(chunks); + assertThat(entry.content()).hasSize(2000); + assertThat(new String(entry.content(), 0, 5, StandardCharsets.UTF_8)).isEqualTo("first"); + assertThat(new String(entry.content(), 1000, 6, StandardCharsets.UTF_8)).isEqualTo("second"); + // everything outside the declared chunks must reconstruct as zero-filled holes + assertThat(entry.content()[10]).isZero(); + assertThat(entry.content()[1999]).isZero(); + } + + @Test + void shouldRoundTripSparseFileWithManyChunksRequiringExtensionBlocks() throws IOException { + final var chunks = new ArrayList(); + final var buffer = new ByteArrayOutputStream(); + for (int i = 0; i < 30; i++) { + final var chunkData = ("chunk" + i).getBytes(StandardCharsets.UTF_8); + chunks.add(new TarHeader.SparseChunk(i * 1000L, chunkData.length)); + buffer.writeBytes(chunkData); + } + final var header = TarHeader.createSparseHeader("sparse.bin", 30_000, chunks, MOD_TIME, 0644); + + final var entry = roundTrip(header, buffer.toByteArray()); + + assertThat(entry.header().sparseChunks()).isEqualTo(chunks); + assertThat(entry.content()).hasSize(30_000); + for (int i = 0; i < 30; i++) { + final var chunkData = ("chunk" + i).getBytes(StandardCharsets.UTF_8); + final var offset = i * 1000; + assertThat(new String(entry.content(), offset, chunkData.length, StandardCharsets.UTF_8)) + .isEqualTo("chunk" + i); + } + assertThat(entry.content()[9]).isZero(); + } + + @Test + void shouldProduceGnuSparseArchiveReadableByRealTar(@TempDir final Path tempDir) throws IOException, InterruptedException { + // GNU tar's own sparse extractor only reliably reconstructs chunks aligned to a + // filesystem block boundary (as any real sparse-file detector, e.g. SEEK_HOLE/SEEK_DATA, + // would naturally produce); arbitrary byte-level offsets it did not write itself can + // extract incorrectly. Use 4096-byte-aligned chunks here to match real-world usage. + final var blockSize = 4096; + final var chunks = List.of(new TarHeader.SparseChunk(0, blockSize), new TarHeader.SparseChunk(98_304, blockSize)); + final var header = TarHeader.createSparseHeader("sparse.bin", 200_000, chunks, MOD_TIME, 0644); + final var data = new byte[2 * blockSize]; + System.arraycopy("first".getBytes(StandardCharsets.UTF_8), 0, data, 0, 5); + System.arraycopy("second".getBytes(StandardCharsets.UTF_8), 0, data, blockSize + (100_000 - 98_304), 6); + + final var archivePath = tempDir.resolve("sparse.tar"); + try (final var tar = new TarOutputStream(Files.newOutputStream(archivePath))) { + tar.putNextEntry(new TarEntry(header)); + tar.write(data); + } + + final var extractDir = tempDir.resolve("extracted"); + Files.createDirectories(extractDir); + final var result = new ProcessBuilder("tar", "-xf", archivePath.toString(), "-C", extractDir.toString()) + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + final var extracted = Files.readAllBytes(extractDir.resolve("sparse.bin")); + assertThat(extracted).hasSize(200_000); + assertThat(new String(extracted, 0, 5, StandardCharsets.UTF_8)).isEqualTo("first"); + assertThat(new String(extracted, 100_000, 6, StandardCharsets.UTF_8)).isEqualTo("second"); + assertThat(extracted[199_999]).isZero(); + } + + @Test + void shouldEmitPaxSizeUidGidRecordsAlongsideLongName() throws IOException { + final var longName = "a".repeat(150) + ".txt"; + final var header = TarHeader.createHeader(longName, 0, MOD_TIME, false, 0644, 99_999_999, 1, "big", "staff"); + + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + tar.putNextEntry(new TarEntry(header)); + } + + // the PAX extended header data block immediately follows the PAX header's own 512-byte block + final var paxData = new String(archive.toByteArray(), 512, 300, StandardCharsets.UTF_8); + assertThat(paxData).contains("uid=99999999"); + + final var entry = roundTrip(header, new byte[0]); + assertThat(entry.header().name()).isEqualTo(longName); + assertThat(entry.header().uid()).isEqualTo(99_999_999); + } + + @Test + void shouldRoundTripHardlink() throws IOException { + final var header = TarHeader.createHardlinkHeader("link.txt", "target.txt", MOD_TIME, 0644); + + final var entry = roundTrip(header, new byte[0]); + + assertThat(entry.header().name()).isEqualTo("link.txt"); + assertThat(entry.header().linkName()).isEqualTo("target.txt"); + assertThat(entry.header().hardlink()).isTrue(); + assertThat(entry.header().directory()).isFalse(); + } + + @Test + void shouldRoundTripSymlinkWithLongTargetViaPax() throws IOException { + final var longTarget = "b".repeat(150) + ".txt"; + final var header = TarHeader.createSymlinkHeader("link.txt", longTarget, MOD_TIME, 0777); + + final var entry = roundTrip(header, new byte[0]); + + assertThat(entry.header().name()).isEqualTo("link.txt"); + assertThat(entry.header().linkName()).isEqualTo(longTarget); + } + + @Test + void shouldRoundTripUnsplittableLongNameViaPax() throws IOException { + // a single path component over 100 bytes with no '/' cannot use the ustar prefix + // field, so this must go out as a PAX 'path' record instead of throwing + final var longName = "a".repeat(150) + ".txt"; + final var content = "content".getBytes(StandardCharsets.UTF_8); + final var header = TarHeader.createHeader(longName, content.length, MOD_TIME, false, 0644); + + final var entry = roundTrip(header, content); + + assertThat(entry.header().name()).isEqualTo(longName); + assertThat(entry.content()).isEqualTo(content); + } + + @Test + void shouldRoundTripSplittableLongNameViaUstarPrefix() throws IOException { + final var longName = "a".repeat(60) + "/" + "b".repeat(60) + ".txt"; + final var content = "content".getBytes(StandardCharsets.UTF_8); + final var header = TarHeader.createHeader(longName, content.length, MOD_TIME, false, 0644); + + final var entry = roundTrip(header, content); + + assertThat(entry.header().name()).isEqualTo(longName); + assertThat(entry.content()).isEqualTo(content); + } + + @Test + void shouldRoundTripNameUnder100CharsButOver100Utf8Bytes() throws IOException { + // 90 CJK characters: <= 100 UTF-16 chars, but 270 UTF-8 bytes. Without a byte-length + // aware PAX-extension check, TarOutputStream silently truncates this to a corrupted + // 100-byte name instead of promoting it to a PAX extended header. + final var name = "中".repeat(90); + final var content = "content".getBytes(StandardCharsets.UTF_8); + final var header = TarHeader.createHeader(name, content.length, MOD_TIME, false, 0644); + + final var entry = roundTrip(header, content); + + assertThat(entry.header().name()).isEqualTo(name); + assertThat(entry.content()).isEqualTo(content); + } + + @Test + void shouldRoundTripSymlinkTargetUnder100CharsButOver100Utf8Bytes() throws IOException { + final var target = "中".repeat(90); + final var header = TarHeader.createSymlinkHeader("link.txt", target, MOD_TIME, 0777); + + final var entry = roundTrip(header, new byte[0]); + + assertThat(entry.header().linkName()).isEqualTo(target); + } + + @Test + void shouldRoundTripNonAsciiName() throws IOException { + final var name = "café-ü.txt"; + final var content = "content".getBytes(StandardCharsets.UTF_8); + final var header = TarHeader.createHeader(name, content.length, MOD_TIME, false, 0644); + + final var entry = roundTrip(header, content); + + assertThat(entry.header().name()).isEqualTo(name); + assertThat(entry.content()).isEqualTo(content); + } + + @Test + void shouldRoundTripOwnership() throws IOException { + final var header = TarHeader.createHeader("owned.txt", 0, MOD_TIME, false, 0644, 1001, 1002, "alice", "staff"); + + final var entry = roundTrip(header, new byte[0]); + + assertThat(entry.header().uid()).isEqualTo(1001); + assertThat(entry.header().gid()).isEqualTo(1002); + assertThat(entry.header().userName()).isEqualTo("alice"); + assertThat(entry.header().groupName()).isEqualTo("staff"); + } + + @Test + void shouldRoundTripEntryWithBothLongNameAndLongLinkTarget() throws IOException { + final var longName = "a".repeat(150) + ".txt"; + final var longTarget = "b".repeat(150) + ".txt"; + final var header = TarHeader.createSymlinkHeader(longName, longTarget, MOD_TIME, 0777); + + final var entry = roundTrip(header, new byte[0]); + + assertThat(entry.header().name()).isEqualTo(longName); + assertThat(entry.header().linkName()).isEqualTo(longTarget); + } + + @Test + void shouldRoundTripMultipleEntriesMixingPlainAndPaxNames() throws IOException { + final var longName = "a".repeat(150) + ".txt"; + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + tar.putNextEntry(new TarEntry(TarHeader.createHeader("plain.txt", 5, MOD_TIME, false, 0644))); + tar.write("first".getBytes(StandardCharsets.UTF_8)); + tar.putNextEntry(new TarEntry(TarHeader.createHeader(longName, 6, MOD_TIME, false, 0644))); + tar.write("second".getBytes(StandardCharsets.UTF_8)); + tar.putNextEntry(new TarEntry(TarHeader.createHeader("plain2.txt", 5, MOD_TIME, false, 0644))); + tar.write("third".getBytes(StandardCharsets.UTF_8)); + } + + try (final var tar = new TarInputStream(new ByteArrayInputStream(archive.toByteArray()))) { + final var first = tar.getNextEntry(); + assertThat(first.header().name()).isEqualTo("plain.txt"); + assertThat(new String(tar.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("first"); + + final var second = tar.getNextEntry(); + assertThat(second.header().name()).isEqualTo(longName); + assertThat(new String(tar.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("second"); + + final var third = tar.getNextEntry(); + assertThat(third.header().name()).isEqualTo("plain2.txt"); + assertThat(new String(tar.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("third"); + + assertThat(tar.getNextEntry()).isNull(); + } + } + + @Test + void shouldProducePaxArchiveReadableByRealTar(@TempDir final Path tempDir) throws IOException, InterruptedException { + // a single path component over 100 bytes with no '/', forcing our writer to emit + // a PAX extended header rather than a ustar prefix split + final var longName = "a".repeat(150) + ".txt"; + final var content = "written by our TarOutputStream".getBytes(StandardCharsets.UTF_8); + + final var archivePath = tempDir.resolve("ours.tar"); + try (final var tar = new TarOutputStream(Files.newOutputStream(archivePath))) { + tar.putNextEntry(new TarEntry(TarHeader.createHeader(longName, content.length, MOD_TIME, false, 0644))); + tar.write(content); + } + + final var extractDir = tempDir.resolve("extracted"); + Files.createDirectories(extractDir); + final var result = new ProcessBuilder("tar", "-xf", archivePath.toString(), "-C", extractDir.toString()) + .redirectErrorStream(true) + .start(); + assertThat(result.waitFor()).isEqualTo(0); + + assertThat(Files.readString(extractDir.resolve(longName))).isEqualTo(new String(content, StandardCharsets.UTF_8)); + } + + private static RoundTrippedEntry roundTrip(final TarHeader header, final byte[] content) throws IOException { + final var archive = new ByteArrayOutputStream(); + try (final var tar = new TarOutputStream(archive)) { + tar.putNextEntry(new TarEntry(header)); + tar.write(content); + } + + try (final var tar = new TarInputStream(new ByteArrayInputStream(archive.toByteArray()))) { + final var entry = tar.getNextEntry(); + assertThat(entry).isNotNull(); + final var readContent = tar.readAllBytes(); + assertThat(tar.getNextEntry()).isNull(); + return new RoundTrippedEntry(entry.header(), readContent); + } + } + + private record RoundTrippedEntry(TarHeader header, byte[] content) { + } +}