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) {
+ }
+}