Flesh out MemoryMount into a writable mount

This moves MemoryMount to the main core module, and converts it to be a
"proper" WritableMount. It's still naively implemented - definitely
would be good to flesh out our tests in the future - but enough for what
we need it for.

We also do the following:
 - Remove the FileEntry.path variable, and instead pass the path around
   as a variable.
 - Clean up BinaryReadableHandle to use ByteBuffers in a more idiomatic
   way.
 - Add a couple more tests to our FS tests. These are in a bit of an odd
   place, where we want both Lua tests (for emulator compliance) and
   Java tests (for testing different implementations) - something to
   think about in the future.
This commit is contained in:
Jonathan Coates 2023-09-29 22:14:54 +01:00
parent e7a1065bfc
commit 96b6947ef2
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
20 changed files with 616 additions and 228 deletions

View File

@ -58,7 +58,7 @@ private void load(ResourceManager manager) {
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 @@ private void load(ResourceManager manager) {
}
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<FileEntry> {
protected static final class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
final ResourceLocation identifier;
FileEntry(String path, ResourceLocation identifier) {
super(path);
FileEntry(ResourceLocation identifier) {
this.identifier = identifier;
}
}

View File

@ -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 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 @@ private UploadResultMessage finishUpload(ServerPlayer player) {
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);

View File

@ -77,12 +77,10 @@ public final Object[] read(Optional<Integer> countArg) throws LuaException {
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 final Object[] read(Optional<Integer> countArg) throws LuaException {
List<ByteBuffer> 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 final Object[] read(Optional<Integer> countArg) throws LuaException {
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 {

View File

@ -6,10 +6,9 @@
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);
}
/**

View File

@ -28,7 +28,7 @@ public abstract class AbstractInMemoryMount<T extends AbstractInMemoryMount.File
@Nullable
protected T root;
private @Nullable T get(String path) {
protected final @Nullable T get(String path) {
var lastEntry = root;
var lastIndex = 0;
@ -57,58 +57,61 @@ public final boolean isDirectory(String path) {
@Override
public final void list(String path, List<String> 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<String, T> factory) {
@ -133,20 +136,11 @@ protected T getOrCreateChild(T lastEntry, String localPath, Function<String, T>
}
protected static class FileEntry<T extends FileEntry<T>> {
public final String path;
@Nullable
public Map<String, T> children;
protected FileEntry(String path) {
this.path = path;
}
public boolean isDirectory() {
return children != null;
}
protected void list(List<String> contents) {
if (children != null) contents.addAll(children.keySet());
}
}
}

View File

@ -41,35 +41,36 @@ public abstract class ArchiveMount<T extends ArchiveMount.FileEntry<T>> 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 @@ private byte[] getContents(T file) throws IOException {
/**
* 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<T extends FileEntry<T>> extends AbstractInMemoryMount.FileEntry<T> {
long size = -1;
protected FileEntry(String path) {
super(path);
}
}
}

View File

@ -22,7 +22,7 @@
/**
* A mount which reads zip/jar files.
*/
public class JarMount extends ArchiveMount<JarMount.FileEntry> implements Closeable {
public final class JarMount extends ArchiveMount<JarMount.FileEntry> implements Closeable {
private final ZipFile zip;
public JarMount(File jarFile, String subPath) throws IOException {
@ -42,7 +42,7 @@ public JarMount(File jarFile, String subPath) throws IOException {
}
// 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 JarMount(File jarFile, String subPath) throws IOException {
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 void close() throws IOException {
zip.close();
}
protected static class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
protected static final class FileEntry extends ArchiveMount.FileEntry<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);

View File

@ -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<MemoryMount.FileEntry> 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<FileEntry> 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<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<String, FileEntry> 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;
}
}
}

View File

@ -27,7 +27,7 @@
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<OpenOption> WRITE_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
private static final Set<OpenOption> APPEND_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.APPEND);

View File

@ -12,10 +12,9 @@
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 static void run(String program, Consumer<Computer> setup, int maxTimes) {
.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) {

View File

@ -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();
}
};
}
}

View File

@ -10,10 +10,10 @@
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.
* <p>
* 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.
* <p>
* 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.

View File

@ -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('_', ' ');
}
}

View File

@ -11,10 +11,9 @@
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) {

View File

@ -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<MemoryMount.FileEntry> {
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<FileEntry> {
@Nullable
byte[] contents;
protected FileEntry(String path) {
super(path);
}
}
}

View File

@ -34,7 +34,10 @@ public interface MountContract {
* Create a skeleton mount. This should contain the following files:
*
* <ul>
* <li>{@code dir/file.lua}, containing {@code print('testing')}.</li>
* <li>
* {@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}.
* </li>
* <li>{@code f.lua}, containing nothing.</li>
* </ul>
*

View File

@ -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);
}
}
}

View File

@ -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<String> 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;
}
}

View File

@ -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 @@
*
* @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<String> fileContents() {
return Stream.of("", LONG_CONTENTS);
}
/**
* Create a new empty mount.
*
@ -43,18 +56,30 @@ default MountAccess createExisting(long capacity) throws IOException {
}
@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 @@ default void testDirReadOnly() throws IOException {
}
@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 @@ interface MountAccess {
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.
*/

View File

@ -23,7 +23,7 @@
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