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 fbdc39b1a..b213588c2 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 @@ -58,7 +58,7 @@ public final class ResourceMount extends ArchiveMount { var hasAny = false; String existingNamespace = null; - var newRoot = new FileEntry("", new ResourceLocation(namespace, subPath)); + var newRoot = new FileEntry(new ResourceLocation(namespace, subPath)); for (var file : manager.listResources(subPath, s -> true).keySet()) { existingNamespace = file.getNamespace(); @@ -86,24 +86,23 @@ public final class ResourceMount extends ArchiveMount { } private FileEntry createEntry(String path) { - return new FileEntry(path, new ResourceLocation(namespace, subPath + "/" + path)); + return new FileEntry(new ResourceLocation(namespace, subPath + "/" + path)); } @Override - public byte[] getFileContents(FileEntry file) throws IOException { + protected byte[] getFileContents(String path, FileEntry file) throws IOException { var resource = manager.getResource(file.identifier).orElse(null); - if (resource == null) throw new FileOperationException(file.path, NO_SUCH_FILE); + if (resource == null) throw new FileOperationException(path, NO_SUCH_FILE); try (var stream = resource.open()) { return stream.readAllBytes(); } } - protected static class FileEntry extends ArchiveMount.FileEntry { + protected static final class FileEntry extends ArchiveMount.FileEntry { final ResourceLocation identifier; - FileEntry(String path, ResourceLocation identifier) { - super(path); + FileEntry(ResourceLocation identifier) { this.identifier = identifier; } } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/computer/menu/ServerInputState.java b/projects/common/src/main/java/dan200/computercraft/shared/computer/menu/ServerInputState.java index a1cee206a..f9fa8e1ed 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/computer/menu/ServerInputState.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/computer/menu/ServerInputState.java @@ -4,6 +4,7 @@ package dan200.computercraft.shared.computer.menu; +import dan200.computercraft.core.apis.handles.ByteBufferChannel; import dan200.computercraft.core.apis.transfer.TransferredFile; import dan200.computercraft.core.apis.transfer.TransferredFiles; import dan200.computercraft.shared.computer.upload.FileSlice; @@ -22,7 +23,6 @@ import org.slf4j.LoggerFactory; import javax.annotation.Nullable; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; /** * The default concrete implementation of {@link ServerInputHandler}. @@ -156,7 +156,7 @@ public class ServerInputState im computer.queueEvent(TransferredFiles.EVENT, new Object[]{ new TransferredFiles( - toUpload.stream().map(x -> new TransferredFile(x.getName(), x.getBytes())).collect(Collectors.toList()), + toUpload.stream().map(x -> new TransferredFile(x.getName(), new ByteBufferChannel(x.getBytes()))).toList(), () -> { if (player.isAlive() && player.containerMenu == owner) { PlatformHelper.get().sendToPlayer(UploadResultMessage.consumed(owner), player); diff --git a/projects/core/src/main/java/dan200/computercraft/core/apis/handles/BinaryReadableHandle.java b/projects/core/src/main/java/dan200/computercraft/core/apis/handles/BinaryReadableHandle.java index f0b5ee8b4..a1b1acb29 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/apis/handles/BinaryReadableHandle.java +++ b/projects/core/src/main/java/dan200/computercraft/core/apis/handles/BinaryReadableHandle.java @@ -77,12 +77,10 @@ public class BinaryReadableHandle extends HandleGeneric { var buffer = ByteBuffer.allocate(BUFFER_SIZE); var read = channel.read(buffer); if (read < 0) return null; + buffer.flip(); // If we failed to read "enough" here, let's just abort - if (read >= count || read < BUFFER_SIZE) { - buffer.flip(); - return new Object[]{ buffer }; - } + if (read >= count || read < BUFFER_SIZE) return new Object[]{ buffer }; // Build up an array of ByteBuffers. Hopefully this means we can perform less allocation // than doubling up the buffer each time. @@ -90,11 +88,13 @@ public class BinaryReadableHandle extends HandleGeneric { List parts = new ArrayList<>(4); parts.add(buffer); while (read >= BUFFER_SIZE && totalRead < count) { - buffer = ByteBuffer.allocate(Math.min(BUFFER_SIZE, count - totalRead)); + buffer = ByteBuffer.allocateDirect(Math.min(BUFFER_SIZE, count - totalRead)); read = channel.read(buffer); if (read < 0) break; + buffer.flip(); totalRead += read; + assert read == buffer.remaining(); parts.add(buffer); } @@ -102,9 +102,11 @@ public class BinaryReadableHandle extends HandleGeneric { var bytes = new byte[totalRead]; var pos = 0; for (var part : parts) { - System.arraycopy(part.array(), 0, bytes, pos, part.position()); - pos += part.position(); + int length = part.remaining(); + part.get(bytes, pos, length); + pos += length; } + assert pos == totalRead; return new Object[]{ bytes }; } } else { diff --git a/projects/core/src/main/java/dan200/computercraft/core/apis/transfer/TransferredFile.java b/projects/core/src/main/java/dan200/computercraft/core/apis/transfer/TransferredFile.java index 51565f32f..1893d9290 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/apis/transfer/TransferredFile.java +++ b/projects/core/src/main/java/dan200/computercraft/core/apis/transfer/TransferredFile.java @@ -6,10 +6,9 @@ package dan200.computercraft.core.apis.transfer; import dan200.computercraft.api.lua.LuaFunction; import dan200.computercraft.core.apis.handles.BinaryReadableHandle; -import dan200.computercraft.core.apis.handles.ByteBufferChannel; import dan200.computercraft.core.methods.ObjectSource; -import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; import java.util.Collections; import java.util.Optional; @@ -26,9 +25,9 @@ public class TransferredFile implements ObjectSource { private final String name; private final BinaryReadableHandle handle; - public TransferredFile(String name, ByteBuffer contents) { + public TransferredFile(String name, SeekableByteChannel contents) { this.name = name; - handle = BinaryReadableHandle.of(new ByteBufferChannel(contents)); + handle = BinaryReadableHandle.of(contents); } /** 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 index 9ea0a26dd..58c34ba62 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/filesystem/AbstractInMemoryMount.java +++ b/projects/core/src/main/java/dan200/computercraft/core/filesystem/AbstractInMemoryMount.java @@ -28,7 +28,7 @@ public abstract class AbstractInMemoryMount contents) throws IOException { var file = get(path); - if (file == null || !file.isDirectory()) throw new FileOperationException(path, "Not a directory"); + if (file == null || file.children == null) throw new FileOperationException(path, "Not a directory"); - file.list(contents); + contents.addAll(file.children.keySet()); } @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); + return getSize(path, file); } /** * Get the size of a file. * + * @param path The file path, for error messages. * @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; + protected abstract long getSize(String path, 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); + return openForRead(path, file); } /** * Open a file for reading. * + * @param path The file path, for error messages. * @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; + protected abstract SeekableByteChannel openForRead(String path, 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); + return getAttributes(path, file); } /** * Get all attributes of the file. * + * @param path The file path, for error messages. * @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 BasicFileAttributes getAttributes(String path, T file) throws IOException { + return new FileAttributes(file.isDirectory(), getSize(path, file)); } protected T getOrCreateChild(T lastEntry, String localPath, Function factory) { @@ -133,20 +136,11 @@ public abstract class AbstractInMemoryMount> { - 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 fd9c83050..8d47d7f33 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 @@ -41,35 +41,36 @@ public abstract class ArchiveMount> extends .build(); @Override - protected final long getSize(T file) throws IOException { + protected final long getSize(String path, T file) throws IOException { if (file.size != -1) return file.size; if (file.isDirectory()) return file.size = 0; var contents = CONTENTS_CACHE.getIfPresent(file); - return file.size = contents != null ? contents.length : getFileSize(file); + return file.size = contents != null ? contents.length : getFileSize(path, file); } /** * Get the size of the file by reading it (or its metadata) from disk. * + * @param path The file path, for error messages. * @param file The file to get the size of. * @return The file's size. * @throws IOException If the size could not be computed. */ - protected long getFileSize(T file) throws IOException { - return getContents(file).length; + protected long getFileSize(String path, T file) throws IOException { + return getContents(path, file).length; } @Override - protected final SeekableByteChannel openForRead(T file) throws IOException { - return new ArrayByteChannel(getContents(file)); + protected final SeekableByteChannel openForRead(String path, T file) throws IOException { + return new ArrayByteChannel(getContents(path, file)); } - private byte[] getContents(T file) throws IOException { + private byte[] getContents(String path, T file) throws IOException { var cachedContents = CONTENTS_CACHE.getIfPresent(file); if (cachedContents != null) return cachedContents; - var contents = getFileContents(file); + var contents = getFileContents(path, file); CONTENTS_CACHE.put(file, contents); return contents; } @@ -77,16 +78,13 @@ public abstract class ArchiveMount> extends /** * Read the entirety of a file into memory. * + * @param path The file path, for error messages. * @param file The file to read into memory. This will not be a directory. * @return The contents of the file. */ - protected abstract byte[] getFileContents(T file) throws IOException; + protected abstract byte[] getFileContents(String path, T file) throws IOException; protected static class FileEntry> extends AbstractInMemoryMount.FileEntry { long size = -1; - - protected FileEntry(String path) { - 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 9c641d43c..10c4959d1 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 @@ -22,7 +22,7 @@ import java.util.zip.ZipFile; /** * A mount which reads zip/jar files. */ -public class JarMount extends ArchiveMount implements Closeable { +public final class JarMount extends ArchiveMount implements Closeable { private final ZipFile zip; public JarMount(File jarFile, String subPath) throws IOException { @@ -42,7 +42,7 @@ public class JarMount extends ArchiveMount implements Closea } // Read in all the entries - var root = this.root = new FileEntry(""); + var root = this.root = new FileEntry(); var zipEntries = zip.entries(); while (zipEntries.hasMoreElements()) { var entry = zipEntries.nextElement(); @@ -51,32 +51,32 @@ public class JarMount extends ArchiveMount implements Closea if (!entryPath.startsWith(subPath)) continue; var localPath = FileSystem.toLocal(entryPath, subPath); - getOrCreateChild(root, localPath, FileEntry::new).setup(entry); + getOrCreateChild(root, localPath, x -> new FileEntry()).setup(entry); } } @Override - protected long getFileSize(FileEntry file) throws FileOperationException { - if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE); + protected long getFileSize(String path, FileEntry file) throws FileOperationException { + if (file.zipEntry == null) throw new FileOperationException(path, NO_SUCH_FILE); return file.zipEntry.getSize(); } @Override - protected byte[] getFileContents(FileEntry file) throws FileOperationException { - if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE); + protected byte[] getFileContents(String path, FileEntry file) throws FileOperationException { + if (file.zipEntry == null) throw new FileOperationException(path, NO_SUCH_FILE); try (var stream = zip.getInputStream(file.zipEntry)) { return stream.readAllBytes(); } catch (IOException e) { // Mask other IO exceptions as a non-existent file. - throw new FileOperationException(file.path, NO_SUCH_FILE); + throw new FileOperationException(path, NO_SUCH_FILE); } } @Override - 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()) + protected BasicFileAttributes getAttributes(String path, FileEntry file) throws IOException { + return file.zipEntry == null ? super.getAttributes(path, file) : new FileAttributes( + file.isDirectory(), getSize(path, file), orEpoch(file.zipEntry.getCreationTime()), orEpoch(file.zipEntry.getLastModifiedTime()) ); } @@ -85,14 +85,10 @@ public class JarMount extends ArchiveMount implements Closea zip.close(); } - protected static class FileEntry extends ArchiveMount.FileEntry { + protected static final class FileEntry extends ArchiveMount.FileEntry { @Nullable ZipEntry zipEntry; - protected FileEntry(String path) { - super(path); - } - void setup(ZipEntry entry) { zipEntry = entry; if (children == null && entry.isDirectory()) children = new HashMap<>(0); diff --git a/projects/core/src/main/java/dan200/computercraft/core/filesystem/MemoryMount.java b/projects/core/src/main/java/dan200/computercraft/core/filesystem/MemoryMount.java new file mode 100644 index 000000000..702cfbe79 --- /dev/null +++ b/projects/core/src/main/java/dan200/computercraft/core/filesystem/MemoryMount.java @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: 2019 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 dan200.computercraft.api.filesystem.WritableMount; +import dan200.computercraft.core.util.Nullability; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AccessDeniedException; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.*; + +import static dan200.computercraft.core.filesystem.WritableFileMount.MINIMUM_FILE_SIZE; + +/** + * A basic {@link Mount} which stores files and directories in-memory. + */ +public final class MemoryMount extends AbstractInMemoryMount implements WritableMount { + private static final byte[] EMPTY = new byte[0]; + private static final FileTime EPOCH = FileTime.from(Instant.EPOCH); + + private final long capacity; + + /** + * Create a memory mount with a 1GB capacity. + */ + public MemoryMount() { + this(1000_000_000); + } + + /** + * Create a memory mount with a custom capacity. Note, this is only used in calculations for {@link #getCapacity()} + * and {@link #getRemainingSpace()}, it is not checked when creating or writing files. + * + * @param capacity The capacity of this mount. + */ + public MemoryMount(long capacity) { + this.capacity = capacity; + root = new FileEntry(); + root.children = new HashMap<>(); + } + + public MemoryMount addFile(String file, byte[] contents, FileTime created, FileTime modified) { + var entry = getOrCreateChild(Nullability.assertNonNull(root), file, x -> new FileEntry()); + entry.contents = contents; + entry.length = contents.length; + entry.created = created; + entry.modified = modified; + return this; + } + + public MemoryMount addFile(String file, String contents, FileTime created, FileTime modified) { + return addFile(file, contents.getBytes(StandardCharsets.UTF_8), created, modified); + } + + public MemoryMount addFile(String file, byte[] contents) { + return addFile(file, contents, EPOCH, EPOCH); + } + + public MemoryMount addFile(String file, String contents) { + return addFile(file, contents, EPOCH, EPOCH); + } + + @Override + protected long getSize(String path, FileEntry file) { + return file.length; + } + + @Override + protected SeekableByteChannel openForRead(String path, FileEntry file) throws IOException { + if (file.contents == null) throw new FileOperationException(path, "File is a directory"); + return new EntryChannel(file, 0); + } + + @Override + protected BasicFileAttributes getAttributes(String path, FileEntry file) throws IOException { + return new FileAttributes(file.isDirectory(), file.length, file.created, file.modified); + } + + private @Nullable ParentAndName getParentAndName(String path) { + if (path.isEmpty()) throw new IllegalArgumentException("Path is empty"); + var index = path.lastIndexOf('/'); + if (index == -1) { + return new ParentAndName(Nullability.assertNonNull(Nullability.assertNonNull(root).children), path); + } + + var entry = get(path.substring(0, index)); + return entry == null || entry.children == null + ? null + : new ParentAndName(entry.children, path.substring(index + 1)); + } + + @Override + public void makeDirectory(String path) throws IOException { + if (path.isEmpty()) return; + + var lastEntry = Nullability.assertNonNull(root); + var lastIndex = 0; + while (lastIndex < path.length()) { + if (lastEntry.children == null) throw new NullPointerException("children is null"); + + var nextIndex = path.indexOf('/', lastIndex); + if (nextIndex < 0) nextIndex = path.length(); + + var part = path.substring(lastIndex, nextIndex); + var nextEntry = lastEntry.children.get(part); + if (nextEntry == null) { + lastEntry.children.put(part, nextEntry = FileEntry.newDir()); + } else if (nextEntry.children == null) { + throw new FileOperationException(path, "File exists"); + } + + lastEntry = nextEntry; + lastIndex = nextIndex + 1; + } + } + + @Override + public void delete(String path) throws IOException { + if (path.isEmpty()) throw new AccessDeniedException("Access denied"); + var node = getParentAndName(path); + if (node != null) node.parent().remove(node.name()); + } + + @Override + public void rename(String source, String dest) throws IOException { + if (dest.startsWith(source)) throw new FileOperationException(source, "Cannot move a directory inside itself"); + + var sourceParent = getParentAndName(source); + if (sourceParent == null || !sourceParent.exists()) throw new FileOperationException(source, "No such file"); + + var destParent = getParentAndName(dest); + if (destParent == null) throw new FileOperationException(dest, "Parent directory does not exist"); + if (destParent.exists()) throw new FileOperationException(dest, "File exists"); + + destParent.put(sourceParent.parent().remove(sourceParent.name())); + } + + private FileEntry getForWrite(String path) throws FileOperationException { + if (path.isEmpty()) throw new FileOperationException(path, "Cannot write to directory"); + + var parent = getParentAndName(path); + if (parent == null) throw new FileOperationException(path, "Parent directory does not exist"); + + var file = parent.get(); + if (file != null && file.isDirectory()) throw new FileOperationException(path, "Cannot write to directory"); + if (file == null) parent.put(file = FileEntry.newFile()); + + return file; + } + + @Override + public SeekableByteChannel openForWrite(String path) throws IOException { + var file = getForWrite(path); + + // Truncate the file. + file.contents = EMPTY; + file.length = 0; + return new EntryChannel(file, 0); + } + + @Override + public SeekableByteChannel openForAppend(String path) throws IOException { + var file = getForWrite(path); + return new EntryChannel(file, file.length); + } + + @Override + public long getRemainingSpace() { + return capacity - computeUsedSpace(); + } + + @Override + public long getCapacity() { + return capacity; + } + + private long computeUsedSpace() { + Queue queue = new ArrayDeque<>(); + queue.add(root); + + long size = 0; + + FileEntry entry; + while ((entry = queue.poll()) != null) { + if (entry.children == null) { + size += Math.max(MINIMUM_FILE_SIZE, Nullability.assertNonNull(entry.contents).length); + } else { + size += MINIMUM_FILE_SIZE; + queue.addAll(entry.children.values()); + } + } + + return size - MINIMUM_FILE_SIZE; // Subtract one file for the root. + } + + protected static final class FileEntry extends AbstractInMemoryMount.FileEntry { + FileTime created = EPOCH; + FileTime modified = EPOCH; + @Nullable + byte[] contents; + + int length; + + static FileEntry newFile() { + var entry = new FileEntry(); + entry.contents = EMPTY; + entry.created = entry.modified = FileTime.from(Instant.now()); + return entry; + } + + static FileEntry newDir() { + var entry = new FileEntry(); + entry.children = new HashMap<>(); + entry.created = entry.modified = FileTime.from(Instant.now()); + return entry; + } + } + + private record ParentAndName(Map parent, String name) { + boolean exists() { + return parent.containsKey(name); + } + + @Nullable + FileEntry get() { + return parent.get(name); + } + + void put(FileEntry entry) { + assert !parent.containsKey(name); + parent.put(name, entry); + } + } + + private static final class EntryChannel implements SeekableByteChannel { + private final FileEntry entry; + private long position; + private boolean isOpen = true; + + private void checkClosed() throws ClosedChannelException { + if (!isOpen()) throw new ClosedChannelException(); + } + + private EntryChannel(FileEntry entry, int position) { + this.entry = entry; + this.position = position; + } + + @Override + public int read(ByteBuffer destination) throws IOException { + checkClosed(); + + var backing = Nullability.assertNonNull(entry.contents); + if (position >= backing.length) return -1; + + var remaining = Math.min(backing.length - (int) position, destination.remaining()); + destination.put(backing, (int) position, remaining); + position += remaining; + return remaining; + } + + private byte[] ensureCapacity(int capacity) { + var contents = Nullability.assertNonNull(entry.contents); + if (capacity >= entry.length) { + var newCapacity = Math.max(capacity, contents.length << 1); + contents = entry.contents = Arrays.copyOf(contents, newCapacity); + } + + return contents; + } + + @Override + public int write(ByteBuffer src) throws IOException { + var toWrite = src.remaining(); + var endPosition = position + toWrite; + if (endPosition > 1 << 30) throw new IOException("File is too large"); + + var contents = ensureCapacity((int) endPosition); + src.get(contents, (int) position, toWrite); + position = endPosition; + if (endPosition > entry.length) entry.length = (int) endPosition; + return toWrite; + } + + @Override + public long position() throws IOException { + checkClosed(); + return position; + } + + @Override + public SeekableByteChannel position(long newPosition) throws IOException { + checkClosed(); + if (newPosition < 0) throw new IllegalArgumentException("Position out of bounds"); + this.position = newPosition; + return this; + } + + @Override + public long size() throws IOException { + checkClosed(); + return entry.length; + } + + @Override + public SeekableByteChannel truncate(long size) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isOpen() { + return isOpen && entry.contents != null; + } + + @Override + public void close() throws IOException { + checkClosed(); + isOpen = false; + } + } +} diff --git a/projects/core/src/main/java/dan200/computercraft/core/filesystem/WritableFileMount.java b/projects/core/src/main/java/dan200/computercraft/core/filesystem/WritableFileMount.java index 6764d01af..bb7b33f91 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/filesystem/WritableFileMount.java +++ b/projects/core/src/main/java/dan200/computercraft/core/filesystem/WritableFileMount.java @@ -27,7 +27,7 @@ import java.util.Set; public class WritableFileMount extends FileMount implements WritableMount { private static final Logger LOG = LoggerFactory.getLogger(WritableFileMount.class); - private static final long MINIMUM_FILE_SIZE = 500; + static final long MINIMUM_FILE_SIZE = 500; private static final Set WRITE_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); private static final Set APPEND_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.APPEND); diff --git a/projects/core/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java b/projects/core/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java index 42f0947af..3ecd8a6e5 100644 --- a/projects/core/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java +++ b/projects/core/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java @@ -12,10 +12,9 @@ import dan200.computercraft.api.lua.LuaFunction; import dan200.computercraft.core.ComputerContext; import dan200.computercraft.core.computer.mainthread.MainThread; import dan200.computercraft.core.computer.mainthread.MainThreadConfig; +import dan200.computercraft.core.filesystem.MemoryMount; import dan200.computercraft.core.terminal.Terminal; import dan200.computercraft.test.core.computer.BasicEnvironment; -import dan200.computercraft.test.core.filesystem.MemoryMount; -import dan200.computercraft.test.core.filesystem.ReadOnlyWritableMount; import org.junit.jupiter.api.Assertions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,7 +36,7 @@ public class ComputerBootstrap { .addFile("test.lua", program) .addFile("startup.lua", "assertion.assert(pcall(loadfile('test.lua', nil, _ENV))) os.shutdown()"); - run(new ReadOnlyWritableMount(mount), setup, maxTimes); + run(mount, setup, maxTimes); } public static void run(String program, int maxTimes) { diff --git a/projects/core/src/test/java/dan200/computercraft/core/filesystem/MemoryMountTest.java b/projects/core/src/test/java/dan200/computercraft/core/filesystem/MemoryMountTest.java new file mode 100644 index 000000000..7b03c14b2 --- /dev/null +++ b/projects/core/src/test/java/dan200/computercraft/core/filesystem/MemoryMountTest.java @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package dan200.computercraft.core.filesystem; + +import dan200.computercraft.api.filesystem.Mount; +import dan200.computercraft.api.filesystem.WritableMount; +import dan200.computercraft.test.core.filesystem.MountContract; +import dan200.computercraft.test.core.filesystem.WritableMountContract; +import org.opentest4j.TestAbortedException; + +public class MemoryMountTest implements MountContract, WritableMountContract { + @Override + public Mount createSkeleton() { + var mount = new MemoryMount(); + mount.addFile("f.lua", ""); + mount.addFile("dir/file.lua", "print('testing')", EPOCH, MODIFY_TIME); + return mount; + } + + @Override + public MountAccess createMount(long capacity) { + var mount = new MemoryMount(capacity); + return new MountAccess() { + @Override + public WritableMount mount() { + return mount; + } + + @Override + public void makeReadOnly(String path) { + throw new TestAbortedException("Not supported for MemoryMount"); + } + + @Override + public void ensuresExist() { + } + + @Override + public long computeRemainingSpace() { + return mount.getRemainingSpace(); + } + }; + } +} diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/CloseScope.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/CloseScope.java index d6a54a91e..0b4d5b3d5 100644 --- a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/CloseScope.java +++ b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/CloseScope.java @@ -10,10 +10,10 @@ import java.util.Deque; import java.util.Objects; /** - * An {@link AutoCloseable} implementation which can be used to combine other [AutoCloseable] instances. + * An {@link AutoCloseable} implementation which can be used to combine other {@link AutoCloseable} instances. *

- * Values which implement {@link AutoCloseable} can be dynamically registered with [CloseScope.add]. When the scope is - * closed, each value is closed in the opposite order. + * Values which implement {@link AutoCloseable} can be dynamically registered with {@link CloseScope#add(AutoCloseable)}. + * When the scope is closed, each value is closed in the opposite order. *

* This is largely intended for cases where it's not appropriate to nest try-with-resources blocks, for instance when * nested would be too deep or when objects are dynamically created. diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/ReplaceUnderscoresDisplayNameGenerator.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/ReplaceUnderscoresDisplayNameGenerator.java new file mode 100644 index 000000000..17b24331c --- /dev/null +++ b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/ReplaceUnderscoresDisplayNameGenerator.java @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package dan200.computercraft.test.core; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; + +import java.lang.reflect.Method; + +/** + * A {@link DisplayNameGenerator} which replaces underscores with spaces. This is equivalent to + * {@link DisplayNameGenerator.ReplaceUnderscores}, but excludes the parameter types. + * + * @see DisplayNameGeneration + */ +public class ReplaceUnderscoresDisplayNameGenerator extends DisplayNameGenerator.ReplaceUnderscores { + @Override + public String generateDisplayNameForMethod(Class testClass, Method testMethod) { + return testMethod.getName().replace('_', ' '); + } +} diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/computer/BasicEnvironment.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/computer/BasicEnvironment.java index 11a9700eb..0f506c82a 100644 --- a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/computer/BasicEnvironment.java +++ b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/computer/BasicEnvironment.java @@ -11,10 +11,9 @@ import dan200.computercraft.core.computer.ComputerEnvironment; import dan200.computercraft.core.computer.GlobalEnvironment; import dan200.computercraft.core.filesystem.FileMount; import dan200.computercraft.core.filesystem.JarMount; +import dan200.computercraft.core.filesystem.MemoryMount; import dan200.computercraft.core.metrics.Metric; import dan200.computercraft.core.metrics.MetricsObserver; -import dan200.computercraft.test.core.filesystem.MemoryMount; -import dan200.computercraft.test.core.filesystem.ReadOnlyWritableMount; import java.io.File; import java.io.IOException; @@ -32,7 +31,7 @@ public class BasicEnvironment implements ComputerEnvironment, GlobalEnvironment, private final WritableMount mount; public BasicEnvironment() { - this(new ReadOnlyWritableMount(new MemoryMount())); + this(new MemoryMount()); } public BasicEnvironment(WritableMount mount) { 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 deleted file mode 100644 index b365a4ac8..000000000 --- a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2019 The CC: Tweaked Developers -// -// SPDX-License-Identifier: MPL-2.0 - -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; - -/** - * A read-only mount {@link Mount} which provides a list of in-memory set of files. - */ -public class MemoryMount extends AbstractInMemoryMount { - public MemoryMount() { - root = new FileEntry(""); - } - - public MemoryMount addFile(String file, String contents) { - 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); - } - } -} diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MountContract.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MountContract.java index 60f541212..a43fece23 100644 --- a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MountContract.java +++ b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MountContract.java @@ -34,7 +34,10 @@ public interface MountContract { * Create a skeleton mount. This should contain the following files: * *

    - *
  • {@code dir/file.lua}, containing {@code print('testing')}.
  • + *
  • + * {@code dir/file.lua}, containing {@code print('testing')}. If {@linkplain #hasFileTimes() file times are + * supported}, it should have a modification time of {@link #MODIFY_TIME}. + *
  • *
  • {@code f.lua}, containing nothing.
  • *
* diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/Mounts.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/Mounts.java new file mode 100644 index 000000000..5874453de --- /dev/null +++ b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/Mounts.java @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers +// +// SPDX-License-Identifier: MPL-2.0 + +package dan200.computercraft.test.core.filesystem; + +import dan200.computercraft.api.filesystem.WritableMount; + +import java.io.IOException; +import java.nio.channels.Channels; +import java.nio.charset.StandardCharsets; + +/** + * Utility functions for working with mounts. + */ +public final class Mounts { + private Mounts() { + } + + /** + * Write a file to this mount. + * + * @param mount The mount to modify. + * @param path The path to write to. + * @param contents The contents of this path. + * @throws IOException If writing fails. + */ + public static void writeFile(WritableMount mount, String path, String contents) throws IOException { + try (var handle = Channels.newWriter(mount.openForWrite(path), StandardCharsets.UTF_8)) { + handle.write(contents); + } + } +} diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/ReadOnlyWritableMount.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/ReadOnlyWritableMount.java deleted file mode 100644 index 6282f4744..000000000 --- a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/ReadOnlyWritableMount.java +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers -// -// SPDX-License-Identifier: MPL-2.0 - -package dan200.computercraft.test.core.filesystem; - -import dan200.computercraft.api.filesystem.FileOperationException; -import dan200.computercraft.api.filesystem.Mount; -import dan200.computercraft.api.filesystem.WritableMount; - -import java.io.IOException; -import java.nio.channels.SeekableByteChannel; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.List; - -/** - * Wraps a {@link Mount} into a read-only {@link WritableMount}. - * - * @param mount The original read-only mount we're wrapping. - */ -public record ReadOnlyWritableMount(Mount mount) implements WritableMount { - @Override - public boolean exists(String path) throws IOException { - return mount.exists(path); - } - - @Override - public boolean isDirectory(String path) throws IOException { - return mount.isDirectory(path); - } - - @Override - public void list(String path, List contents) throws IOException { - mount.list(path, contents); - } - - @Override - public long getSize(String path) throws IOException { - return mount.getSize(path); - } - - @Override - public SeekableByteChannel openForRead(String path) throws IOException { - return mount.openForRead(path); - } - - @Override - public BasicFileAttributes getAttributes(String path) throws IOException { - return mount.getAttributes(path); - } - - @Override - public void makeDirectory(String path) throws IOException { - throw new FileOperationException(path, "Access denied"); - } - - @Override - public void delete(String path) throws IOException { - throw new FileOperationException(path, "Access denied"); - } - - @Override - public void rename(String source, String dest) throws IOException { - throw new FileOperationException(source, "Access denied"); - } - - @Override - public SeekableByteChannel openForWrite(String path) throws IOException { - throw new FileOperationException(path, "Access denied"); - } - - @Override - public SeekableByteChannel openForAppend(String path) throws IOException { - throw new FileOperationException(path, "Access denied"); - } - - @Override - public long getRemainingSpace() { - return Integer.MAX_VALUE; - } - - @Override - public long getCapacity() { - return Integer.MAX_VALUE; - } - - @Override - public boolean isReadOnly(String path) { - return true; - } -} diff --git a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/WritableMountContract.java b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/WritableMountContract.java index a9dc0cdea..a69668dee 100644 --- a/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/WritableMountContract.java +++ b/projects/core/src/testFixtures/java/dan200/computercraft/test/core/filesystem/WritableMountContract.java @@ -5,10 +5,16 @@ package dan200.computercraft.test.core.filesystem; import dan200.computercraft.api.filesystem.WritableMount; +import dan200.computercraft.api.lua.LuaValues; +import dan200.computercraft.test.core.ReplaceUnderscoresDisplayNameGenerator; +import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.opentest4j.TestAbortedException; import java.io.IOException; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -17,9 +23,16 @@ import static org.junit.jupiter.api.Assertions.*; * * @see MountContract */ +@DisplayNameGeneration(ReplaceUnderscoresDisplayNameGenerator.class) public interface WritableMountContract { long CAPACITY = 1_000_000; + String LONG_CONTENTS = "This is some example text.\n".repeat(100); + + static Stream fileContents() { + return Stream.of("", LONG_CONTENTS); + } + /** * Create a new empty mount. * @@ -43,18 +56,30 @@ public interface WritableMountContract { } @Test - default void testRootWritable() throws IOException { + default void Root_is_writable() throws IOException { assertFalse(createExisting(CAPACITY).mount().isReadOnly("/")); assertFalse(createMount(CAPACITY).mount().isReadOnly("/")); } @Test - default void testMissingDirWritable() throws IOException { + default void Missing_dir_is_writable() throws IOException { assertFalse(createExisting(CAPACITY).mount().isReadOnly("/foo/bar/baz/qux")); } @Test - default void testDirReadOnly() throws IOException { + default void Make_directory_recursive() throws IOException { + var access = createMount(CAPACITY); + var mount = access.mount(); + mount.makeDirectory("a/b/c"); + + assertTrue(mount.isDirectory("a/b/c")); + + assertEquals(CAPACITY - 500 * 3, mount.getRemainingSpace()); + assertEquals(access.computeRemainingSpace(), access.mount().getRemainingSpace(), "Free space is inconsistent"); + } + + @Test + default void Can_make_read_only() throws IOException { var root = createMount(CAPACITY); var mount = root.mount(); mount.makeDirectory("read-only"); @@ -66,18 +91,97 @@ public interface WritableMountContract { } @Test - default void testMovePreservesSpace() throws IOException { + default void Initial_free_space_and_capacity() throws IOException { + var mount = createExisting(CAPACITY).mount(); + assertEquals(CAPACITY, mount.getCapacity()); + assertEquals(CAPACITY, mount.getRemainingSpace()); + } + + @Test + default void Write_updates_size_and_free_space() throws IOException { var access = createExisting(CAPACITY); var mount = access.mount(); - mount.openForWrite("foo").close(); + + Mounts.writeFile(mount, "hello.txt", LONG_CONTENTS); + assertEquals(LONG_CONTENTS.length(), mount.getSize("hello.txt")); + assertEquals(CAPACITY - LONG_CONTENTS.length(), mount.getRemainingSpace()); + + Mounts.writeFile(mount, "hello.txt", ""); + assertEquals(0, mount.getSize("hello.txt")); + assertEquals(CAPACITY - 500, mount.getRemainingSpace()); + assertEquals(access.computeRemainingSpace(), access.mount().getRemainingSpace(), "Free space is inconsistent"); + } + + @Test + default void Append_jumps_to_file_end() throws IOException { + var access = createExisting(CAPACITY); + var mount = access.mount(); + + Mounts.writeFile(mount, "a.txt", "example"); + + try (var handle = mount.openForAppend("a.txt")) { + assertEquals(7, handle.position()); + handle.write(LuaValues.encode(" text")); + assertEquals(12, handle.position()); + } + + assertEquals(12, mount.getSize("a.txt")); + } + + @ParameterizedTest(name = "\"{0}\"") + @MethodSource("fileContents") + default void Move_file(String contents) throws IOException { + var access = createExisting(CAPACITY); + var mount = access.mount(); + Mounts.writeFile(mount, "src.txt", contents); var remainingSpace = mount.getRemainingSpace(); - mount.rename("foo", "bar"); + mount.rename("src.txt", "dest.txt"); + assertFalse(mount.exists("src.txt")); + assertTrue(mount.exists("dest.txt")); + + assertEquals(contents.length(), mount.getSize("dest.txt")); assertEquals(remainingSpace, mount.getRemainingSpace(), "Free space has changed after moving"); assertEquals(access.computeRemainingSpace(), access.mount().getRemainingSpace(), "Free space is inconsistent"); } + @ParameterizedTest(name = "\"{0}\"") + @MethodSource("fileContents") + default void Move_file_fails_when_destination_exists(String contents) throws IOException { + var access = createExisting(CAPACITY); + var mount = access.mount(); + Mounts.writeFile(mount, "src.txt", contents); + Mounts.writeFile(mount, "dest.txt", "dest"); + + var remainingSpace = mount.getRemainingSpace(); + + assertThrows(IOException.class, () -> mount.rename("src.txt", "dest.txt")); + + assertEquals(contents.length(), mount.getSize("src.txt")); + assertEquals(4, mount.getSize("dest.txt")); + + assertEquals(remainingSpace, mount.getRemainingSpace(), "Free space has changed despite no move occurred."); + assertEquals(access.computeRemainingSpace(), access.mount().getRemainingSpace(), "Free space is inconsistent"); + } + + @Test + default void Move_file_fails_when_source_does_not_exist() throws IOException { + var access = createExisting(CAPACITY); + var mount = access.mount(); + Mounts.writeFile(mount, "dest.txt", "dest"); + + var remainingSpace = mount.getRemainingSpace(); + + assertThrows(IOException.class, () -> mount.rename("src.txt", "dest.txt")); + + assertFalse(mount.exists("src.txt")); + assertEquals(4, mount.getSize("dest.txt")); + + assertEquals(remainingSpace, mount.getRemainingSpace(), "Free space has changed despite no move occurred."); + assertEquals(access.computeRemainingSpace(), access.mount().getRemainingSpace(), "Free space is inconsistent"); + } + /** * Wraps a {@link WritableMount} with additional operations. */ @@ -90,7 +194,7 @@ public interface WritableMountContract { WritableMount mount(); /** - * Make a path read-only. This may throw a {@link TestAbortedException} if + * Make a path read-only. This may throw a {@link TestAbortedException} if this operation is not supported. * * @param path The mount-relative path. */ diff --git a/projects/lints/src/main/kotlin/cc/tweaked/linter/LoaderOverride.kt b/projects/lints/src/main/kotlin/cc/tweaked/linter/LoaderOverride.kt index 587e6a815..d8edf0a62 100644 --- a/projects/lints/src/main/kotlin/cc/tweaked/linter/LoaderOverride.kt +++ b/projects/lints/src/main/kotlin/cc/tweaked/linter/LoaderOverride.kt @@ -23,7 +23,7 @@ internal object LoaderOverrides { private const val FABRIC_ANNOTATION: String = "dan200.computercraft.annotations.FabricOverride" fun hasOverrideAnnotation(symbol: Symbol.MethodSymbol, state: VisitorState) = - ASTHelpers.hasAnnotation(symbol, Override::class.java, state) + ASTHelpers.hasAnnotation(symbol, "java.lang.Override", state) fun getAnnotation(flags: ErrorProneFlags) = when (flags.get("ModLoader").orElse(null)) { "forge" -> FORGE_ANNOTATION