diff --git a/projects/common/src/main/java/dan200/computercraft/shared/computer/core/ResourceMount.java b/projects/common/src/main/java/dan200/computercraft/shared/computer/core/ResourceMount.java index f39f1913c..fbdc39b1a 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/computer/core/ResourceMount.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/computer/core/ResourceMount.java @@ -29,8 +29,6 @@ import java.util.Map; public final class ResourceMount extends ArchiveMount { private static final Logger LOG = LoggerFactory.getLogger(ResourceMount.class); - private static final byte[] TEMP_BUFFER = new byte[8192]; - /** * Maintain a cache of currently loaded resource mounts. This cache is invalidated when currentManager changes. */ @@ -68,7 +66,11 @@ public final class ResourceMount extends ArchiveMount { if (!FileSystem.contains(subPath, file.getPath())) continue; // Some packs seem to include the parent? var localPath = FileSystem.toLocal(file.getPath(), subPath); - create(newRoot, localPath); + try { + getOrCreateChild(newRoot, localPath, this::createEntry); + } catch (ResourceLocationException e) { + LOG.warn("Cannot create resource location for {} ({})", localPath, e.getMessage()); + } hasAny = true; } @@ -83,52 +85,12 @@ public final class ResourceMount extends ArchiveMount { } } - private void create(FileEntry lastEntry, String path) { - var lastIndex = 0; - while (lastIndex < path.length()) { - var nextIndex = path.indexOf('/', lastIndex); - if (nextIndex < 0) nextIndex = path.length(); - - var part = path.substring(lastIndex, nextIndex); - if (lastEntry.children == null) lastEntry.children = new HashMap<>(); - - var nextEntry = lastEntry.children.get(part); - if (nextEntry == null) { - ResourceLocation childPath; - try { - childPath = new ResourceLocation(namespace, subPath + "/" + path); - } catch (ResourceLocationException e) { - LOG.warn("Cannot create resource location for {} ({})", part, e.getMessage()); - return; - } - lastEntry.children.put(part, nextEntry = new FileEntry(path, childPath)); - } - - lastEntry = nextEntry; - lastIndex = nextIndex + 1; - } + private FileEntry createEntry(String path) { + return new FileEntry(path, new ResourceLocation(namespace, subPath + "/" + path)); } @Override - public long getSize(FileEntry file) { - var resource = manager.getResource(file.identifier).orElse(null); - if (resource == null) return 0; - - try (var stream = resource.open()) { - int total = 0, read = 0; - do { - total += read; - read = stream.read(TEMP_BUFFER); - } while (read > 0); - - return total; - } catch (IOException e) { - return 0; - } - } - - @Override - public byte[] getContents(FileEntry file) throws IOException { + public byte[] getFileContents(FileEntry file) throws IOException { var resource = manager.getResource(file.identifier).orElse(null); if (resource == null) throw new FileOperationException(file.path, NO_SUCH_FILE); diff --git a/projects/core-api/src/main/java/dan200/computercraft/api/filesystem/FileAttributes.java b/projects/core-api/src/main/java/dan200/computercraft/api/filesystem/FileAttributes.java index 2d8fb7ecc..cf478ca4b 100644 --- a/projects/core-api/src/main/java/dan200/computercraft/api/filesystem/FileAttributes.java +++ b/projects/core-api/src/main/java/dan200/computercraft/api/filesystem/FileAttributes.java @@ -12,25 +12,30 @@ import java.time.Instant; /** * A simple version of {@link BasicFileAttributes}, which provides what information a {@link Mount} already exposes. * - * @param isDirectory Whether this filesystem entry is a directory. - * @param size The size of the file. + * @param isDirectory Whether this filesystem entry is a directory. + * @param size The size of the file. + * @param creationTime The time the file was created. + * @param lastModifiedTime The time the file was last modified. */ -public record FileAttributes(boolean isDirectory, long size) implements BasicFileAttributes { +public record FileAttributes( + boolean isDirectory, long size, FileTime creationTime, FileTime lastModifiedTime +) implements BasicFileAttributes { private static final FileTime EPOCH = FileTime.from(Instant.EPOCH); - @Override - public FileTime lastModifiedTime() { - return EPOCH; + /** + * Create a new {@link FileAttributes} instance with the {@linkplain #creationTime() creation time} and + * {@linkplain #lastModifiedTime() last modified time} set to the Unix epoch. + * + * @param isDirectory Whether the filesystem entry is a directory. + * @param size The size of the file. + */ + public FileAttributes(boolean isDirectory, long size) { + this(isDirectory, size, EPOCH, EPOCH); } @Override public FileTime lastAccessTime() { - return EPOCH; - } - - @Override - public FileTime creationTime() { - return EPOCH; + return lastModifiedTime(); } @Override diff --git a/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java b/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java index b41937be2..4e2f9b2ca 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java +++ b/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java @@ -17,7 +17,6 @@ import dan200.computercraft.core.filesystem.FileSystemException; import dan200.computercraft.core.metrics.Metrics; import javax.annotation.Nullable; -import java.nio.file.attribute.FileTime; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @@ -488,9 +487,9 @@ public class FSAPI implements ILuaAPI { try (var ignored = environment.time(Metrics.FS_OPS)) { var attributes = getFileSystem().getAttributes(path); Map result = new HashMap<>(); - result.put("modification", getFileTime(attributes.lastModifiedTime())); - result.put("modified", getFileTime(attributes.lastModifiedTime())); - result.put("created", getFileTime(attributes.creationTime())); + result.put("modification", attributes.lastModifiedTime().toMillis()); + result.put("modified", attributes.lastModifiedTime().toMillis()); + result.put("created", attributes.creationTime().toMillis()); result.put("size", attributes.isDirectory() ? 0 : attributes.size()); result.put("isDir", attributes.isDirectory()); result.put("isReadOnly", getFileSystem().isReadOnly(path)); @@ -499,8 +498,4 @@ public class FSAPI implements ILuaAPI { throw new LuaException(e.getMessage()); } } - - private static long getFileTime(@Nullable FileTime time) { - return time == null ? 0 : time.toMillis(); - } } diff --git a/projects/core/src/main/java/dan200/computercraft/core/filesystem/AbstractInMemoryMount.java b/projects/core/src/main/java/dan200/computercraft/core/filesystem/AbstractInMemoryMount.java new file mode 100644 index 000000000..9ea0a26dd --- /dev/null +++ b/projects/core/src/main/java/dan200/computercraft/core/filesystem/AbstractInMemoryMount.java @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package dan200.computercraft.core.filesystem; + +import dan200.computercraft.api.filesystem.FileAttributes; +import dan200.computercraft.api.filesystem.FileOperationException; +import dan200.computercraft.api.filesystem.Mount; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * An abstract mount which stores its file tree in memory. + * + * @param The type of file. + */ +public abstract class AbstractInMemoryMount> implements Mount { + protected static final String NO_SUCH_FILE = "No such file"; + + @Nullable + protected T root; + + private @Nullable T get(String path) { + var lastEntry = root; + var lastIndex = 0; + + while (lastEntry != null && lastIndex < path.length()) { + var nextIndex = path.indexOf('/', lastIndex); + if (nextIndex < 0) nextIndex = path.length(); + + lastEntry = lastEntry.children == null ? null : lastEntry.children.get(path.substring(lastIndex, nextIndex)); + lastIndex = nextIndex + 1; + } + + return lastEntry; + } + + @Override + public final boolean exists(String path) { + return get(path) != null; + } + + @Override + public final boolean isDirectory(String path) { + var file = get(path); + return file != null && file.isDirectory(); + } + + @Override + public final void list(String path, List contents) throws IOException { + var file = get(path); + if (file == null || !file.isDirectory()) throw new FileOperationException(path, "Not a directory"); + + file.list(contents); + } + + @Override + public final long getSize(String path) throws IOException { + var file = get(path); + if (file == null) throw new FileOperationException(path, NO_SUCH_FILE); + return getSize(file); + } + + /** + * Get the size of a file. + * + * @param file The file to get the size of. + * @return The size of the file. This should be 0 for directories, and equal to {@code openForRead(_).size()} for files. + * @throws IOException If the size could not be read. + */ + protected abstract long getSize(T file) throws IOException; + + @Override + public final SeekableByteChannel openForRead(String path) throws IOException { + var file = get(path); + if (file == null || file.isDirectory()) throw new FileOperationException(path, NO_SUCH_FILE); + return openForRead(file); + } + + /** + * Open a file for reading. + * + * @param file The file to read. This will not be a directory. + * @return The channel for this file. + */ + protected abstract SeekableByteChannel openForRead(T file) throws IOException; + + @Override + public final BasicFileAttributes getAttributes(String path) throws IOException { + var file = get(path); + if (file == null) throw new FileOperationException(path, NO_SUCH_FILE); + return getAttributes(file); + } + + /** + * Get all attributes of the file. + * + * @param file The file to compute attributes for. + * @return The file's attributes. + * @throws IOException If the attributes could not be read. + */ + protected BasicFileAttributes getAttributes(T file) throws IOException { + return new FileAttributes(file.isDirectory(), getSize(file)); + } + + protected T getOrCreateChild(T lastEntry, String localPath, Function factory) { + var lastIndex = 0; + while (lastIndex < localPath.length()) { + var nextIndex = localPath.indexOf('/', lastIndex); + if (nextIndex < 0) nextIndex = localPath.length(); + + var part = localPath.substring(lastIndex, nextIndex); + if (lastEntry.children == null) lastEntry.children = new HashMap<>(0); + + var nextEntry = lastEntry.children.get(part); + if (nextEntry == null || !nextEntry.isDirectory()) { + lastEntry.children.put(part, nextEntry = factory.apply(localPath.substring(0, nextIndex))); + } + + lastEntry = nextEntry; + lastIndex = nextIndex + 1; + } + + return lastEntry; + } + + protected static class FileEntry> { + public final String path; + @Nullable + public Map children; + + protected FileEntry(String path) { + this.path = path; + } + + public boolean isDirectory() { + return children != null; + } + + protected void list(List contents) { + if (children != null) contents.addAll(children.keySet()); + } + } +} diff --git a/projects/core/src/main/java/dan200/computercraft/core/filesystem/ArchiveMount.java b/projects/core/src/main/java/dan200/computercraft/core/filesystem/ArchiveMount.java index cacc6c74a..fd9c83050 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/filesystem/ArchiveMount.java +++ b/projects/core/src/main/java/dan200/computercraft/core/filesystem/ArchiveMount.java @@ -6,25 +6,21 @@ package dan200.computercraft.core.filesystem; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; -import dan200.computercraft.api.filesystem.FileAttributes; -import dan200.computercraft.api.filesystem.FileOperationException; -import dan200.computercraft.api.filesystem.Mount; import dan200.computercraft.core.apis.handles.ArrayByteChannel; -import javax.annotation.Nullable; import java.io.IOException; import java.nio.channels.SeekableByteChannel; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.List; -import java.util.Map; import java.util.concurrent.TimeUnit; /** * An abstract mount based on some archive of files, such as a Zip or Minecraft's resources. + *

+ * We assume that we cannot create {@link SeekableByteChannel}s directly from the archive, and so maintain a (shared) + * cache of recently read files and their contents. * * @param The type of file. */ -public abstract class ArchiveMount> implements Mount { +public abstract class ArchiveMount> extends AbstractInMemoryMount { protected static final String NO_SUCH_FILE = "No such file"; /** @@ -44,82 +40,38 @@ public abstract class ArchiveMount> implemen ., byte[]>weigher((k, v) -> v.length) .build(); - @Nullable - protected T root; - - private @Nullable T get(String path) { - var lastEntry = root; - var lastIndex = 0; - - while (lastEntry != null && lastIndex < path.length()) { - var nextIndex = path.indexOf('/', lastIndex); - if (nextIndex < 0) nextIndex = path.length(); - - lastEntry = lastEntry.children == null ? null : lastEntry.children.get(path.substring(lastIndex, nextIndex)); - lastIndex = nextIndex + 1; - } - - return lastEntry; - } - @Override - public final boolean exists(String path) { - return get(path) != null; - } - - @Override - public final boolean isDirectory(String path) { - var file = get(path); - return file != null && file.isDirectory(); - } - - @Override - public final void list(String path, List contents) throws IOException { - var file = get(path); - if (file == null || !file.isDirectory()) throw new FileOperationException(path, "Not a directory"); - - file.list(contents); - } - - @Override - public final long getSize(String path) throws IOException { - var file = get(path); - if (file == null) throw new FileOperationException(path, NO_SUCH_FILE); - return getCachedSize(file); - } - - private long getCachedSize(T file) throws IOException { + protected final long getSize(T file) throws IOException { if (file.size != -1) return file.size; if (file.isDirectory()) return file.size = 0; var contents = CONTENTS_CACHE.getIfPresent(file); - if (contents != null) return file.size = contents.length; - - return file.size = getSize(file); + return file.size = contents != null ? contents.length : getFileSize(file); } /** - * Get the size of a file. - *

- * This should only be called once per file, as the result is cached in {@link #getSize(String)}. + * Get the size of the file by reading it (or its metadata) from disk. * - * @param file The file to compute the size of. This will not be a directory. - * @return The size of the file. - * @throws IOException If the size could not be read. + * @param file The file to get the size of. + * @return The file's size. + * @throws IOException If the size could not be computed. */ - protected abstract long getSize(T file) throws IOException; + protected long getFileSize(T file) throws IOException { + return getContents(file).length; + } @Override - public SeekableByteChannel openForRead(String path) throws IOException { - var file = get(path); - if (file == null || file.isDirectory()) throw new FileOperationException(path, NO_SUCH_FILE); + protected final SeekableByteChannel openForRead(T file) throws IOException { + return new ArrayByteChannel(getContents(file)); + } + private byte[] getContents(T file) throws IOException { var cachedContents = CONTENTS_CACHE.getIfPresent(file); - if (cachedContents != null) return new ArrayByteChannel(cachedContents); + if (cachedContents != null) return cachedContents; - var contents = getContents(file); + var contents = getFileContents(file); CONTENTS_CACHE.put(file, contents); - return new ArrayByteChannel(contents); + return contents; } /** @@ -128,44 +80,13 @@ public abstract class ArchiveMount> implemen * @param file The file to read into memory. This will not be a directory. * @return The contents of the file. */ - protected abstract byte[] getContents(T file) throws IOException; - - @Override - public final BasicFileAttributes getAttributes(String path) throws IOException { - var file = get(path); - if (file == null) throw new FileOperationException(path, NO_SUCH_FILE); - - return getAttributes(file); - } - - /** - * Get all attributes of the file. - * - * @param file The file to compute attributes for. This will not be a directory. - * @return The file's attributes. - * @throws IOException If the attributes could not be read. - */ - protected BasicFileAttributes getAttributes(T file) throws IOException { - return new FileAttributes(file.isDirectory(), getCachedSize(file)); - } - - protected static class FileEntry> { - public final String path; - @Nullable - public Map children; + protected abstract byte[] getFileContents(T file) throws IOException; + protected static class FileEntry> extends AbstractInMemoryMount.FileEntry { long size = -1; protected FileEntry(String path) { - this.path = path; - } - - protected boolean isDirectory() { - return children != null; - } - - protected void list(List contents) { - if (children != null) contents.addAll(children.keySet()); + super(path); } } } diff --git a/projects/core/src/main/java/dan200/computercraft/core/filesystem/JarMount.java b/projects/core/src/main/java/dan200/computercraft/core/filesystem/JarMount.java index 18f1698d7..9c641d43c 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/filesystem/JarMount.java +++ b/projects/core/src/main/java/dan200/computercraft/core/filesystem/JarMount.java @@ -4,8 +4,8 @@ package dan200.computercraft.core.filesystem; +import dan200.computercraft.api.filesystem.FileAttributes; import dan200.computercraft.api.filesystem.FileOperationException; -import dan200.computercraft.core.util.Nullability; import javax.annotation.Nullable; import java.io.Closeable; @@ -42,7 +42,7 @@ public class JarMount extends ArchiveMount implements Closea } // Read in all the entries - root = new FileEntry(""); + var root = this.root = new FileEntry(""); var zipEntries = zip.entries(); while (zipEntries.hasMoreElements()) { var entry = zipEntries.nextElement(); @@ -51,41 +51,18 @@ public class JarMount extends ArchiveMount implements Closea if (!entryPath.startsWith(subPath)) continue; var localPath = FileSystem.toLocal(entryPath, subPath); - create(entry, localPath); + getOrCreateChild(root, localPath, FileEntry::new).setup(entry); } } - private void create(ZipEntry entry, String localPath) { - var lastEntry = Nullability.assertNonNull(root); - - var lastIndex = 0; - while (lastIndex < localPath.length()) { - var nextIndex = localPath.indexOf('/', lastIndex); - if (nextIndex < 0) nextIndex = localPath.length(); - - var part = localPath.substring(lastIndex, nextIndex); - if (lastEntry.children == null) lastEntry.children = new HashMap<>(0); - - var nextEntry = lastEntry.children.get(part); - if (nextEntry == null || !nextEntry.isDirectory()) { - lastEntry.children.put(part, nextEntry = new FileEntry(localPath.substring(0, nextIndex))); - } - - lastEntry = nextEntry; - lastIndex = nextIndex + 1; - } - - lastEntry.setup(entry); - } - @Override - protected long getSize(FileEntry file) throws FileOperationException { + protected long getFileSize(FileEntry file) throws FileOperationException { if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE); return file.zipEntry.getSize(); } @Override - protected byte[] getContents(FileEntry file) throws FileOperationException { + protected byte[] getFileContents(FileEntry file) throws FileOperationException { if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE); try (var stream = zip.getInputStream(file.zipEntry)) { @@ -97,9 +74,10 @@ public class JarMount extends ArchiveMount implements Closea } @Override - public BasicFileAttributes getAttributes(FileEntry file) throws FileOperationException { - if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE); - return new ZipEntryAttributes(file.zipEntry); + protected BasicFileAttributes getAttributes(FileEntry file) throws IOException { + return file.zipEntry == null ? super.getAttributes(file) : new FileAttributes( + file.isDirectory(), getSize(file), orEpoch(file.zipEntry.getCreationTime()), orEpoch(file.zipEntry.getLastModifiedTime()) + ); } @Override @@ -117,69 +95,13 @@ public class JarMount extends ArchiveMount implements Closea void setup(ZipEntry entry) { zipEntry = entry; - size = entry.getSize(); if (children == null && entry.isDirectory()) children = new HashMap<>(0); } } - private static class ZipEntryAttributes implements BasicFileAttributes { - private final ZipEntry entry; + private static final FileTime EPOCH = FileTime.from(Instant.EPOCH); - ZipEntryAttributes(ZipEntry entry) { - this.entry = entry; - } - - @Override - public FileTime lastModifiedTime() { - return orEpoch(entry.getLastModifiedTime()); - } - - @Override - public FileTime lastAccessTime() { - return orEpoch(entry.getLastAccessTime()); - } - - @Override - public FileTime creationTime() { - var time = entry.getCreationTime(); - return time == null ? lastModifiedTime() : time; - } - - @Override - public boolean isRegularFile() { - return !entry.isDirectory(); - } - - @Override - public boolean isDirectory() { - return entry.isDirectory(); - } - - @Override - public boolean isSymbolicLink() { - return false; - } - - @Override - public boolean isOther() { - return false; - } - - @Override - public long size() { - return entry.getSize(); - } - - @Nullable - @Override - public Object fileKey() { - return null; - } - - private static final FileTime EPOCH = FileTime.from(Instant.EPOCH); - - private static FileTime orEpoch(@Nullable FileTime time) { - return time == null ? EPOCH : time; - } + private static FileTime orEpoch(@Nullable FileTime time) { + return time == null ? EPOCH : time; } } diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java index b333961b7..b365a4ac8 100644 --- a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java +++ b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java @@ -7,53 +7,44 @@ package dan200.computercraft.test.core.filesystem; import dan200.computercraft.api.filesystem.FileOperationException; import dan200.computercraft.api.filesystem.Mount; import dan200.computercraft.core.apis.handles.ArrayByteChannel; +import dan200.computercraft.core.filesystem.AbstractInMemoryMount; +import dan200.computercraft.core.util.Nullability; +import javax.annotation.Nullable; +import java.io.IOException; import java.nio.channels.SeekableByteChannel; import java.nio.charset.StandardCharsets; -import java.util.*; /** * A read-only mount {@link Mount} which provides a list of in-memory set of files. */ -public class MemoryMount implements Mount { - private final Map files = new HashMap<>(); - private final Set directories = new HashSet<>(); - +public class MemoryMount extends AbstractInMemoryMount { public MemoryMount() { - directories.add(""); - } - - @Override - public boolean exists(String path) { - return files.containsKey(path) || directories.contains(path); - } - - @Override - public boolean isDirectory(String path) { - return directories.contains(path); - } - - @Override - public void list(String path, List files) { - for (var file : this.files.keySet()) { - if (file.startsWith(path)) files.add(file.substring(path.length() + 1)); - } - } - - @Override - public long getSize(String path) { - throw new RuntimeException("Not implemented"); - } - - @Override - public SeekableByteChannel openForRead(String path) throws FileOperationException { - var file = files.get(path); - if (file == null) throw new FileOperationException(path, "File not found"); - return new ArrayByteChannel(file); + root = new FileEntry(""); } public MemoryMount addFile(String file, String contents) { - files.put(file, contents.getBytes(StandardCharsets.UTF_8)); + getOrCreateChild(Nullability.assertNonNull(root), file, FileEntry::new).contents = contents.getBytes(StandardCharsets.UTF_8); return this; } + + @Override + protected long getSize(FileEntry file) { + return file.contents == null ? 0 : file.contents.length; + } + + @Override + protected SeekableByteChannel openForRead(FileEntry file) throws IOException { + if (file.contents == null) throw new FileOperationException(file.path, "File is a directory"); + return new ArrayByteChannel(file.contents); + } + + protected static class FileEntry extends AbstractInMemoryMount.FileEntry { + @Nullable + byte[] contents; + + protected FileEntry(String path) { + super(path); + } + } }