ArchiveEntry.java
package xyz.apollosoftware.bibliothiki.compression;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
import static xyz.apollosoftware.bibliothiki.compression.CompressionSecurityException.SecurityViolation.*;
/**
* An entry found in an archive.
*
* @param name The name of the entry, relative to the root of the archive.
* @param type The type of entry (see {@link Type}).
* @param permissions The permissions for the entry (if available).
* @param size The uncompressed size of the entry (if available).
*/
public record ArchiveEntry(@NonNull String name, @NonNull Type type, @Nullable PermissionSet permissions, @Nullable Long size) {
/**
* The builder for {@link ArchiveEntry}.
*/
public static final class Builder {
private String name;
private Type type;
private PermissionSet permissions;
private Long size;
private Builder() {
}
/**
* Set the name (qualified file path) 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 {@link Type} of the entry.
*
* @param type The type of the entry.
* @return The same {@link Builder}.
*/
@NonNull
public Builder type(@NonNull Type type) {
this.type = type;
return this;
}
/**
* Set the UNIX permissions of the entry.
*
* @param permissions The permissions of the entry.
* @return The same {@link Builder}.
*/
@NonNull
public Builder permissions(@Nullable PermissionSet permissions) {
this.permissions = permissions;
return this;
}
/**
* Set the file size, in bytes, of the entry.
*
* @param size The size of the entry.
* @return The same {@link Builder}.
*/
@NonNull
public Builder size(@Nullable Long size) {
this.size = size;
return this;
}
/**
* Build the {@link ArchiveEntry}.
*
* <p>
* If any of the fields were invalid, they will fail at this point as
* this is when the {@link ArchiveEntry} constructor is executed.
*
* @return The built {@link ArchiveEntry}.
*/
@NonNull
public ArchiveEntry build() {
return new ArchiveEntry(this.name, this.type, this.permissions, this.size);
}
}
/**
* Create a builder for an {@link ArchiveEntry}.
*
* @return The {@link Builder}.
*/
@NonNull
public static Builder builder() {
return new Builder();
}
/**
* Permission flags for a single target (e.g., user, group or world).
*
* <p>
* In a UNIX file permissions value, this is represented by a single octal
* digit.
*
* @param read Whether read permission is granted.
* @param write Whether write permission is granted.
* @param execute Whether execute permission is granted.
* @see PermissionSet
*/
public record Permission(boolean read, boolean write, boolean execute) {
/**
* The read permission bit mask (UNIX {@code 0b100}).
*/
public static final int READ_MASK = 0b100;
/**
* The write permission bit mask (UNIX {@code 0b010}).
*/
public static final int WRITE_MASK = 0b010;
/**
* The execute permission bit mask (UNIX {@code 0b001}).
*/
public static final int EXECUTE_MASK = 0b001;
/**
* Decode the permission from the numeric value.
*
* <p>
* This method assumes that any necessary shifts have already been
* performed (i.e., the only bits that should be set are the lower three
* octets).
*
* @param value The value to decode.
* @return The decoded value.
*/
@NonNull
public static Permission decode(int value) {
if ((value & 0b111) != value) {
throw new IllegalArgumentException("Invalid permission bits: %x".formatted(value));
}
return new Permission(
(value & READ_MASK) != 0,
(value & WRITE_MASK) != 0,
(value & EXECUTE_MASK) != 0
);
}
@Override
@NonNull
public String toString() {
return (this.read() ? "r" : "-") +
(this.write() ? "w" : "-") +
(this.execute() ? "x" : "-");
}
}
/**
* A set of permissions.
*
* <p>
* This covers the UNIX permissions values for user, group and world (other)
* permissions. Each permission within the set is represented by a
* {@link Permission} value.
*
* @param user The user (owner) permissions.
* @param group The group (owner) permissions.
* @param world The world (non-owner) permissions.
*/
public record PermissionSet(@NonNull Permission user, @NonNull Permission group, @NonNull Permission world) {
/**
* Decode the permission set from the numeric value.
*
* <p>
* This method assumes there are three octal digits starting in the
* least significant bit position - in the order user, group then world.
* (That is, the world permission digit is expected to be the least
* significant three octets).
*
* @param mode The mode value to decode.
* @return The decoded {@link PermissionSet}.
*/
@NonNull
public static PermissionSet decode(long mode) {
return new PermissionSet(
Permission.decode((int) ((mode >> 6) & 0b111)),
Permission.decode((int) ((mode >> 3) & 0b111)),
Permission.decode((int) (mode & 0b111))
);
}
}
/**
* The type of {@link ArchiveEntry}.
*/
public enum Type {
/**
* An ordinary file.
*/
FILE,
/**
* A directory (folder).
*/
DIRECTORY,
/**
* Hard link to another file (from elsewhere in the archive).
*/
HARD_LINK,
/**
* Soft (symbolic) link to another file.
*/
SOFT_LINK
}
/**
* Construct an entry found in an archive.
*
* <p>
* This constructor also normalizes the path and ensures it is not an
* absolute path.
*
* @param name The name of the archive entry.
* @param type The type of the archive entry.
* @param permissions The permissions (if available) of the archive entry.
* @param size The size (if available) of the archive entry.
* @see Type
* @see Builder
* @throws CompressionSecurityException If the name denotes an absolute file
* path.
*/
public ArchiveEntry {
Objects.requireNonNull(name, "The name of the archive entry must not be null");
Objects.requireNonNull(type, "The type of the archive entry must not be null");
// Ensure the name is not blank.
if (name.isBlank()) {
throw new CompressionSecurityException(BLANK_FILENAME);
}
// Normalize the path (removes redundant path components).
final var path = Path.of(name).normalize();
if (path.isAbsolute()) {
throw new CompressionSecurityException(FORBIDDEN_ABSOLUTE_PATH);
}
// Use the interpreted path.
name = path.toString();
if (type == Type.DIRECTORY && !name.endsWith("/")) {
// If the path is a directory, ensure it ends with a trailing slash.
name += "/";
} else if (name.endsWith("/")) {
// Otherwise, ensure it does not end with a trailing slash.
name = name.substring(0, name.lastIndexOf('/'));
}
}
/**
* Resolve a reference to a file, relative to the given {@code parent}.
*
* <p>
* If {@code includeRoot} is false, the first path component of every entry
* inside the archive will be ignored (this allows an archived directory to
* be extracted directly into the given {@code parent}).
*
* @param parent The parent directory to resolve paths relative to.
* @param includeRoot Whether to include the first path component for each
* component in the archive.
* @return The resolved file.
* @throws IOException If file system queries fail.
*/
@NonNull
public File resolveFile(@NonNull File parent, boolean includeRoot) throws IOException {
final var canonicalParent = parent.getCanonicalFile();
var normalizedPath = Path.of(this.name);
// If the root directory should not be included, relativize the path to
// that directory.
if (!includeRoot) {
final var topLevelDirectory = normalizedPath.subpath(0, 1);
normalizedPath = normalizedPath.relativize(topLevelDirectory);
}
// Canonicalize the path and then return it.
final var destinationFile = new File(canonicalParent, normalizedPath.toString()).getCanonicalFile();
if (!destinationFile.toPath().startsWith(canonicalParent.toPath())) {
throw new CompressionSecurityException(PATH_OUTSIDE_PARENT);
}
return destinationFile;
}
}