TapeArchivePaxAttributes.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 java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;

import static xyz.apollosoftware.bibliothiki.compression.formats.tar.TapeArchivePaxAttributes.ParsedAttribute.PAX_PATH;

/**
 * The extended attributes data as originally defined by the {@code pax} archive
 * utility and codified in POSIX.1-2001.
 *
 * @param path The full path for the entry.
 */
public record TapeArchivePaxAttributes(@Nullable String path) {

    /**
     * A raw PaxHeader attribute.
     *
     * @param keyword The keyword from the Pax specification.
     * @param value The value of the keyword.
     */
    record ParsedAttribute(String keyword, String value) {

        /**
         * The extended path property (replaces the path from the ordinary or
         * UStar headers).
         */
        public static final String PAX_PATH = "path";

    }

    /**
     * Decode the {@code pax} extended attributes from the given value.
     *
     * @param value The value to decode the attributes from.
     * @return The decoded attributes.
     */
    @NonNull
    public static TapeArchivePaxAttributes decode(@NonNull String value) {
        // Parse the raw attribute lines.
        final var rawAttributes = value.lines().map(line -> {
            final var parts = line.split(" ", 2);

            final long expectedLength;
            try {
                expectedLength = Long.parseLong(parts[0]);
            } catch (NumberFormatException ex) {
                throw new CompressionException("Failed to parse PAX attribute length: %s".formatted(parts[0]), ex);
            }

            // This unfortunately re-decodes the individual line (the
            // alternative is to parse line-by-line when passing to this decode
            // function).
            final var actualLength = line.getBytes(StandardCharsets.UTF_8).length + 1 /* newline */;
            if (expectedLength != actualLength) {
                throw new CompressionException("Extended PaxHeader length is invalid. Expected: %d, got: %d".formatted(expectedLength, actualLength));
            }

            final var entry = parts[1].split("=", 2);
            if (entry.length != 2) {
                throw new CompressionException("Invalid PaxAttribute (did not match the expected 'key=value' format - got missing or extra = occurrences)");
            }

            return new ParsedAttribute(entry[0], entry[1]);
        }).collect(Collectors.toUnmodifiableMap(ParsedAttribute::keyword, ParsedAttribute::value));

        // Gather supported attributes.
        return new TapeArchivePaxAttributes(rawAttributes.get(PAX_PATH));
    }

}