TapeArchiveEntryHeader.java

package xyz.apollosoftware.bibliothiki.compression.formats.tar;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import xyz.apollosoftware.bibliothiki.compression.CompressionException;
import xyz.apollosoftware.bibliothiki.compression.CompressionSecurityException;

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;

import static xyz.apollosoftware.bibliothiki.compression.CompressionSecurityException.SecurityViolation.BLANK_FILENAME;

/**
 * The header for a Tape Archive file.
 *
 * @param name The path and name of the file (max. 100 chars).
 * @param mode The UNIX permission mode of the file.
 * @param owner The UID of the owner of the file.
 * @param group The UID of the group that owns the file.
 * @param fileSize The file size, in bytes.
 * @param lastModified The timestamp that the file was last modified.
 * @param checksum The checksum for the header record.
 * @param type The file type.
 * @param linkedFile The path that the file links to, if the link indicator
 *                   indicates that the file is a link, rather than a normal
 *                   file. If the link indicator is such that the file is not
 *                   a link, this is null.
 * @param unixStandardHeader The UNIX Standard header data (if present).
 */
public record TapeArchiveEntryHeader(
    @NonNull String name,
    long mode,
    long owner,
    long group,
    long fileSize,
    Instant lastModified,
    long checksum,
    @NonNull Type type,
    @Nullable String linkedFile,
    @Nullable UnixStandardEntryHeader unixStandardHeader
) {

    /**
     * The builder for {@link TapeArchiveEntryHeader}.
     */
    public static final class Builder {

        private String name;
        private long mode;
        private long owner;
        private long group;
        private long fileSize;
        private Instant lastModified;
        private long checksum;
        private Type type;
        private String linkedFile;
        private UnixStandardEntryHeader unixStandardHeader;

        private Builder() {
        }

        /**
         * Set the name of the entry.
         *
         * @param name The name of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder name(@NonNull String name) {
            this.name = name;
            return this;
        }


        /**
         * Set the mode of the entry.
         *
         * @param mode The mode of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder mode(long mode) {
            this.mode = mode;
            return this;
        }

        /**
         * Set the owner of the entry.
         *
         * @param owner The owner of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder owner(long owner) {
            this.owner = owner;
            return this;
        }

        /**
         * Set the group of the entry.
         *
         * @param group The group of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder group(long group) {
            this.group = group;
            return this;
        }

        /**
         * Set the fileSize of the entry.
         *
         * @param fileSize The fileSize of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder fileSize(long fileSize) {
            this.fileSize = fileSize;
            return this;
        }

        /**
         * Set the last modification timestamp of the entry.
         *
         * @param lastModified The last modification timestamp of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder lastModified(Instant lastModified) {
            this.lastModified = lastModified;
            return this;
        }

        /**
         * Set the checksum of the entry.
         *
         * @param checksum The checksum of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder checksum(long checksum) {
            this.checksum = checksum;
            return this;
        }

        /**
         * Set the {@link Type} of the entry.
         *
         * @param type The type of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder type(Type type) {
            this.type = type;
            return this;
        }

        /**
         * Set the linked file of the entry (relevant only to types where
         * {@link Type#isLink()} is true).
         *
         * @param linkedFile The linked file of the entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder linkedFile(String linkedFile) {
            this.linkedFile = linkedFile;
            return this;
        }

        /**
         * Set the (UStar) UNIX Standard header information of the entry.
         *
         * @param unixStandardHeader The UNIX Standard header information of the
         *                           entry.
         * @return The same {@link Builder}.
         */
        @NonNull
        public Builder unixStandardHeader(UnixStandardEntryHeader unixStandardHeader) {
            this.unixStandardHeader = unixStandardHeader;
            return this;
        }

        /**
         * Build the {@link TapeArchiveEntryHeader}.
         *
         * <p>
         * If any of the fields were invalid, they will fail at this point as
         * this is when the {@link TapeArchiveEntryHeader} constructor is
         * executed.
         *
         * @return The built {@link TapeArchiveEntryHeader}.
         */
        @NonNull
        public TapeArchiveEntryHeader build() {
            return new TapeArchiveEntryHeader(
                this.name,
                this.mode,
                this.owner,
                this.group,
                this.fileSize,
                this.lastModified,
                this.checksum,
                this.type,
                this.linkedFile,
                this.unixStandardHeader
            );
        }

    }

    /**
     * Create a builder for a {@link TapeArchiveEntryHeader}.
     *
     * <p>
     * This can be useful when wrapping code for creating archives, as well as
     * for reading parameters from a tar file.
     *
     * @return The {@link Builder}.
     */
    @NonNull
    public static Builder builder() {
        return new Builder();
    }

    /**
     * The length, in bytes, of the tape archive header.
     */
    public static final int LENGTH_BYTES = 512;

    /**
     * The length, in bytes, of the file path and name field.
     */
    public static final int FILENAME_LENGTH_BYTES = 100;

    /**
     * The length, in bytes, of the file path and name prefix.
     */
    public static final int FILENAME_PREFIX_LENGTH_BYTES = 155;

    /**
     * The maximum name that can be stored in a {@link TapeArchiveEntryHeader},
     * including the {@link UnixStandardEntryHeader}.
     *
     * <p>
     * (Longer names may be supported but will be implemented with the
     * {@link TapeArchivePaxAttributes}).
     */
    public static final int FULL_FILENAME_LENGTH_BYTES = FILENAME_LENGTH_BYTES + FILENAME_PREFIX_LENGTH_BYTES;

    /**
     * Construct a header for a tar archive.
     *
     * <p>
     * This record represents either an entry that is being added to an archive
     * or which has been parsed from an existing archive.
     *
     * @param name The name of the entry.
     * @param mode The UNIX permission mode of the entry.
     * @param owner The owner UID of the entry.
     * @param group The group UID of the entry.
     * @param fileSize The size of the entry, in bytes.
     * @param lastModified The last modification timestamp of the entry.
     * @param checksum The checksum of the entry header.
     * @param type The type of entry.
     * @param linkedFile The linked file (if applicable).
     * @param unixStandardHeader The UNIX Standard tar (UStar) header data (if
     *                           applicable).
     */
    public TapeArchiveEntryHeader {
        Objects.requireNonNull(name, "The entry name must not be null");

        // Ensure the name is not blank.
        if (name.isBlank()) {
            throw new CompressionSecurityException(BLANK_FILENAME);
        }

        if (name.length() > FILENAME_LENGTH_BYTES) {
            throw new IllegalArgumentException("The entry name length field (%d) exceeded the maximum length (%d)".formatted(name.length(), FILENAME_LENGTH_BYTES));
        }
    }

    /**
     * The type of tape archive entry.
     */
    public enum Type {
        /**
         * A normal file.
         *
         * <p>
         * In some cases, ASCII NUL (or '\0') is supported. Thus,
         * {@link #getByValue(byte)} will return this value for either '0'
         * (ASCII 0x30) or '\0' (ASCII NUL).
         */
        NORMAL((byte) '0'),

        /**
         * A hard link to another file in the archive.
         */
        HARD_LINK((byte) '1'),

        /**
         * A soft (symbolic) link to another file that may be in the archive
         * or may exist elsewhere on the system.
         */
        SOFT_LINK((byte) '2'),

        /**
         * A UNIX character (device) special file.
         */
        CHARACTER_SPECIAL((byte) '3'),

        /**
         * A UNIX block (device) special file.
         */
        BLOCK_SPECIAL((byte) '4'),

        /**
         * A directory (folder).
         */
        DIRECTORY((byte) '5'),

        /**
         * A FIFO (named pipe).
         */
        FIFO((byte) '6'),

        /**
         * A contiguous file.
         *
         * <p>
         * This type is generally considered obsolete or reserved.
         */
        CONTIGUOUS_FILE((byte) '7'),

        /**
         * A "global extended header with metadata" (POSIX.1-2001).
         */
        GLOBAL_EXTENDED_HEADER((byte) 'g'),

        /**
         * An extended header with metadata for the next file in the
         * archive (POSIX.1-2001).
         */
        EXTENDED_HEADER_WITH_METADATA((byte) 'x');

        private final byte value;

        Type(byte value) {
            this.value = value;
        }

        /**
         * The byte value used in the UNIX Standard entry header for the
         * type of file.
         *
         * @return The byte value for the file type.
         */
        public byte getValue() {
            return this.value;
        }

        /**
         * Resolve the type by its byte value.
         *
         * @param value The byte value to resolve.
         * @return The resolved {@link Type}, or null if one couldn't be
         *         resolved.
         */
        @Nullable
        public static Type getByValue(byte value) {
            if (value == '\0') return Type.NORMAL;

            return Arrays.stream(values())
                    .filter(entry -> entry.getValue() == value)
                    .findFirst()
                    .orElse(null);
        }

        /**
         * Whether the entry type is a link (i.e., {@link #HARD_LINK} or
         * {@link #SOFT_LINK}).
         *
         * @return True if, and only if, the entry type is a link type.
         */
        public boolean isLink() {
            return this == HARD_LINK || this == SOFT_LINK;
        }

        /**
         * Whether the entry type is a special/device entry (i.e.,
         * {@link #CHARACTER_SPECIAL} or {@link #BLOCK_SPECIAL}).
         *
         * @return True if, and only if, the entry type is a device type.
         */
        public boolean isDevice() {
            return this == CHARACTER_SPECIAL || this == BLOCK_SPECIAL;
        }
    }

    /**
     * The UStar (UNIX Standard tar) format extends the nominal tar header to
     * include additional fields.
     *
     * <p>
     * These fields appear within the 255 bytes of the header record that
     * would otherwise be NULL (when padding the header to 512 bytes).
     *
     * @param version The UStar version number (should be "00").
     * @param userName The name of the user that owns the file.
     * @param groupName The name of the group that owns the file.
     * @param deviceMajorNumber The major version number of the device (for
     *                          special device files).
     * @param deviceMinorNumber The minor version number of the device (for
     *                          special device files).
     * @param filenamePrefix The prefix to prepend to the filename.
     */
    public record UnixStandardEntryHeader(
        short version,
        @Nullable String userName,
        @Nullable String groupName,
        long deviceMajorNumber,
        long deviceMinorNumber,
        @Nullable String filenamePrefix
    ) {

        /**
         * The builder for {@link UnixStandardEntryHeader}.
         */
        public static final class Builder {

            private short version;
            private String userName;
            private String groupName;
            private long deviceMajorNumber;
            private long deviceMinorNumber;
            private String filenamePrefix;

            private Builder() {
            }

            /**
             * Set the version of the entry.
             *
             * @param version The version of the entry.
             * @return The same {@link Builder}.
             */
            @NonNull
            public Builder version(short version) {
                this.version = version;
                return this;
            }

            /**
             * Set the owner username of the entry.
             *
             * @param userName The username of the entry.
             * @return The same {@link Builder}.
             */
            @NonNull
            public Builder userName(String userName) {
                this.userName = userName;
                return this;
            }

            /**
             * Set the owner group name of the entry.
             *
             * @param groupName The group name of the entry.
             * @return The same {@link Builder}.
             */
            @NonNull
            public Builder groupName(String groupName) {
                this.groupName = groupName;
                return this;
            }

            /**
             * Set the device major version number of the entry. (This is
             * relevant only to types where {@link Type#isDevice()} is true).
             *
             * @param deviceMajorNumber The device major version number of the
             *                          entry.
             * @return The same {@link Builder}.
             */
            @NonNull
            public Builder deviceMajorNumber(long deviceMajorNumber) {
                this.deviceMajorNumber = deviceMajorNumber;
                return this;
            }

            /**
             * Set the device minor version number of the entry. (This is
             * relevant only to types where {@link Type#isDevice()} is true).
             *
             * @param deviceMinorNumber The device minor version number of the
             *                          entry.
             * @return The same {@link Builder}.
             */
            @NonNull
            public Builder deviceMinorNumber(long deviceMinorNumber) {
                this.deviceMinorNumber = deviceMinorNumber;
                return this;
            }

            /**
             * Set the filename prefix of the entry.
             *
             * <p>
             * This is the additional 155 characters that can be prepended to
             * the {@link TapeArchiveEntryHeader#name()} to produce a full 255
             * character UNIX filename.
             *
             * @param filenamePrefix The filename prefix of the entry.
             * @return The same {@link Builder}.
             */
            @NonNull
            public Builder filenamePrefix(String filenamePrefix) {
                this.filenamePrefix = filenamePrefix;
                return this;
            }

            /**
             * Build the {@link UnixStandardEntryHeader}.
             *
             * <p>
             * If any of the fields were invalid, they will fail at this point
             * as this is when the {@link UnixStandardEntryHeader} constructor
             * is executed.
             *
             * @return The built {@link UnixStandardEntryHeader}.
             */
            @NonNull
            public UnixStandardEntryHeader build() {
                return new UnixStandardEntryHeader(
                    version,
                    userName,
                    groupName,
                    deviceMajorNumber,
                    deviceMinorNumber,
                    filenamePrefix
                );
            }

        }

        /**
         * Construct a UNIX Standard (UStar) header for a tar archive header.
         *
         * @param version The version of the header.
         * @param userName The name of the user that owns the entry.
         * @param groupName The name of the group that owns the entry.
         * @param deviceMajorNumber The major version number (for device files).
         * @param deviceMinorNumber The minor version number (for device files).
         * @param filenamePrefix The prefix to prepend to the name.
         */
        public UnixStandardEntryHeader {
            if (filenamePrefix != null && filenamePrefix.length() > FILENAME_PREFIX_LENGTH_BYTES) {
                throw new IllegalArgumentException("The entry name prefix length field (%d) exceeded the maximum length (%d)".formatted(filenamePrefix.length(), FILENAME_LENGTH_BYTES));
            }
        }

        /**
         * The ASCII bytes for 'ustar' are used as the indicator for a
         * {@link UnixStandardEntryHeader}.
         */
        public static final String INDICATOR = "ustar";

        /**
         * Create a builder for a UStar header
         * ({@link UnixStandardEntryHeader}).
         *
         * @return The {@link Builder}.
         */
        @NonNull
        public static Builder builder() {
            return new Builder();
        }

    }

    /**
     * Returns whether the {@link UnixStandardEntryHeader} is present (i.e.,
     * whether the UStar fields are present).
     *
     * @return Whether the UStar header is present.
     */
    public boolean hasUnixStandardHeader() {
        return this.unixStandardHeader != null;
    }

    /**
     * Get the full name of the entry.
     *
     * <p>
     * If the entry {@link #hasUnixStandardHeader()} and
     * {@link UnixStandardEntryHeader#filenamePrefix()}, they are concatenated
     * to produce the full name.
     *
     * <p>
     * Otherwise, the {@link #name()} field is used directly.
     *
     * @return The full path and name of the entry.
     */
    @NonNull
    public String getFullName() {
        if (hasUnixStandardHeader()) {
            final var prefix = this.unixStandardHeader().filenamePrefix();
            if (prefix != null) {
                return prefix + this.name();
            }
        }

        return this.name();
    }

    /**
     * Decode a header from its binary representation.
     *
     * @param buffer A buffer pointing to the binary representation of a header.
     * @return The decoded header.
     */
    @NonNull
    public static TapeArchiveEntryHeader decode(@NonNull ByteBuffer buffer) {
        Objects.requireNonNull(buffer, "The buffer must not be null.");
        if (buffer.capacity() != LENGTH_BYTES) {
            throw new IllegalArgumentException("Invalid header length");
        }

        // Read the default header.
        final var headerBuilder = TapeArchiveEntryHeader.builder();
        final String name = readTerminatedString(buffer, FILENAME_LENGTH_BYTES);
        final String mode = readTerminatedString(buffer, 8, true);
        if (!mode.isEmpty()) {
            headerBuilder.mode(Long.parseLong(mode, 8));
        }

        final String owner = readTerminatedString(buffer, 8, true);
        if (!owner.isEmpty()) {
            headerBuilder.owner(Long.parseLong(owner, 8));
        }

        final String group = readTerminatedString(buffer, 8, true);
        if (!group.isEmpty()) {
            headerBuilder.group(Long.parseLong(group, 8));
        }

        final String fileSize = readTerminatedString(buffer, 12, true);

        // Some types (e.g., extended metadata) have empty size (not zero).
        if (!fileSize.isEmpty()) {
            final long parsedFileSize = Long.parseLong(fileSize, 8);

            // Historically, archives can contain files up to 8 GB as only the first
            // 11 octal digits are used. For simplicity, larger files are currently
            // not supported.
            if (parsedFileSize >> (11 * 3) != 0) {
                throw new CompressionException("Unsupported file size: %d".formatted(parsedFileSize));
            }

            headerBuilder.fileSize(parsedFileSize);
        }

        final String lastModified = readTerminatedString(buffer, 12, true);
        if (!lastModified.isEmpty()) {
            headerBuilder.lastModified(Instant.ofEpochSecond(Long.parseLong(lastModified, 8)));
        }

        final long checksum = buffer.getLong();

        final byte rawType = buffer.get();
        final Type type = Optional.ofNullable(Type.getByValue(rawType))
            .orElseThrow(() -> new CompressionException("Unsupported type: '%s'".formatted((char) rawType)));

        final String linkedFileName = type.isLink()
                ? readTerminatedString(buffer, FILENAME_LENGTH_BYTES, true)
                : null;

        // Check if there's an extended header by reading the next bytes after
        // the default header.
        final String extendedHeaderIndicator = readTerminatedString(buffer, 6);

        return headerBuilder
            .name(name)
            .checksum(checksum)
            .type(type)
            .linkedFile(linkedFileName)
            // Interpret the UNIX Standard (UStar) header if it's present.
            .unixStandardHeader(switch (extendedHeaderIndicator) {
                case UnixStandardEntryHeader.INDICATOR -> UnixStandardEntryHeader.builder()
                    .version(buffer.getShort())
                    .userName(readTerminatedString(buffer, 32))
                    .groupName(readTerminatedString(buffer, 32))
                    .deviceMajorNumber(buffer.getLong())
                    .deviceMinorNumber(buffer.getLong())
                    .filenamePrefix(readTerminatedString(buffer, FILENAME_PREFIX_LENGTH_BYTES))
                    .build();
                case "" -> null;
                default -> throw new IllegalArgumentException("Unsupported extended header: %s".formatted(extendedHeaderIndicator));
            })
            .build();
    }

    /**
     * Read up to {@code maxLength} bytes until a null terminator is reached
     * (or {@code maxLength} is reached) as a US-ASCII string.
     *
     * <p>
     * The returned string will never be null but may be empty.
     *
     * @param buffer The data to read.
     * @param maxLength The maximum number of bytes to read.
     * @return The read and decoded string.
     */
    @NonNull
    private static String readTerminatedString(@NonNull final ByteBuffer buffer, final int maxLength) {
        return readTerminatedString(buffer, maxLength, false);
    }

    /**
     * Read up to {@code maxLength} bytes until a null terminator is reached
     * (or {@code maxLength} is reached) as a US-ASCII string.
     *
     * <p>
     * The returned string will never be null but may be empty.
     *
     * <p>
     * If {@code spaceTerminator} is true, a trailing space will also be treated
     * as a terminator character.
     *
     * @param buffer The data to read.
     * @param maxLength The maximum number of bytes to read.
     * @param spaceTerminator Whether spaces should count as terminators.
     * @return The read and decoded string.
     */
    @NonNull
    private static String readTerminatedString(@NonNull final ByteBuffer buffer, final int maxLength, boolean spaceTerminator) {
        final int offset = buffer.arrayOffset() + buffer.position();

        int length = 0;
        for (int i = 0; i < maxLength; i++) {
            // Check whether the current character is a termination character.
            final byte c = buffer.array()[i + offset];
            if (c == '\0') break;

            // In some cases, space can be used as a terminator (e.g., for octal
            // values). It's hard to track down where the definition of this
            // comes from, but JTar and Apache Commons Compress both treat octal
            // values in this way.
            if (spaceTerminator && c == ' ') break;

            // Update the length according to that described by this offset.
            length = i + 1;
        }

        // Advance the buffer position and decode the identified offsets as
        // ASCII.
        buffer.position(buffer.position() + maxLength);
        return new String(buffer.array(), offset, length, StandardCharsets.US_ASCII);
    }

}