// Near Infinity - An Infinity Engine Browser and Editor
// Copyright (C) 2001 - 2005 Jon Olav Hauglid
// See LICENSE.txt for license information
package org.infinity.util.io.zip;
import static org.infinity.util.io.zip.ZipUtils.toRegexPattern;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.channels.FileChannel.MapMode;
import java.nio.file.AccessMode;
import java.nio.file.ClosedFileSystemException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileStore;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemException;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.NotDirectoryException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.ReadOnlyFileSystemException;
import java.nio.file.StandardOpenOption;
import java.nio.file.WatchService;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.UserPrincipalLookupService;
import java.nio.file.spi.FileSystemProvider;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.regex.Pattern;
import org.infinity.util.io.ByteBufferInputStream;
/**
* FileSystem implementation for DLC archives in zip format inspired by
* Oracles example code for virtual filesystems.
*
* Provides methods for read-only operations on zip archives created with the
* "store" compression method.
*
* Supported filesystem properties:
* - encoding: Specifies the filename encoding (Default: CP437)
*/
public class DlcFileSystem extends FileSystem
{
private static final Set<String> supportedFileAttributeViews =
Collections.unmodifiableSet(new HashSet<String>(
Arrays.asList(DlcFileAttributeView.VIEW_BASIC, DlcFileAttributeView.VIEW_ZIP)));
private static final String GLOB_SYNTAX = "glob";
private static final String REGEX_SYNTAX = "regex";
// the outstanding input streams that need to be closed
private final Set<InputStream> streams = Collections.synchronizedSet(new HashSet<InputStream>());
// configurable by env map
private final String nameEncoding; // default encoding for name/comment
// guarantees read/write access without having concurrency issues
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final DlcFileSystemProvider provider;
private final DlcPath defaultDir;
private final boolean readOnly;
private final Path dfpath;
private final ZipCoder zc;
private final FileChannel ch;
private final ZipNode root;
private volatile boolean isOpen = true;
DlcFileSystem(DlcFileSystemProvider provider, Path dfpath, Map<String, ?> env) throws IOException
{
// configurable env setup
if (env != null) {
this.nameEncoding = env.containsKey("encoding") ? (String) env.get("encoding") : "CP437";
} else {
this.nameEncoding = "CP437";
}
this.readOnly = true;
this.provider = provider;
this.dfpath = dfpath;
if (Files.notExists(this.dfpath)) {
throw new FileSystemNotFoundException(this.dfpath.toString());
}
this.dfpath.getFileSystem().provider().checkAccess(this.dfpath, AccessMode.READ);
this.zc = ZipCoder.get(nameEncoding);
this.defaultDir = new DlcPath(this, getBytes("/"));
this.ch = FileChannel.open(this.dfpath, StandardOpenOption.READ);
this.root = ZipNode.createRoot(ch);
}
@Override
public FileSystemProvider provider()
{
return provider;
}
@Override
public void close() throws IOException
{
if (!isOpen) {
return;
}
isOpen = false; // set closed
if (!streams.isEmpty()) { // unlock and close all remaining streams
Set<InputStream> copy = new HashSet<>(streams);
for (InputStream is : copy) {
is.close();
}
}
ch.close(); // close the ch just in case no update
provider.removeFileSystem(dfpath, this);
}
@Override
public boolean isOpen()
{
return isOpen;
}
@Override
public boolean isReadOnly()
{
return readOnly;
}
@Override
public String getSeparator()
{
return "/";
}
@Override
public Iterable<Path> getRootDirectories()
{
ArrayList<Path> pathArr = new ArrayList<>();
pathArr.add(new DlcPath(this, new byte[] { '/' }));
return pathArr;
}
@Override
public Iterable<FileStore> getFileStores()
{
ArrayList<FileStore> list = new ArrayList<>(1);
list.add(new DlcFileStore(new DlcPath(this, new byte[]{ '/' })));
return list;
}
@Override
public Set<String> supportedFileAttributeViews()
{
return supportedFileAttributeViews;
}
@Override
public Path getPath(String first, String... more)
{
String path;
if (more.length == 0) {
path = first;
} else {
StringBuilder sb = new StringBuilder();
sb.append(first);
for (String segment : more) {
if (segment.length() > 0) {
if (sb.length() > 0) {
sb.append('/');
}
sb.append(segment);
}
}
path = sb.toString();
}
return new DlcPath(this, getBytes(path));
}
@Override
public PathMatcher getPathMatcher(String syntaxAndPattern)
{
int pos = syntaxAndPattern.indexOf(':');
if (pos <= 0 || pos == syntaxAndPattern.length()) {
throw new IllegalArgumentException();
}
String syntax = syntaxAndPattern.substring(0, pos);
String input = syntaxAndPattern.substring(pos + 1);
String expr;
if (syntax.equals(GLOB_SYNTAX)) {
expr = toRegexPattern(input);
} else if (syntax.equals(REGEX_SYNTAX)) {
expr = input;
} else {
throw new UnsupportedOperationException("Syntax '" + syntax + "' not recognized");
}
// return matcher
final Pattern pattern = Pattern.compile(expr);
return new PathMatcher() {
@Override
public boolean matches(Path path)
{
return pattern.matcher(path.toString()).matches();
}
};
}
@Override
public UserPrincipalLookupService getUserPrincipalLookupService()
{
throw new UnsupportedOperationException();
}
@Override
public WatchService newWatchService() throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public String toString()
{
return dfpath.toString();
}
@Override
protected void finalize() throws IOException
{
close();
}
Path getDlcFile()
{
return dfpath;
}
DlcPath getDefaultDir()
{
return defaultDir;
}
FileStore getFileStore(DlcPath path)
{
return new DlcFileStore(path);
}
DlcFileAttributes getFileAttributes(byte[] path) throws IOException
{
ZipNode folder = null;
beginRead();
try {
ensureOpen();
folder = root.getNode(path);
} finally {
endRead();
}
if (folder != null) {
return new DlcFileAttributes(folder);
} else {
return null;
}
}
boolean exists(byte[] path) throws IOException
{
beginRead();
try {
ensureOpen();
return (root.getNode(path) != null);
} finally {
endRead();
}
}
boolean isDirectory(byte[] path) throws IOException
{
beginRead();
try {
ZipNode folder = root.getNode(path);
return (folder != null && folder.isDirectory());
} finally {
endRead();
}
}
private DlcPath toDlcPath(byte[] path)
{
// make it absolute
byte[] p = new byte[path.length + 1];
p[0] = '/';
System.arraycopy(path, 0, p, 1, path.length);
return new DlcPath(this, p);
}
// returns the list of child paths of "path"
Iterator<Path> iteratorOf(byte[] path, DirectoryStream.Filter<? super Path> filter) throws IOException
{
beginRead(); // iteration of inodes needs exclusive lock
try {
ensureOpen();
ZipNode folder = root.getNode(path);
if (folder == null) {
throw new NotDirectoryException(getString(path));
}
List<ZipNode> children = folder.getChildren();
List<Path> pathList = new ArrayList<>();
for (final ZipNode child: children) {
pathList.add(toDlcPath(child.getPath()));
}
return Collections.unmodifiableList(pathList).iterator();
} finally {
endRead();
}
}
// Returns the byte array representation of the specified string
final byte[] getBytes(String name)
{
return zc.getBytes(name);
}
// Returns the string representation of the specified byte array using the current character encoding.
final String getString(byte[] name)
{
return zc.toString(name);
}
// Returns an input stream for reading the contents of the specified file entry.
InputStream newInputStream(byte[] path) throws IOException
{
beginRead();
try {
ensureOpen();
ZipNode folder = root.getNode(path);
if (folder == null) {
throw new NoSuchFileException(getString(path));
}
if (folder.isDirectory()) {
throw new FileSystemException(getString(path), "is a directory", null);
}
return getInputStream(folder);
} finally {
endRead();
}
}
SeekableByteChannel newByteChannel(byte[] path, Set<? extends OpenOption> options,
FileAttribute<?>... attrs) throws IOException
{
checkOptions(options);
if (options.contains(StandardOpenOption.WRITE) || options.contains(StandardOpenOption.APPEND)) {
checkWritable();
}
beginRead();
try {
ensureOpen();
ZipNode folder = root.getNode(path);
if (folder == null) {
throw new NoSuchFileException(getString(path));
}
if (folder.isDirectory()) {
throw new FileSystemException(getString(path), "is a directory", null);
}
final long basePos = folder.getCentral().getDataOffset(ch);
final long baseSize = folder.getCentral().sizeUncompressed;
final SeekableByteChannel sbc = Files.newByteChannel(getDlcFile(), options, attrs);
sbc.position(basePos);
return new SeekableByteChannel() {
@Override
public boolean isOpen()
{
return sbc.isOpen();
}
@Override
public void close() throws IOException
{
sbc.close();
}
@Override
public int write(ByteBuffer src) throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public SeekableByteChannel truncate(long size) throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public long size() throws IOException
{
return baseSize;
}
@Override
public int read(ByteBuffer dst) throws IOException
{
checkOpen();
if (sbc.position() >= basePos+baseSize) {
return -1;
}
ByteBuffer buffer = ByteBuffer.allocate(65536);
int remaining = (int)(baseSize - position());
remaining = Math.min(dst.remaining(), remaining);
int processed = 0;
while (remaining > 0) {
buffer.compact().position(0);
buffer.limit(Math.min(remaining, buffer.remaining()));
int nread = sbc.read(buffer);
if (nread < 0) {
break;
}
buffer.flip();
dst.put(buffer);
remaining -= nread;
processed += nread;
}
return processed;
}
@Override
public SeekableByteChannel position(long newPosition) throws IOException
{
checkOpen();
if (newPosition < 0) {
throw new IOException("Negative position");
}
sbc.position(basePos + newPosition);
return this;
}
@Override
public long position() throws IOException
{
checkOpen();
return sbc.position() - basePos;
}
private void checkOpen() throws IOException
{
if (sbc == null || !sbc.isOpen()) {
throw new IOException("Channel not open");
}
}
};
} finally {
endRead();
}
}
FileChannel newFileChannel(byte[] path, Set<? extends OpenOption> options, FileAttribute<?>... attrs)
throws IOException
{
checkOptions(options);
if (options.contains(StandardOpenOption.WRITE) || options.contains(StandardOpenOption.APPEND)) {
checkWritable();
}
beginRead();
try {
ensureOpen();
ZipNode folder = root.getNode(path);
if (folder == null) {
throw new NoSuchFileException(getString(path));
}
if (folder.isDirectory()) {
throw new FileSystemException(getString(path), "is a directory", null);
}
final long basePos = folder.getCentral().getDataOffset(ch);
final long baseSize = folder.getCentral().sizeUncompressed;
final FileChannel fch = FileChannel.open(getDlcFile(), options, attrs);
fch.position(basePos);
return new FileChannel() {
@Override
protected void implCloseChannel() throws IOException
{
fch.close();
}
@Override
public long write(ByteBuffer[] srcs, int offset, int length) throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public int write(ByteBuffer src, long position) throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public int write(ByteBuffer src) throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public FileLock tryLock(long position, long size, boolean shared) throws IOException
{
checkOpen();
if (position < 0) {
throw new IOException("Position is negative");
} else if (size < 0) {
throw new IOException("Size is negative");
}
return fch.tryLock(basePos + position, Math.min(size, baseSize - position), shared);
}
@Override
public FileChannel truncate(long size) throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public long transferTo(long position, long count, WritableByteChannel target) throws IOException
{
checkOpen();
return fch.transferTo(basePos + position, Math.min(count, baseSize - position), target);
}
@Override
public long transferFrom(ReadableByteChannel src, long position, long count) throws IOException
{
throw new UnsupportedOperationException();
}
@Override
public long size() throws IOException
{
checkOpen();
return baseSize;
}
@Override
public long read(ByteBuffer[] dsts, int offset, int length) throws IOException
{
checkOpen();
if (fch.position() >= basePos+baseSize) {
return -1L;
}
ByteBuffer buffer = ByteBuffer.allocate(65536);
long remaining = baseSize - position();
long processed = 0;
int idx = offset;
int maxIdx = Math.min(dsts.length - offset, idx + length);
while (idx < maxIdx) {
long toRead = Math.min(dsts[idx].remaining(), remaining);
while (toRead > 0) {
buffer.compact().position(0);
buffer.limit(Math.min((int)toRead, buffer.remaining()));
int nread = fch.read(buffer);
if (nread < 0) {
idx = maxIdx;
break;
}
buffer.flip();
dsts[idx].put(buffer);
toRead -= nread;
remaining -= nread;
processed += nread;
}
idx++;
}
return processed;
}
@Override
public int read(ByteBuffer dst, long position) throws IOException
{
checkOpen();
if (position >= basePos+baseSize) {
return -1;
} else if (position < 0) {
throw new IOException("Negative position");
}
long curPosition = fch.position();
try {
fch.position(basePos + position);
long retVal = read(new ByteBuffer[]{dst}, 0, 1);
return (int)retVal;
} finally {
fch.position(curPosition);
}
}
@Override
public int read(ByteBuffer dst) throws IOException
{
return (int)read(new ByteBuffer[]{dst}, 0, 1);
}
@Override
public FileChannel position(long newPosition) throws IOException
{
checkOpen();
if (newPosition < 0) {
throw new IOException("Negative position");
}
fch.position(basePos + newPosition);
return this;
}
@Override
public long position() throws IOException
{
checkOpen();
return fch.position() - basePos;
}
@Override
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException
{
checkOpen();
if (position < 0) {
throw new IOException("Negative position");
} else if (size < 0) {
throw new IOException("Negative size");
} else if (position > baseSize) {
throw new IOException("Position exceeds file size");
}
long absPos = basePos + position;
long absSize = Math.min(size, baseSize - position);
return fch.map(mode, absPos, absSize);
}
@Override
public FileLock lock(long position, long size, boolean shared) throws IOException
{
checkOpen();
if (position < 0) {
throw new IOException("Position is negative");
} else if (size < 0) {
throw new IOException("Size is negative");
}
return fch.lock(basePos + position, Math.min(size, baseSize - position), shared);
}
@Override
public void force(boolean metaData) throws IOException
{
checkOpen();
// do nothing
}
private void checkOpen() throws IOException
{
if (fch == null || !fch.isOpen()) {
throw new IOException("Channel not open");
}
}
};
} finally {
endRead();
}
}
private void checkWritable() throws IOException
{
throw new ReadOnlyFileSystemException();
}
private void checkOptions(Set<? extends OpenOption> options)
{
// check for options of null type and option is an intance of
// StandardOpenOption
for (OpenOption option : options) {
if (option == null) {
throw new NullPointerException();
}
if (!(option instanceof StandardOpenOption)) {
throw new IllegalArgumentException();
}
}
}
private final void beginRead()
{
rwlock.readLock().lock();
}
private final void endRead()
{
rwlock.readLock().unlock();
}
private void ensureOpen() throws IOException
{
if (!isOpen) {
throw new ClosedFileSystemException();
}
}
private InputStream getInputStream(ZipNode folder) throws IOException
{
InputStream is = null;
if (folder == null) {
throw new NullPointerException();
}
if (folder.isDirectory()) {
throw new FileSystemException(folder.toString(), "is a directory", null);
}
beginRead();
try {
ensureOpen();
long offset = folder.getCentral().getDataOffset(ch);
long size = folder.getCentral().sizeCompressed;
if (offset < 0 || size < 0) {
throw new IOException("Data offset out of range");
}
is = new ByteBufferInputStream(ch.map(MapMode.READ_ONLY, offset, size));
streams.add(is);
return is;
} finally {
endRead();
}
}
}