1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2024-06-16 10:09:55 +00:00

Some refactoring of mounts

- Separate FileMount into separate FileMount and WritableFileMount
   classes. This separates the (relatively simple) read-only code from
   the (soon to be even more complex) read/write code.

   It also allows you to create read-only mounts which don't bother with
   filesystem accounting, which is nice.

 - Make openForWrite/openForAppend always return a SeekableFileHandle.
   Appendable files still cannot be seeked within, but that check is now
   done on the FS side.

 - Refactor the various mount tests to live in test contract interfaces,
   allowing us to reuse them between mounts.

 - Clean up our error handling a little better. (Most) file-specific code
   has been moved to FileMount, and ArchiveMount-derived classes now
   throw correct path-localised exceptions.
This commit is contained in:
Jonathan Coates 2022-12-09 22:01:01 +00:00
parent 8007a30849
commit 367773e173
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
35 changed files with 978 additions and 656 deletions

View File

@ -61,22 +61,25 @@ public static int createUniqueNumberedSaveDir(MinecraftServer server, String par
/** /**
* Creates a file system mount that maps to a subfolder of the save directory for a given world, and returns it. * Creates a file system mount that maps to a subfolder of the save directory for a given world, and returns it.
* <p> * <p>
* Use in conjunction with IComputerAccess.mount() or IComputerAccess.mountWritable() to mount a folder from the * Use in conjunction with Use {@link IComputerAccess#mount(String, Mount)} or {@link IComputerAccess#mountWritable(String, WritableMount)}
* users save directory onto a computers file system. * to mount this on a computer's file system.
* <p>
* If the same folder may be mounted on multiple computers at once (for instance, if you provide a network file share),
* the same mount instance should be used for all computers. You should NOT have multiple mount instances for the
* same folder.
* *
* @param server The server which the save dir can be found. * @param server The server which the save dir can be found.
* @param subPath The folder path within the save directory that the mount should map to. eg: "computer/disk/42". * @param subPath The folder path within the save directory that the mount should map to. eg: "disk/42".
* Use createUniqueNumberedSaveDir() to create a new numbered folder to use. * Use {@link #createUniqueNumberedSaveDir(MinecraftServer, String)} to create a new numbered folder
* to use.
* @param capacity The amount of data that can be stored in the directory before it fills up, in bytes. * @param capacity The amount of data that can be stored in the directory before it fills up, in bytes.
* @return The mount, or null if it could be created for some reason. Use IComputerAccess.mount() or IComputerAccess.mountWritable() * @return The newly created mount.
* to mount this on a Computers' file system.
* @see #createUniqueNumberedSaveDir(MinecraftServer, String) * @see #createUniqueNumberedSaveDir(MinecraftServer, String)
* @see IComputerAccess#mount(String, Mount) * @see IComputerAccess#mount(String, Mount)
* @see IComputerAccess#mountWritable(String, WritableMount) * @see IComputerAccess#mountWritable(String, WritableMount)
* @see Mount * @see Mount
* @see WritableMount * @see WritableMount
*/ */
@Nullable
public static WritableMount createSaveDirMount(MinecraftServer server, String subPath, long capacity) { public static WritableMount createSaveDirMount(MinecraftServer server, String subPath, long capacity) {
return getInstance().createSaveDirMount(server, subPath, capacity); return getInstance().createSaveDirMount(server, subPath, capacity);
} }

View File

@ -43,7 +43,6 @@ static ComputerCraftAPIService get() {
int createUniqueNumberedSaveDir(MinecraftServer server, String parentSubPath); int createUniqueNumberedSaveDir(MinecraftServer server, String parentSubPath);
@Nullable
WritableMount createSaveDirMount(MinecraftServer server, String subPath, long capacity); WritableMount createSaveDirMount(MinecraftServer server, String subPath, long capacity);
@Nullable @Nullable

View File

@ -24,6 +24,7 @@ dependencies {
compileOnly(libs.mixin) compileOnly(libs.mixin)
annotationProcessorEverywhere(libs.autoService) annotationProcessorEverywhere(libs.autoService)
testFixturesAnnotationProcessor(libs.autoService)
testImplementation(testFixtures(project(":core"))) testImplementation(testFixtures(project(":core")))
testImplementation(libs.bundles.test) testImplementation(libs.bundles.test)

View File

@ -7,6 +7,7 @@
import dan200.computercraft.api.detail.BlockReference; import dan200.computercraft.api.detail.BlockReference;
import dan200.computercraft.api.detail.DetailRegistry; import dan200.computercraft.api.detail.DetailRegistry;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.api.filesystem.WritableMount; import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.lua.GenericSource; import dan200.computercraft.api.lua.GenericSource;
import dan200.computercraft.api.lua.ILuaAPIFactory; import dan200.computercraft.api.lua.ILuaAPIFactory;
@ -18,9 +19,10 @@
import dan200.computercraft.api.turtle.TurtleRefuelHandler; import dan200.computercraft.api.turtle.TurtleRefuelHandler;
import dan200.computercraft.core.apis.ApiFactories; import dan200.computercraft.core.apis.ApiFactories;
import dan200.computercraft.core.asm.GenericMethod; import dan200.computercraft.core.asm.GenericMethod;
import dan200.computercraft.core.filesystem.FileMount; import dan200.computercraft.core.filesystem.WritableFileMount;
import dan200.computercraft.impl.detail.DetailRegistryImpl; import dan200.computercraft.impl.detail.DetailRegistryImpl;
import dan200.computercraft.impl.network.wired.WiredNodeImpl; import dan200.computercraft.impl.network.wired.WiredNodeImpl;
import dan200.computercraft.shared.computer.core.ResourceMount;
import dan200.computercraft.shared.computer.core.ServerContext; import dan200.computercraft.shared.computer.core.ServerContext;
import dan200.computercraft.shared.details.BlockDetails; import dan200.computercraft.shared.details.BlockDetails;
import dan200.computercraft.shared.details.ItemDetails; import dan200.computercraft.shared.details.ItemDetails;
@ -57,13 +59,15 @@ public final int createUniqueNumberedSaveDir(MinecraftServer server, String pare
} }
@Override @Override
public final @Nullable WritableMount createSaveDirMount(MinecraftServer server, String subPath, long capacity) { public final WritableMount createSaveDirMount(MinecraftServer server, String subPath, long capacity) {
var root = ServerContext.get(server).storageDir().toFile(); var root = ServerContext.get(server).storageDir().toFile();
try { return new WritableFileMount(new File(root, subPath), capacity);
return new FileMount(new File(root, subPath), capacity); }
} catch (Exception e) {
return null; @Override
} public final @Nullable Mount createResourceMount(MinecraftServer server, String domain, String subPath) {
var mount = ResourceMount.get(domain, subPath, server.getResourceManager());
return mount.exists("") ? mount : null;
} }
@Override @Override

View File

@ -5,6 +5,8 @@
*/ */
package dan200.computercraft.shared.computer.core; package dan200.computercraft.shared.computer.core;
import com.google.common.annotations.VisibleForTesting;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.core.filesystem.ArchiveMount; import dan200.computercraft.core.filesystem.ArchiveMount;
import dan200.computercraft.core.filesystem.FileSystem; import dan200.computercraft.core.filesystem.FileSystem;
import net.minecraft.ResourceLocationException; import net.minecraft.ResourceLocationException;
@ -16,7 +18,6 @@
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -49,7 +50,8 @@ public static ResourceMount get(String namespace, String subPath, ResourceManage
} }
} }
private ResourceMount(String namespace, String subPath, ResourceManager manager) { @VisibleForTesting
ResourceMount(String namespace, String subPath, ResourceManager manager) {
this.namespace = namespace; this.namespace = namespace;
this.subPath = subPath; this.subPath = subPath;
load(manager); load(manager);
@ -59,7 +61,7 @@ private void load(ResourceManager manager) {
var hasAny = false; var hasAny = false;
String existingNamespace = null; 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()) { for (var file : manager.listResources(subPath, s -> true).keySet()) {
existingNamespace = file.getNamespace(); existingNamespace = file.getNamespace();
@ -100,7 +102,7 @@ private void create(FileEntry lastEntry, String path) {
LOG.warn("Cannot create resource location for {} ({})", part, e.getMessage()); LOG.warn("Cannot create resource location for {} ({})", part, e.getMessage());
return; return;
} }
lastEntry.children.put(part, nextEntry = new FileEntry(childPath)); lastEntry.children.put(part, nextEntry = new FileEntry(path, childPath));
} }
lastEntry = nextEntry; lastEntry = nextEntry;
@ -129,7 +131,7 @@ public long getSize(FileEntry file) {
@Override @Override
public byte[] getContents(FileEntry file) throws IOException { public byte[] getContents(FileEntry file) throws IOException {
var resource = manager.getResource(file.identifier).orElse(null); var resource = manager.getResource(file.identifier).orElse(null);
if (resource == null) throw new FileNotFoundException(NO_SUCH_FILE); if (resource == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
try (var stream = resource.open()) { try (var stream = resource.open()) {
return stream.readAllBytes(); return stream.readAllBytes();
@ -139,7 +141,8 @@ public byte[] getContents(FileEntry file) throws IOException {
protected static class FileEntry extends ArchiveMount.FileEntry<FileEntry> { protected static class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
final ResourceLocation identifier; final ResourceLocation identifier;
FileEntry(ResourceLocation identifier) { FileEntry(String path, ResourceLocation identifier) {
super(path);
this.identifier = identifier; this.identifier = identifier;
} }
} }

View File

@ -25,6 +25,7 @@
public class UploadFileMessage extends ComputerServerMessage { public class UploadFileMessage extends ComputerServerMessage {
public static final int MAX_SIZE = 512 * 1024; public static final int MAX_SIZE = 512 * 1024;
static final int MAX_PACKET_SIZE = 30 * 1024; // Max packet size is 32767. static final int MAX_PACKET_SIZE = 30 * 1024; // Max packet size is 32767.
private static final int HEADER_SIZE = 16 + 1; // 16 bytes for the UUID, 4 for the flag.
public static final int MAX_FILES = 32; public static final int MAX_FILES = 32;
public static final int MAX_FILE_NAME = 128; public static final int MAX_FILE_NAME = 128;
@ -120,8 +121,8 @@ public void toBytes(FriendlyByteBuf buf) {
public static void send(AbstractContainerMenu container, List<FileUpload> files, Consumer<UploadFileMessage> send) { public static void send(AbstractContainerMenu container, List<FileUpload> files, Consumer<UploadFileMessage> send) {
var uuid = UUID.randomUUID(); var uuid = UUID.randomUUID();
var remaining = MAX_PACKET_SIZE; var remaining = MAX_PACKET_SIZE - HEADER_SIZE;
for (var file : files) remaining -= file.getName().length() * 4 + FileUpload.CHECKSUM_LENGTH; for (var file : files) remaining -= 4 + file.getName().length() * 4 + FileUpload.CHECKSUM_LENGTH;
var first = true; var first = true;
List<FileSlice> slices = new ArrayList<>(files.size()); List<FileSlice> slices = new ArrayList<>(files.size());
@ -137,7 +138,7 @@ public static void send(AbstractContainerMenu container, List<FileUpload> files,
? new UploadFileMessage(container, uuid, FLAG_FIRST, files, new ArrayList<>(slices)) ? new UploadFileMessage(container, uuid, FLAG_FIRST, files, new ArrayList<>(slices))
: new UploadFileMessage(container, uuid, 0, null, new ArrayList<>(slices))); : new UploadFileMessage(container, uuid, 0, null, new ArrayList<>(slices)));
slices.clear(); slices.clear();
remaining = MAX_PACKET_SIZE; remaining = MAX_PACKET_SIZE - HEADER_SIZE;
first = false; first = false;
} }

View File

@ -202,6 +202,7 @@ void ejectDisk() {
ejectQueued.set(true); ejectQueued.set(true);
} }
@GuardedBy("this")
private void mountDisk(IComputerAccess computer, MountInfo info, MediaStack disk) { private void mountDisk(IComputerAccess computer, MountInfo info, MediaStack disk) {
var mount = disk.getMount((ServerLevel) getLevel()); var mount = disk.getMount((ServerLevel) getLevel());
if (mount != null) { if (mount != null) {

View File

@ -8,7 +8,6 @@
import com.google.auto.service.AutoService; import com.google.auto.service.AutoService;
import com.mojang.authlib.GameProfile; import com.mojang.authlib.GameProfile;
import com.mojang.brigadier.arguments.ArgumentType; import com.mojang.brigadier.arguments.ArgumentType;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.api.network.wired.WiredElement; import dan200.computercraft.api.network.wired.WiredElement;
import dan200.computercraft.api.peripheral.IPeripheral; import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.impl.AbstractComputerCraftAPI; import dan200.computercraft.impl.AbstractComputerCraftAPI;
@ -221,12 +220,6 @@ public String getInstalledVersion() {
return "1.0"; return "1.0";
} }
@Nullable
@Override
public Mount createResourceMount(MinecraftServer server, String domain, String subPath) {
throw new UnsupportedOperationException("Cannot create resource mount");
}
@Nullable @Nullable
@Override @Override
public <T> T tryGetRegistryObject(ResourceKey<Registry<T>> registry, ResourceLocation id) { public <T> T tryGetRegistryObject(ResourceKey<Registry<T>> registry, ResourceLocation id) {

View File

@ -5,73 +5,59 @@
*/ */
package dan200.computercraft.shared.computer.core; package dan200.computercraft.shared.computer.core;
import com.google.common.io.MoreFiles;
import com.google.common.io.RecursiveDeleteOption;
import dan200.computercraft.api.filesystem.Mount; import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.test.core.CloseScope;
import dan200.computercraft.test.core.filesystem.MountContract;
import net.minecraft.Util; import net.minecraft.Util;
import net.minecraft.server.packs.PackType; import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.PathPackResources; import net.minecraft.server.packs.PathPackResources;
import net.minecraft.server.packs.resources.ReloadableResourceManager; import net.minecraft.server.packs.resources.ReloadableResourceManager;
import net.minecraft.util.Unit; import net.minecraft.util.Unit;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import static org.junit.jupiter.api.Assertions.*; public class ResourceMountTest implements MountContract {
private final CloseScope toClose = new CloseScope();
public class ResourceMountTest { @Override
private Mount mount; public Mount createSkeleton() throws IOException {
var path = Files.createTempDirectory("cctweaked-test");
toClose.add(() -> MoreFiles.deleteRecursively(path, RecursiveDeleteOption.ALLOW_INSECURE));
Files.createDirectories(path.resolve("data/computercraft/rom/dir"));
try (var writer = Files.newBufferedWriter(path.resolve("data/computercraft/rom/dir/file.lua"))) {
writer.write("print('testing')");
}
Files.newBufferedWriter(path.resolve("data/computercraft/rom/f.lua")).close();
@BeforeEach
public void before() {
var manager = new ReloadableResourceManager(PackType.SERVER_DATA); var manager = new ReloadableResourceManager(PackType.SERVER_DATA);
var done = new CompletableFuture<Unit>(); var reload = manager.createReload(Util.backgroundExecutor(), Util.backgroundExecutor(), CompletableFuture.completedFuture(Unit.INSTANCE), List.of(
manager.createReload(Util.backgroundExecutor(), Util.backgroundExecutor(), done, List.of( new PathPackResources("resources", path, false)
new PathPackResources("resources", Path.of("../core/src/main/resources"), false)
)); ));
mount = ResourceMount.get("computercraft", "lua/rom", manager); try {
reload.done().get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException("Failed to load resources", e);
}
return new ResourceMount("computercraft", "rom", manager);
} }
@Test @Override
public void testList() throws IOException { public boolean hasFileTimes() {
List<String> files = new ArrayList<>(); return false;
mount.list("", files);
files.sort(Comparator.naturalOrder());
assertEquals(
Arrays.asList("apis", "autorun", "help", "modules", "motd.txt", "programs", "startup.lua"),
files
);
} }
@Test @AfterEach
public void testExists() throws IOException { public void after() throws Exception {
assertTrue(mount.exists("")); toClose.close();
assertTrue(mount.exists("startup.lua"));
assertTrue(mount.exists("programs/fun/advanced/paint.lua"));
assertFalse(mount.exists("programs/fun/advance/paint.lua"));
assertFalse(mount.exists("programs/fun/advanced/paint.lu"));
}
@Test
public void testIsDir() throws IOException {
assertTrue(mount.isDirectory(""));
}
@Test
public void testIsFile() throws IOException {
assertFalse(mount.isDirectory("startup.lua"));
}
@Test
public void testSize() throws IOException {
assertNotEquals(mount.getSize("startup.lua"), 0);
} }
} }

View File

@ -9,7 +9,7 @@
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.channels.WritableByteChannel; import java.nio.channels.SeekableByteChannel;
/** /**
* Represents a part of a virtual filesystem that can be mounted onto a computer using {@link IComputerAccess#mount(String, Mount)} * Represents a part of a virtual filesystem that can be mounted onto a computer using {@link IComputerAccess#mount(String, Mount)}
@ -54,21 +54,19 @@ public interface WritableMount extends Mount {
* Opens a file with a given path, and returns an {@link OutputStream} for writing to it. * Opens a file with a given path, and returns an {@link OutputStream} for writing to it.
* *
* @param path A file path in normalised format, relative to the mount location. ie: "programs/myprogram". * @param path A file path in normalised format, relative to the mount location. ie: "programs/myprogram".
* @return A stream for writing to. If the channel implements {@link java.nio.channels.SeekableByteChannel}, one * @return A stream for writing to.
* will be able to seek to arbitrary positions when using binary mode.
* @throws IOException If the file could not be opened for writing. * @throws IOException If the file could not be opened for writing.
*/ */
WritableByteChannel openForWrite(String path) throws IOException; SeekableByteChannel openForWrite(String path) throws IOException;
/** /**
* Opens a file with a given path, and returns an {@link OutputStream} for appending to it. * Opens a file with a given path, and returns an {@link OutputStream} for appending to it.
* *
* @param path A file path in normalised format, relative to the mount location. ie: "programs/myprogram". * @param path A file path in normalised format, relative to the mount location. ie: "programs/myprogram".
* @return A stream for writing to. If the channel implements {@link java.nio.channels.SeekableByteChannel}, one * @return A stream for writing to.
* will be able to seek to arbitrary positions when using binary mode.
* @throws IOException If the file could not be opened for writing. * @throws IOException If the file could not be opened for writing.
*/ */
WritableByteChannel openForAppend(String path) throws IOException; SeekableByteChannel openForAppend(String path) throws IOException;
/** /**
* Get the amount of free space on the mount, in bytes. You should decrease this value as the user writes to the * Get the amount of free space on the mount, in bytes. You should decrease this value as the user writes to the

View File

@ -391,12 +391,12 @@ public final Object[] open(String path, String mode) throws LuaException {
case "wb" -> { case "wb" -> {
// Open the file for binary writing, then create a wrapper around the writer // Open the file for binary writing, then create a wrapper around the writer
var writer = getFileSystem().openForWrite(path, false, Function.identity()); var writer = getFileSystem().openForWrite(path, false, Function.identity());
return new Object[]{ BinaryWritableHandle.of(writer.get(), writer) }; return new Object[]{ BinaryWritableHandle.of(writer.get(), writer, true) };
} }
case "ab" -> { case "ab" -> {
// Open the file for binary appending, then create a wrapper around the reader // Open the file for binary appending, then create a wrapper around the reader
var writer = getFileSystem().openForWrite(path, true, Function.identity()); var writer = getFileSystem().openForWrite(path, true, Function.identity());
return new Object[]{ BinaryWritableHandle.of(writer.get(), writer) }; return new Object[]{ BinaryWritableHandle.of(writer.get(), writer, false) };
} }
default -> throw new LuaException("Unsupported mode"); default -> throw new LuaException("Unsupported mode");
} }

View File

@ -10,14 +10,12 @@
import dan200.computercraft.api.lua.LuaFunction; import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.api.lua.LuaValues; import dan200.computercraft.api.lua.LuaValues;
import dan200.computercraft.core.filesystem.TrackingCloseable; import dan200.computercraft.core.filesystem.TrackingCloseable;
import dan200.computercraft.core.util.Nullability;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.channels.SeekableByteChannel; import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.util.Optional; import java.util.Optional;
/** /**
@ -27,23 +25,16 @@
* @cc.module fs.BinaryWriteHandle * @cc.module fs.BinaryWriteHandle
*/ */
public class BinaryWritableHandle extends HandleGeneric { public class BinaryWritableHandle extends HandleGeneric {
private final WritableByteChannel writer; final SeekableByteChannel channel;
final @Nullable SeekableByteChannel seekable;
private final ByteBuffer single = ByteBuffer.allocate(1); private final ByteBuffer single = ByteBuffer.allocate(1);
protected BinaryWritableHandle(WritableByteChannel writer, @Nullable SeekableByteChannel seekable, TrackingCloseable closeable) { protected BinaryWritableHandle(SeekableByteChannel channel, TrackingCloseable closeable) {
super(closeable); super(closeable);
this.writer = writer; this.channel = channel;
this.seekable = seekable;
} }
public static BinaryWritableHandle of(WritableByteChannel channel, TrackingCloseable closeable) { public static BinaryWritableHandle of(SeekableByteChannel channel, TrackingCloseable closeable, boolean canSeek) {
var seekable = asSeekable(channel); return canSeek ? new Seekable(channel, closeable) : new BinaryWritableHandle(channel, closeable);
return seekable == null ? new BinaryWritableHandle(channel, null, closeable) : new Seekable(seekable, closeable);
}
public static BinaryWritableHandle of(WritableByteChannel channel) {
return of(channel, new TrackingCloseable.Impl(channel));
} }
/** /**
@ -66,9 +57,9 @@ public final void write(IArguments arguments) throws LuaException {
single.put((byte) number); single.put((byte) number);
single.flip(); single.flip();
writer.write(single); channel.write(single);
} else if (arg instanceof String) { } else if (arg instanceof String) {
writer.write(arguments.getBytes(0)); channel.write(arguments.getBytes(0));
} else { } else {
throw LuaValues.badArgumentOf(0, "string or number", arg); throw LuaValues.badArgumentOf(0, "string or number", arg);
} }
@ -87,15 +78,15 @@ public final void flush() throws LuaException {
checkOpen(); checkOpen();
try { try {
// Technically this is not needed // Technically this is not needed
if (writer instanceof FileChannel channel) channel.force(false); if (channel instanceof FileChannel channel) channel.force(false);
} catch (IOException e) { } catch (IOException e) {
throw new LuaException(e.getMessage()); throw new LuaException(e.getMessage());
} }
} }
public static class Seekable extends BinaryWritableHandle { public static class Seekable extends BinaryWritableHandle {
public Seekable(SeekableByteChannel seekable, TrackingCloseable closeable) { public Seekable(SeekableByteChannel channel, TrackingCloseable closeable) {
super(seekable, seekable, closeable); super(channel, closeable);
} }
/** /**
@ -121,7 +112,7 @@ public Seekable(SeekableByteChannel seekable, TrackingCloseable closeable) {
@LuaFunction @LuaFunction
public final Object[] seek(Optional<String> whence, Optional<Long> offset) throws LuaException { public final Object[] seek(Optional<String> whence, Optional<Long> offset) throws LuaException {
checkOpen(); checkOpen();
return handleSeek(Nullability.assertNonNull(seekable), whence, offset); return handleSeek(channel, whence, offset);
} }
} }
} }

View File

@ -12,7 +12,6 @@
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.io.IOException; import java.io.IOException;
import java.nio.channels.Channel;
import java.nio.channels.SeekableByteChannel; import java.nio.channels.SeekableByteChannel;
import java.util.Optional; import java.util.Optional;
@ -75,16 +74,4 @@ protected static Object[] handleSeek(SeekableByteChannel channel, Optional<Strin
return null; return null;
} }
} }
@Nullable
protected static SeekableByteChannel asSeekable(Channel channel) {
if (!(channel instanceof SeekableByteChannel seekable)) return null;
try {
seekable.position(seekable.position());
return seekable;
} catch (IOException | UnsupportedOperationException e) {
return null;
}
}
} }

View File

@ -6,7 +6,7 @@
package dan200.computercraft.core.computer; package dan200.computercraft.core.computer;
import dan200.computercraft.api.filesystem.WritableMount; import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.core.filesystem.FileMount; import dan200.computercraft.core.filesystem.WritableFileMount;
import dan200.computercraft.core.metrics.MetricsObserver; import dan200.computercraft.core.metrics.MetricsObserver;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -38,7 +38,7 @@ public interface ComputerEnvironment {
* Construct the mount for this computer's user-writable data. * Construct the mount for this computer's user-writable data.
* *
* @return The constructed mount or {@code null} if the mount could not be created. * @return The constructed mount or {@code null} if the mount could not be created.
* @see FileMount * @see WritableFileMount
*/ */
@Nullable @Nullable
WritableMount createRootMount(); WritableMount createRootMount();

View File

@ -27,6 +27,7 @@
*/ */
public abstract class ArchiveMount<T extends ArchiveMount.FileEntry<T>> implements Mount { public abstract class ArchiveMount<T extends ArchiveMount.FileEntry<T>> implements Mount {
protected static final String NO_SUCH_FILE = "No such file"; protected static final String NO_SUCH_FILE = "No such file";
/** /**
* Limit the entire cache to 64MiB. * Limit the entire cache to 64MiB.
*/ */
@ -103,7 +104,7 @@ private long getCachedSize(T file) throws IOException {
* <p> * <p>
* This should only be called once per file, as the result is cached in {@link #getSize(String)}. * This should only be called once per file, as the result is cached in {@link #getSize(String)}.
* *
* @param file The file to compute the size of. * @param file The file to compute the size of. This will not be a directory.
* @return The size of the file. * @return The size of the file.
* @throws IOException If the size could not be read. * @throws IOException If the size could not be read.
*/ */
@ -125,7 +126,7 @@ public SeekableByteChannel openForRead(String path) throws IOException {
/** /**
* Read the entirety of a file into memory. * Read the entirety of a file into memory.
* *
* @param file The file to read into memory. * @param file The file to read into memory. This will not be a directory.
* @return The contents of the file. * @return The contents of the file.
*/ */
protected abstract byte[] getContents(T file) throws IOException; protected abstract byte[] getContents(T file) throws IOException;
@ -141,7 +142,7 @@ public final BasicFileAttributes getAttributes(String path) throws IOException {
/** /**
* Get all attributes of the file. * Get all attributes of the file.
* *
* @param file The file to compute attributes for. * @param file The file to compute attributes for. This will not be a directory.
* @return The file's attributes. * @return The file's attributes.
* @throws IOException If the attributes could not be read. * @throws IOException If the attributes could not be read.
*/ */
@ -150,11 +151,16 @@ protected BasicFileAttributes getAttributes(T file) throws IOException {
} }
protected static class FileEntry<T extends ArchiveMount.FileEntry<T>> { protected static class FileEntry<T extends ArchiveMount.FileEntry<T>> {
public final String path;
@Nullable @Nullable
public Map<String, T> children; public Map<String, T> children;
long size = -1; long size = -1;
protected FileEntry(String path) {
this.path = path;
}
protected boolean isDirectory() { protected boolean isDirectory() {
return children != null; return children != null;
} }

View File

@ -5,370 +5,141 @@
*/ */
package dan200.computercraft.core.filesystem; package dan200.computercraft.core.filesystem;
import com.google.common.collect.Sets; import dan200.computercraft.api.filesystem.FileAttributes;
import dan200.computercraft.api.filesystem.FileOperationException; import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.WritableMount; import dan200.computercraft.api.filesystem.Mount;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.NonReadableChannelException;
import java.nio.channels.SeekableByteChannel; import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel; import java.nio.file.FileSystemException;
import java.nio.file.*; import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
public class FileMount implements WritableMount { /**
private static final Logger LOG = LoggerFactory.getLogger(FileMount.class); * A {@link Mount} implementation which provides read-only access to a directory.
private static final long MINIMUM_FILE_SIZE = 500; */
public class FileMount implements Mount {
private static final Set<OpenOption> READ_OPTIONS = Collections.singleton(StandardOpenOption.READ); private static final Set<OpenOption> READ_OPTIONS = Collections.singleton(StandardOpenOption.READ);
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);
private class WritableCountingChannel implements WritableByteChannel { protected final Path root;
private final WritableByteChannel inner; public FileMount(Path root) {
long ignoredBytesLeft; this.root = root;
WritableCountingChannel(WritableByteChannel inner, long bytesToIgnore) {
this.inner = inner;
ignoredBytesLeft = bytesToIgnore;
}
@Override
public int write(ByteBuffer b) throws IOException {
count(b.remaining());
return inner.write(b);
}
void count(long n) throws IOException {
ignoredBytesLeft -= n;
if (ignoredBytesLeft < 0) {
var newBytes = -ignoredBytesLeft;
ignoredBytesLeft = 0;
var bytesLeft = capacity - usedSpace;
if (newBytes > bytesLeft) throw new IOException("Out of space");
usedSpace += newBytes;
}
}
@Override
public boolean isOpen() {
return inner.isOpen();
}
@Override
public void close() throws IOException {
inner.close();
}
} }
private class SeekableCountingChannel extends WritableCountingChannel implements SeekableByteChannel { /**
private final SeekableByteChannel inner; * Resolve a mount-relative path to one on the file system.
*
SeekableCountingChannel(SeekableByteChannel inner, long bytesToIgnore) { * @param path The path to resolve.
super(inner, bytesToIgnore); * @return The resolved path.
this.inner = inner; */
} protected Path resolvePath(String path) {
return root.resolve(path);
@Override
public SeekableByteChannel position(long newPosition) throws IOException {
if (!isOpen()) throw new ClosedChannelException();
if (newPosition < 0) {
throw new IllegalArgumentException("Cannot seek before the beginning of the stream");
}
var delta = newPosition - inner.position();
if (delta < 0) {
ignoredBytesLeft -= delta;
} else {
count(delta);
}
return inner.position(newPosition);
}
@Override
public SeekableByteChannel truncate(long size) throws IOException {
throw new IOException("Not yet implemented");
}
@Override
public int read(ByteBuffer dst) throws ClosedChannelException {
if (!inner.isOpen()) throw new ClosedChannelException();
throw new NonReadableChannelException();
}
@Override
public long position() throws IOException {
return inner.position();
}
@Override
public long size() throws IOException {
return inner.size();
}
} }
private final File rootPath; protected boolean created() {
private final long capacity; return Files.exists(root);
private long usedSpace;
public FileMount(File rootPath, long capacity) {
this.rootPath = rootPath;
this.capacity = capacity + MINIMUM_FILE_SIZE;
usedSpace = created() ? measureUsedSpace(this.rootPath) : MINIMUM_FILE_SIZE;
} }
// IMount implementation
@Override @Override
public boolean exists(String path) { public boolean exists(String path) {
if (!created()) return path.isEmpty(); return path.isEmpty() || Files.exists(resolvePath(path));
var file = getRealPath(path);
return file.exists();
} }
@Override @Override
public boolean isDirectory(String path) { public boolean isDirectory(String path) {
if (!created()) return path.isEmpty(); return path.isEmpty() || Files.isDirectory(resolvePath(path));
var file = getRealPath(path);
return file.exists() && file.isDirectory();
} }
@Override @Override
public boolean isReadOnly(String path) throws IOException { public void list(String path, List<String> contents) throws FileOperationException {
var file = getRealPath(path); if (path.isEmpty() && !created()) return;
while (true) {
if (file.exists()) return !file.canWrite(); try (var stream = Files.newDirectoryStream(resolvePath(path))) {
if (file.equals(rootPath)) return false; stream.forEach(x -> contents.add(x.getFileName().toString()));
file = file.getParentFile(); } catch (IOException e) {
throw remapException(path, e);
} }
} }
@Override @Override
public void list(String path, List<String> contents) throws IOException { public long getSize(String path) throws FileOperationException {
if (!created()) { var attributes = getAttributes(path);
if (!path.isEmpty()) throw new FileOperationException(path, "Not a directory"); return attributes.isDirectory() ? 0 : attributes.size();
return;
}
var file = getRealPath(path);
if (!file.exists() || !file.isDirectory()) throw new FileOperationException(path, "Not a directory");
var paths = file.list();
for (var subPath : paths) {
if (new File(file, subPath).exists()) contents.add(subPath);
}
} }
@Override @Override
public long getSize(String path) throws IOException { public BasicFileAttributes getAttributes(String path) throws FileOperationException {
if (!created()) { if (path.isEmpty() && !created()) return new FileAttributes(true, 0);
if (path.isEmpty()) return 0;
} else {
var file = getRealPath(path);
if (file.exists()) return file.isDirectory() ? 0 : file.length();
}
throw new FileOperationException(path, "No such file");
}
@Override
public SeekableByteChannel openForRead(String path) throws IOException {
if (created()) {
var file = getRealPath(path);
if (file.exists() && !file.isDirectory()) return Files.newByteChannel(file.toPath(), READ_OPTIONS);
}
throw new FileOperationException(path, "No such file");
}
@Override
public BasicFileAttributes getAttributes(String path) throws IOException {
if (created()) {
var file = getRealPath(path);
if (file.exists()) return Files.readAttributes(file.toPath(), BasicFileAttributes.class);
}
throw new FileOperationException(path, "No such file");
}
// IWritableMount implementation
@Override
public void makeDirectory(String path) throws IOException {
create();
var file = getRealPath(path);
if (file.exists()) {
if (!file.isDirectory()) throw new FileOperationException(path, "File exists");
return;
}
var dirsToCreate = 1;
var parent = file.getParentFile();
while (!parent.exists()) {
++dirsToCreate;
parent = parent.getParentFile();
}
if (getRemainingSpace() < dirsToCreate * MINIMUM_FILE_SIZE) {
throw new FileOperationException(path, "Out of space");
}
if (file.mkdirs()) {
usedSpace += dirsToCreate * MINIMUM_FILE_SIZE;
} else {
throw new FileOperationException(path, "Access denied");
}
}
@Override
public void delete(String path) throws IOException {
if (path.isEmpty()) throw new FileOperationException(path, "Access denied");
if (created()) {
var file = getRealPath(path);
if (file.exists()) deleteRecursively(file);
}
}
private void deleteRecursively(File file) throws IOException {
// Empty directories first
if (file.isDirectory()) {
var children = file.list();
for (var aChildren : children) {
deleteRecursively(new File(file, aChildren));
}
}
// Then delete
var fileSize = file.isDirectory() ? 0 : file.length();
var success = file.delete();
if (success) {
usedSpace -= Math.max(MINIMUM_FILE_SIZE, fileSize);
} else {
throw new IOException("Access denied");
}
}
@Override
public void rename(String source, String dest) throws IOException {
var sourceFile = getRealPath(source);
var destFile = getRealPath(dest);
if (!sourceFile.exists()) throw new FileOperationException(source, "No such file");
if (destFile.exists()) throw new FileOperationException(dest, "File exists");
var sourcePath = sourceFile.toPath();
var destPath = destFile.toPath();
if (destPath.startsWith(sourcePath)) {
throw new FileOperationException(source, "Cannot move a directory inside itself");
}
Files.move(sourcePath, destPath);
}
@Override
public WritableByteChannel openForWrite(String path) throws IOException {
create();
var file = getRealPath(path);
if (file.exists() && file.isDirectory()) throw new FileOperationException(path, "Cannot write to directory");
if (file.exists()) {
usedSpace -= Math.max(file.length(), MINIMUM_FILE_SIZE);
} else if (getRemainingSpace() < MINIMUM_FILE_SIZE) {
throw new FileOperationException(path, "Out of space");
}
usedSpace += MINIMUM_FILE_SIZE;
return new SeekableCountingChannel(Files.newByteChannel(file.toPath(), WRITE_OPTIONS), MINIMUM_FILE_SIZE);
}
@Override
public WritableByteChannel openForAppend(String path) throws IOException {
if (!created()) {
throw new FileOperationException(path, "No such file");
}
var file = getRealPath(path);
if (!file.exists()) throw new FileOperationException(path, "No such file");
if (file.isDirectory()) throw new FileOperationException(path, "Cannot write to directory");
// Allowing seeking when appending is not recommended, so we use a separate channel.
return new WritableCountingChannel(
Files.newByteChannel(file.toPath(), APPEND_OPTIONS),
Math.max(MINIMUM_FILE_SIZE - file.length(), 0)
);
}
@Override
public long getRemainingSpace() {
return Math.max(capacity - usedSpace, 0);
}
@Override
public long getCapacity() {
return capacity - MINIMUM_FILE_SIZE;
}
private File getRealPath(String path) {
return new File(rootPath, path);
}
private boolean created() {
return rootPath.exists();
}
private void create() throws IOException {
if (!rootPath.exists()) {
var success = rootPath.mkdirs();
if (!success) {
throw new IOException("Access denied");
}
}
}
private static class Visitor extends SimpleFileVisitor<Path> {
long size;
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
size += MINIMUM_FILE_SIZE;
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
size += Math.max(attrs.size(), MINIMUM_FILE_SIZE);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
LOG.error("Error computing file size for {}", file, exc);
return FileVisitResult.CONTINUE;
}
}
private static long measureUsedSpace(File file) {
if (!file.exists()) return 0;
try { try {
var visitor = new Visitor(); return Files.readAttributes(resolvePath(path), BasicFileAttributes.class);
Files.walkFileTree(file.toPath(), visitor);
return visitor.size;
} catch (IOException e) { } catch (IOException e) {
LOG.error("Error computing file size for {}", file, e); throw remapException(path, e);
return 0;
} }
} }
@Override
public SeekableByteChannel openForRead(String path) throws FileOperationException {
var file = resolvePath(path);
if (!Files.isRegularFile(file)) throw new FileOperationException(path, "No such file");
try {
return Files.newByteChannel(file, READ_OPTIONS);
} catch (IOException e) {
throw remapException(path, e);
}
}
/**
* Remap a {@link IOException} to a friendlier {@link FileOperationException}.
*
* @param fallbackPath The path currently being operated on. This is used in errors when we cannot determine a more accurate path.
* @param exn The exception that occurred.
* @return The wrapped exception.
*/
protected FileOperationException remapException(String fallbackPath, IOException exn) {
return exn instanceof FileSystemException fsExn
? remapException(fallbackPath, fsExn)
: new FileOperationException(fallbackPath, exn.getMessage() == null ? "Operation failed" : exn.getMessage());
}
/**
* Remap a {@link FileSystemException} to a friendlier {@link FileOperationException}, attempting to remap the path
* provided.
*
* @param fallbackPath The path currently being operated on. This is used in errors when we cannot determine a more accurate path.
* @param exn The exception that occurred.
* @return The wrapped exception.
*/
protected FileOperationException remapException(String fallbackPath, FileSystemException exn) {
var reason = getReason(exn);
var failedFile = exn.getFile();
if (failedFile == null) return new FileOperationException(fallbackPath, reason);
var failedPath = Path.of(failedFile);
return failedPath.startsWith(root)
? new FileOperationException(root.relativize(failedPath).toString(), reason)
: new FileOperationException(fallbackPath, reason);
}
/**
* Get the user-friendly reason for a {@link FileSystemException}.
*
* @param exn The exception that occurred.
* @return The friendly reason for this exception.
*/
protected String getReason(FileSystemException exn) {
if (exn instanceof FileAlreadyExistsException) return "File exists";
if (exn instanceof NoSuchFileException) return "No such file";
if (exn instanceof NotDirectoryException) return "Not a directory";
if (exn instanceof AccessDeniedException) return "Access denied";
var reason = exn.getReason();
return reason != null ? reason.trim() : "Operation failed";
}
} }

View File

@ -19,7 +19,6 @@
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
import java.nio.channels.Channel; import java.nio.channels.Channel;
import java.nio.channels.SeekableByteChannel; import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.AccessDeniedException; import java.nio.file.AccessDeniedException;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.*; import java.util.*;
@ -354,7 +353,7 @@ public synchronized <T extends Closeable> FileSystemWrapper<T> openForRead(Strin
return openFile(mount, channel, open.apply(channel)); return openFile(mount, channel, open.apply(channel));
} }
public synchronized <T extends Closeable> FileSystemWrapper<T> openForWrite(String path, boolean append, Function<WritableByteChannel, T> open) throws FileSystemException { public synchronized <T extends Closeable> FileSystemWrapper<T> openForWrite(String path, boolean append, Function<SeekableByteChannel, T> open) throws FileSystemException {
cleanup(); cleanup();
path = sanitizePath(path); path = sanitizePath(path);

View File

@ -5,6 +5,7 @@
*/ */
package dan200.computercraft.core.filesystem; package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.core.util.Nullability; import dan200.computercraft.core.util.Nullability;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -42,7 +43,7 @@ public JarMount(File jarFile, String subPath) throws IOException {
} }
// Read in all the entries // Read in all the entries
root = new FileEntry(); root = new FileEntry("");
var zipEntries = zip.entries(); var zipEntries = zip.entries();
while (zipEntries.hasMoreElements()) { while (zipEntries.hasMoreElements()) {
var entry = zipEntries.nextElement(); var entry = zipEntries.nextElement();
@ -68,7 +69,7 @@ private void create(ZipEntry entry, String localPath) {
var nextEntry = lastEntry.children.get(part); var nextEntry = lastEntry.children.get(part);
if (nextEntry == null || !nextEntry.isDirectory()) { if (nextEntry == null || !nextEntry.isDirectory()) {
lastEntry.children.put(part, nextEntry = new FileEntry()); lastEntry.children.put(part, nextEntry = new FileEntry(localPath.substring(0, nextIndex)));
} }
lastEntry = nextEntry; lastEntry = nextEntry;
@ -79,26 +80,26 @@ private void create(ZipEntry entry, String localPath) {
} }
@Override @Override
protected long getSize(FileEntry file) throws IOException { protected long getSize(FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileNotFoundException(NO_SUCH_FILE); if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
return file.zipEntry.getSize(); return file.zipEntry.getSize();
} }
@Override @Override
protected byte[] getContents(FileEntry file) throws IOException { protected byte[] getContents(FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileNotFoundException(NO_SUCH_FILE); if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
try (var stream = zip.getInputStream(file.zipEntry)) { try (var stream = zip.getInputStream(file.zipEntry)) {
return stream.readAllBytes(); return stream.readAllBytes();
} catch (IOException e) { } catch (IOException e) {
// Mask other IO exceptions as a non-existent file. // Mask other IO exceptions as a non-existent file.
throw new FileNotFoundException(NO_SUCH_FILE); throw new FileOperationException(file.path, NO_SUCH_FILE);
} }
} }
@Override @Override
public BasicFileAttributes getAttributes(FileEntry file) throws IOException { public BasicFileAttributes getAttributes(FileEntry file) throws FileOperationException {
if (file.zipEntry == null) throw new FileNotFoundException(NO_SUCH_FILE); if (file.zipEntry == null) throw new FileOperationException(file.path, NO_SUCH_FILE);
return new ZipEntryAttributes(file.zipEntry); return new ZipEntryAttributes(file.zipEntry);
} }
@ -111,6 +112,10 @@ protected static class FileEntry extends ArchiveMount.FileEntry<FileEntry> {
@Nullable @Nullable
ZipEntry zipEntry; ZipEntry zipEntry;
protected FileEntry(String path) {
super(path);
}
void setup(ZipEntry entry) { void setup(ZipEntry entry) {
zipEntry = entry; zipEntry = entry;
size = entry.getSize(); size = entry.getSize();

View File

@ -12,10 +12,6 @@
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.io.IOException; import java.io.IOException;
import java.nio.channels.SeekableByteChannel; import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.NoSuchFileException;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.List; import java.util.List;
import java.util.OptionalLong; import java.util.OptionalLong;
@ -158,8 +154,6 @@ public void delete(String path) throws FileSystemException {
if (mount.exists(path)) { if (mount.exists(path)) {
writableMount.delete(path); writableMount.delete(path);
} }
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) { } catch (IOException e) {
throw localExceptionOf(path, e); throw localExceptionOf(path, e);
} }
@ -177,14 +171,12 @@ public void rename(String source, String dest) throws FileSystemException {
} }
writableMount.rename(source, dest); writableMount.rename(source, dest);
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) { } catch (IOException e) {
throw localExceptionOf(source, e); throw localExceptionOf(source, e);
} }
} }
public WritableByteChannel openForWrite(String path) throws FileSystemException { public SeekableByteChannel openForWrite(String path) throws FileSystemException {
if (writableMount == null) throw exceptionOf(path, "Access denied"); if (writableMount == null) throw exceptionOf(path, "Access denied");
path = toLocal(path); path = toLocal(path);
@ -200,14 +192,12 @@ public WritableByteChannel openForWrite(String path) throws FileSystemException
} }
return writableMount.openForWrite(path); return writableMount.openForWrite(path);
} }
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) { } catch (IOException e) {
throw localExceptionOf(path, e); throw localExceptionOf(path, e);
} }
} }
public WritableByteChannel openForAppend(String path) throws FileSystemException { public SeekableByteChannel openForAppend(String path) throws FileSystemException {
if (writableMount == null) throw exceptionOf(path, "Access denied"); if (writableMount == null) throw exceptionOf(path, "Access denied");
path = toLocal(path); path = toLocal(path);
@ -225,8 +215,6 @@ public WritableByteChannel openForAppend(String path) throws FileSystemException
} else { } else {
return writableMount.openForAppend(path); return writableMount.openForAppend(path);
} }
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) { } catch (IOException e) {
throw localExceptionOf(path, e); throw localExceptionOf(path, e);
} }
@ -244,7 +232,8 @@ private FileSystemException localExceptionOf(@Nullable String localPath, IOExcep
if (e instanceof java.nio.file.FileSystemException ex) { if (e instanceof java.nio.file.FileSystemException ex) {
// This error will contain the absolute path, leaking information about where MC is installed. We drop that, // This error will contain the absolute path, leaking information about where MC is installed. We drop that,
// just taking the reason. We assume that the error refers to the input path. // just taking the reason. We assume that the error refers to the input path.
var message = getReason(ex); var message = ex.getReason();
if (message == null) message = "Failed";
return localPath == null ? new FileSystemException(message) : localExceptionOf(localPath, message); return localPath == null ? new FileSystemException(message) : localExceptionOf(localPath, message);
} }
@ -259,15 +248,4 @@ private FileSystemException localExceptionOf(String path, String message) {
private static FileSystemException exceptionOf(String path, String message) { private static FileSystemException exceptionOf(String path, String message) {
return new FileSystemException("/" + path + ": " + message); return new FileSystemException("/" + path + ": " + message);
} }
private static String getReason(java.nio.file.FileSystemException e) {
var reason = e.getReason();
if (reason != null) return reason.trim();
if (e instanceof FileAlreadyExistsException) return "File exists";
if (e instanceof NoSuchFileException) return "No such file";
if (e instanceof AccessDeniedException) return "Access denied";
return "Operation failed";
}
} }

View File

@ -0,0 +1,322 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import com.google.common.collect.Sets;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.WritableMount;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.NonReadableChannelException;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Set;
/**
* A {@link WritableFileMount} implementation which provides read-write access to a directory.
*/
public class WritableFileMount extends FileMount implements WritableMount {
private static final Logger LOG = LoggerFactory.getLogger(WritableFileMount.class);
private 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);
protected final File rootFile;
private final long capacity;
private long usedSpace;
public WritableFileMount(File rootFile, long capacity) {
super(rootFile.toPath());
this.rootFile = rootFile;
this.capacity = capacity + MINIMUM_FILE_SIZE;
usedSpace = created() ? measureUsedSpace(root) : MINIMUM_FILE_SIZE;
}
protected File resolveFile(String path) {
return new File(rootFile, path);
}
private void create() throws FileOperationException {
try {
Files.createDirectories(root);
} catch (IOException e) {
throw new FileOperationException("Access denied");
}
}
@Override
public long getRemainingSpace() {
return Math.max(capacity - usedSpace, 0);
}
@Override
public long getCapacity() {
return capacity - MINIMUM_FILE_SIZE;
}
@Override
public boolean isReadOnly(String path) {
var file = resolveFile(path);
while (true) {
if (file.exists()) return !file.canWrite();
if (file.equals(rootFile)) return false;
file = file.getParentFile();
}
}
@Override
public void makeDirectory(String path) throws IOException {
create();
var file = resolveFile(path);
if (file.exists()) {
if (!file.isDirectory()) throw new FileOperationException(path, "File exists");
return;
}
var dirsToCreate = 1;
var parent = file.getParentFile();
while (!parent.exists()) {
++dirsToCreate;
parent = parent.getParentFile();
}
if (getRemainingSpace() < dirsToCreate * MINIMUM_FILE_SIZE) {
throw new FileOperationException(path, "Out of space");
}
if (file.mkdirs()) {
usedSpace += dirsToCreate * MINIMUM_FILE_SIZE;
} else {
throw new FileOperationException(path, "Access denied");
}
}
@Override
public void delete(String path) throws IOException {
if (path.isEmpty()) throw new FileOperationException(path, "Access denied");
if (created()) {
var file = resolveFile(path);
if (file.exists()) deleteRecursively(file);
}
}
private void deleteRecursively(File file) throws IOException {
// Empty directories first
if (file.isDirectory()) {
var children = file.list();
for (var aChildren : children) {
deleteRecursively(new File(file, aChildren));
}
}
// Then delete
var fileSize = file.isDirectory() ? 0 : file.length();
var success = file.delete();
if (success) {
usedSpace -= Math.max(MINIMUM_FILE_SIZE, fileSize);
} else {
throw new IOException("Access denied");
}
}
@Override
public void rename(String source, String dest) throws FileOperationException {
var sourceFile = resolvePath(source);
var destFile = resolvePath(dest);
if (!Files.exists(sourceFile)) throw new FileOperationException(source, "No such file");
if (Files.exists(destFile)) throw new FileOperationException(dest, "File exists");
if (destFile.startsWith(sourceFile)) {
throw new FileOperationException(source, "Cannot move a directory inside itself");
}
try {
Files.move(sourceFile, destFile);
} catch (IOException e) {
throw remapException(source, e);
}
}
private @Nullable BasicFileAttributes tryGetAttributes(String path, Path resolved) throws FileOperationException {
try {
return Files.readAttributes(resolved, BasicFileAttributes.class);
} catch (NoSuchFileException ignored) {
return null;
} catch (IOException e) {
throw remapException(path, e);
}
}
@Override
public SeekableByteChannel openForWrite(String path) throws FileOperationException {
create();
var file = resolvePath(path);
var attributes = tryGetAttributes(path, file);
if (attributes == null) {
if (getRemainingSpace() < MINIMUM_FILE_SIZE) throw new FileOperationException(path, "Out of space");
} else if (attributes.isDirectory()) {
throw new FileOperationException(path, "Cannot write to directory");
} else {
usedSpace -= Math.max(attributes.size(), MINIMUM_FILE_SIZE);
}
usedSpace += MINIMUM_FILE_SIZE;
try {
return new CountingChannel(Files.newByteChannel(file, WRITE_OPTIONS), MINIMUM_FILE_SIZE, true);
} catch (IOException e) {
throw remapException(path, e);
}
}
@Override
public SeekableByteChannel openForAppend(String path) throws IOException {
create();
var file = resolvePath(path);
var attributes = tryGetAttributes(path, file);
if (attributes == null) {
if (getRemainingSpace() < MINIMUM_FILE_SIZE) throw new FileOperationException(path, "Out of space");
} else if (attributes.isDirectory()) {
throw new FileOperationException(path, "Cannot write to directory");
}
// Allowing seeking when appending is not recommended, so we use a separate channel.
try {
return new CountingChannel(
Files.newByteChannel(file, APPEND_OPTIONS),
Math.max(MINIMUM_FILE_SIZE - (attributes == null ? 0 : attributes.size()), 0),
false
);
} catch (IOException e) {
throw remapException(path, e);
}
}
private class CountingChannel implements SeekableByteChannel {
private final SeekableByteChannel channel;
private long ignoredBytesLeft;
private final boolean canSeek;
CountingChannel(SeekableByteChannel channel, long bytesToIgnore, boolean canSeek) {
this.channel = channel;
ignoredBytesLeft = bytesToIgnore;
this.canSeek = canSeek;
}
@Override
public int write(ByteBuffer b) throws IOException {
count(b.remaining());
return channel.write(b);
}
void count(long n) throws IOException {
ignoredBytesLeft -= n;
if (ignoredBytesLeft < 0) {
var newBytes = -ignoredBytesLeft;
ignoredBytesLeft = 0;
var bytesLeft = capacity - usedSpace;
if (newBytes > bytesLeft) throw new IOException("Out of space");
usedSpace += newBytes;
}
}
@Override
public boolean isOpen() {
return channel.isOpen();
}
@Override
public void close() throws IOException {
channel.close();
}
@Override
public SeekableByteChannel position(long newPosition) throws IOException {
if (!isOpen()) throw new ClosedChannelException();
if (!canSeek) throw new UnsupportedOperationException("File does not support seeking");
if (newPosition < 0) {
throw new IllegalArgumentException("Cannot seek before the beginning of the stream");
}
var delta = newPosition - channel.position();
if (delta < 0) {
ignoredBytesLeft -= delta;
} else {
count(delta);
}
return channel.position(newPosition);
}
@Override
public SeekableByteChannel truncate(long size) throws IOException {
throw new UnsupportedOperationException("File cannot be truncated");
}
@Override
public int read(ByteBuffer dst) throws ClosedChannelException {
if (!channel.isOpen()) throw new ClosedChannelException();
throw new NonReadableChannelException();
}
@Override
public long position() throws IOException {
return channel.position();
}
@Override
public long size() throws IOException {
return channel.size();
}
}
private static long measureUsedSpace(Path path) {
if (!Files.exists(path)) return 0;
class CountingVisitor extends SimpleFileVisitor<Path> {
long size;
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
size += MINIMUM_FILE_SIZE;
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
size += Math.max(attrs.size(), MINIMUM_FILE_SIZE);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
LOG.error("Error computing file size for {}", file, exc);
return FileVisitResult.CONTINUE;
}
}
try {
var visitor = new CountingVisitor();
Files.walkFileTree(path, visitor);
return visitor.size;
} catch (IOException e) {
LOG.error("Error computing file size for {}", path, e);
return 0;
}
}
}

View File

@ -13,8 +13,8 @@
import dan200.computercraft.core.computer.Computer; import dan200.computercraft.core.computer.Computer;
import dan200.computercraft.core.computer.ComputerSide; import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.core.computer.mainthread.NoWorkMainThreadScheduler; import dan200.computercraft.core.computer.mainthread.NoWorkMainThreadScheduler;
import dan200.computercraft.core.filesystem.FileMount;
import dan200.computercraft.core.filesystem.FileSystemException; import dan200.computercraft.core.filesystem.FileSystemException;
import dan200.computercraft.core.filesystem.WritableFileMount;
import dan200.computercraft.core.terminal.Terminal; import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.test.core.computer.BasicEnvironment; import dan200.computercraft.test.core.computer.BasicEnvironment;
import org.junit.jupiter.api.*; import org.junit.jupiter.api.*;
@ -85,7 +85,7 @@ public void before() throws IOException {
if (Files.deleteIfExists(REPORT_PATH)) LOG.info("Deleted previous coverage report."); if (Files.deleteIfExists(REPORT_PATH)) LOG.info("Deleted previous coverage report.");
var term = new Terminal(80, 100, true); var term = new Terminal(80, 100, true);
WritableMount mount = new FileMount(TestFiles.get("mount").toFile(), 10_000_000); WritableMount mount = new WritableFileMount(TestFiles.get("mount").toFile(), 10_000_000);
// Remove any existing files // Remove any existing files
List<String> children = new ArrayList<>(); List<String> children = new ArrayList<>();

View File

@ -16,6 +16,7 @@
import dan200.computercraft.core.terminal.Terminal; import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.test.core.computer.BasicEnvironment; import dan200.computercraft.test.core.computer.BasicEnvironment;
import dan200.computercraft.test.core.filesystem.MemoryMount; import dan200.computercraft.test.core.filesystem.MemoryMount;
import dan200.computercraft.test.core.filesystem.ReadOnlyWritableMount;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -37,7 +38,7 @@ public static void run(String program, Consumer<Computer> setup, int maxTimes) {
.addFile("test.lua", program) .addFile("test.lua", program)
.addFile("startup.lua", "assertion.assert(pcall(loadfile('test.lua', nil, _ENV))) os.shutdown()"); .addFile("startup.lua", "assertion.assert(pcall(loadfile('test.lua', nil, _ENV))) os.shutdown()");
run(mount, setup, maxTimes); run(new ReadOnlyWritableMount(mount), setup, maxTimes);
} }
public static void run(String program, int maxTimes) { public static void run(String program, int maxTimes) {

View File

@ -7,9 +7,10 @@
import com.google.common.io.MoreFiles; import com.google.common.io.MoreFiles;
import com.google.common.io.RecursiveDeleteOption; import com.google.common.io.RecursiveDeleteOption;
import dan200.computercraft.api.filesystem.WritableMount; import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.test.core.filesystem.MountContract;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -17,50 +18,60 @@
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
public class FileMountTest implements WritableMountContract { import static org.junit.jupiter.api.Assertions.*;
public class FileMountTest implements MountContract {
private final List<Path> cleanup = new ArrayList<>(); private final List<Path> cleanup = new ArrayList<>();
@Override
public Mount createSkeleton() throws IOException {
var path = Files.createTempDirectory("cctweaked-test");
cleanup.add(path);
Files.createDirectories(path.resolve("dir"));
try (var writer = Files.newBufferedWriter(path.resolve("dir/file.lua"))) {
writer.write("print('testing')");
}
Files.setLastModifiedTime(path.resolve("dir/file.lua"), MODIFY_TIME);
Files.newBufferedWriter(path.resolve("f.lua")).close();
return new FileMount(path);
}
private Mount createEmpty() throws IOException {
var path = Files.createTempDirectory("cctweaked-test");
cleanup.add(path);
return new FileMount(path.resolve("empty"));
}
@AfterEach @AfterEach
public void cleanup() throws IOException { public void cleanup() throws IOException {
for (var mount : cleanup) MoreFiles.deleteRecursively(mount, RecursiveDeleteOption.ALLOW_INSECURE); for (var mount : cleanup) MoreFiles.deleteRecursively(mount, RecursiveDeleteOption.ALLOW_INSECURE);
} }
@Override @Test
public MountAccess createMount(long capacity) throws IOException { public void testRootExistsWhenEmpty() throws IOException {
var path = Files.createTempDirectory("cctweaked-test"); var mount = createEmpty();
cleanup.add(path); assertTrue(mount.exists(""), "Root always exists");
return new MountAccessImpl(path.resolve("mount"), capacity); assertTrue(mount.isDirectory(""), "Root always is a directory");
} }
private static final class MountAccessImpl implements MountAccess { @Test
private final Path root; public void testListWhenEmpty() throws IOException {
private final long capacity; var mount = createEmpty();
private final WritableMount mount;
private MountAccessImpl(Path root, long capacity) { List<String> list = new ArrayList<>();
this.root = root; mount.list("", list);
this.capacity = capacity; assertEquals(List.of(), list, "Root has no children");
mount = new FileMount(root.toFile(), capacity);
}
@Override assertThrows(IOException.class, () -> mount.list("elsewhere", list));
public WritableMount mount() { }
return mount;
}
@Override @Test
public void makeReadOnly(String path) { public void testAttributesWhenEmpty() throws IOException {
Assumptions.assumeTrue(root.resolve(path).toFile().setReadOnly(), "Change file to read-only"); var mount = createEmpty();
}
@Override assertEquals(0, mount.getSize(""));
public void ensuresExist() throws IOException { assertNotNull(mount.getAttributes(""));
Files.createDirectories(root);
}
@Override
public long computeRemainingSpace() {
return new FileMount(root.toFile(), capacity).getRemainingSpace();
}
} }
} }

View File

@ -27,7 +27,7 @@ public class FileSystemTest {
private static final long CAPACITY = 1000000; private static final long CAPACITY = 1000000;
private static FileSystem mkFs() throws FileSystemException { private static FileSystem mkFs() throws FileSystemException {
WritableMount writableMount = new FileMount(ROOT, CAPACITY); WritableMount writableMount = new WritableFileMount(ROOT, CAPACITY);
return new FileSystem("hdd", writableMount); return new FileSystem("hdd", writableMount);
} }
@ -65,7 +65,7 @@ public void testWriteTruncates() throws FileSystemException, LuaException, IOExc
@Test @Test
public void testUnmountCloses() throws FileSystemException { public void testUnmountCloses() throws FileSystemException {
var fs = mkFs(); var fs = mkFs();
WritableMount mount = new FileMount(new File(ROOT, "child"), CAPACITY); WritableMount mount = new WritableFileMount(new File(ROOT, "child"), CAPACITY);
fs.mountWritable("disk", "disk", mount); fs.mountWritable("disk", "disk", mount);
var writer = fs.openForWrite("disk/out.txt", false, EncodedWritableHandle::openUtf8); var writer = fs.openForWrite("disk/out.txt", false, EncodedWritableHandle::openUtf8);

View File

@ -8,6 +8,9 @@
import com.google.common.io.ByteStreams; import com.google.common.io.ByteStreams;
import dan200.computercraft.api.filesystem.Mount; import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.core.TestFiles; import dan200.computercraft.core.TestFiles;
import dan200.computercraft.test.core.CloseScope;
import dan200.computercraft.test.core.filesystem.MountContract;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -16,50 +19,67 @@
import java.io.IOException; import java.io.IOException;
import java.nio.channels.Channels; import java.nio.channels.Channels;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
public class JarMountTest { public class JarMountTest implements MountContract {
private static final File ZIP_FILE = TestFiles.get("jar-mount.zip").toFile(); private static final File ZIP_FILE = TestFiles.get("jar-mount.zip").toFile();
private static final FileTime MODIFY_TIME = FileTime.from(Instant.EPOCH.plus(2, ChronoUnit.DAYS)); private final CloseScope toClose = new CloseScope();
@BeforeAll @BeforeAll
public static void before() throws IOException { public static void before() throws IOException {
ZIP_FILE.getParentFile().mkdirs(); ZIP_FILE.getParentFile().mkdirs();
try (var stream = new ZipOutputStream(new FileOutputStream(ZIP_FILE))) { try (var stream = new ZipOutputStream(new FileOutputStream(ZIP_FILE))) {
stream.putNextEntry(new ZipEntry("dir/")); stream.putNextEntry(new ZipEntry("root/"));
stream.closeEntry(); stream.closeEntry();
stream.putNextEntry(new ZipEntry("dir/file.lua").setLastModifiedTime(MODIFY_TIME)); stream.putNextEntry(new ZipEntry("root/dir/"));
stream.closeEntry();
stream.putNextEntry(new ZipEntry("root/dir/file.lua").setLastModifiedTime(MODIFY_TIME));
stream.write("print('testing')".getBytes(StandardCharsets.UTF_8)); stream.write("print('testing')".getBytes(StandardCharsets.UTF_8));
stream.closeEntry(); stream.closeEntry();
stream.putNextEntry(new ZipEntry("root/f.lua"));
stream.closeEntry();
} }
} }
@AfterEach
public void after() throws Exception {
toClose.close();
}
@Override
public Mount createSkeleton() throws IOException {
return toClose.add(new JarMount(ZIP_FILE, "root"));
}
private Mount createMount(String path) throws IOException {
return toClose.add(new JarMount(ZIP_FILE, "root/" + path));
}
@Test @Test
public void mountsDir() throws IOException { public void mountsDir() throws IOException {
Mount mount = new JarMount(ZIP_FILE, "dir"); var mount = createMount("dir");
assertTrue(mount.isDirectory(""), "Root should be directory"); assertTrue(mount.isDirectory(""), "Root should be directory");
assertTrue(mount.exists("file.lua"), "File should exist"); assertTrue(mount.exists("file.lua"), "File should exist");
} }
@Test @Test
public void mountsFile() throws IOException { public void mountsFile() throws IOException {
Mount mount = new JarMount(ZIP_FILE, "dir/file.lua"); var mount = createMount("dir/file.lua");
assertTrue(mount.exists(""), "Root should exist"); assertTrue(mount.exists(""), "Root should exist");
assertFalse(mount.isDirectory(""), "Root should be a file"); assertFalse(mount.isDirectory(""), "Root should be a file");
} }
@Test @Test
public void opensFileFromFile() throws IOException { public void opensFileFromFile() throws IOException {
Mount mount = new JarMount(ZIP_FILE, "dir/file.lua"); var mount = createMount("dir/file.lua");
byte[] contents; byte[] contents;
try (var stream = mount.openForRead("")) { try (var stream = mount.openForRead("")) {
contents = ByteStreams.toByteArray(Channels.newInputStream(stream)); contents = ByteStreams.toByteArray(Channels.newInputStream(stream));
@ -70,7 +90,7 @@ public void opensFileFromFile() throws IOException {
@Test @Test
public void opensFileFromDir() throws IOException { public void opensFileFromDir() throws IOException {
Mount mount = new JarMount(ZIP_FILE, "dir"); var mount = createMount("dir");
byte[] contents; byte[] contents;
try (var stream = mount.openForRead("file.lua")) { try (var stream = mount.openForRead("file.lua")) {
contents = ByteStreams.toByteArray(Channels.newInputStream(stream)); contents = ByteStreams.toByteArray(Channels.newInputStream(stream));
@ -78,19 +98,4 @@ public void opensFileFromDir() throws IOException {
assertEquals(new String(contents, StandardCharsets.UTF_8), "print('testing')"); assertEquals(new String(contents, StandardCharsets.UTF_8), "print('testing')");
} }
@Test
public void fileAttributes() throws IOException {
var attributes = new JarMount(ZIP_FILE, "dir").getAttributes("file.lua");
assertFalse(attributes.isDirectory());
assertEquals("print('testing')".length(), attributes.size());
assertEquals(MODIFY_TIME, attributes.lastModifiedTime());
}
@Test
public void directoryAttributes() throws IOException {
var attributes = new JarMount(ZIP_FILE, "dir").getAttributes("");
assertTrue(attributes.isDirectory());
assertEquals(0, attributes.size());
}
} }

View File

@ -0,0 +1,67 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import com.google.common.io.MoreFiles;
import com.google.common.io.RecursiveDeleteOption;
import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.test.core.filesystem.WritableMountContract;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public class WritableFileMountTest implements WritableMountContract {
private final List<Path> cleanup = new ArrayList<>();
@Override
public MountAccess createMount(long capacity) throws IOException {
var path = Files.createTempDirectory("cctweaked-test");
cleanup.add(path);
return new MountAccessImpl(path.resolve("mount"), capacity);
}
@AfterEach
public void cleanup() throws IOException {
for (var mount : cleanup) MoreFiles.deleteRecursively(mount, RecursiveDeleteOption.ALLOW_INSECURE);
}
private static final class MountAccessImpl implements MountAccess {
private final Path root;
private final long capacity;
private final WritableMount mount;
private MountAccessImpl(Path root, long capacity) {
this.root = root;
this.capacity = capacity;
mount = new WritableFileMount(root.toFile(), capacity);
}
@Override
public WritableMount mount() {
return mount;
}
@Override
public void makeReadOnly(String path) {
Assumptions.assumeTrue(root.resolve(path).toFile().setReadOnly(), "Change file to read-only");
}
@Override
public void ensuresExist() throws IOException {
Files.createDirectories(root);
}
@Override
public long computeRemainingSpace() {
return new WritableFileMount(root.toFile(), capacity).getRemainingSpace();
}
}
}

View File

@ -0,0 +1,61 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.test.core;
import javax.annotation.Nullable;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Objects;
/**
* An {@link AutoCloseable} implementation which can be used to combine other [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.
* <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.
*/
public class CloseScope implements AutoCloseable {
private final Deque<AutoCloseable> toClose = new ArrayDeque<>();
/**
* Add a value to be closed when this scope exists.
*
* @param value The value to be closed.
* @param <T> The type of the provided value.
* @return The provided value.
*/
public <T extends AutoCloseable> T add(T value) {
Objects.requireNonNull(value, "Value cannot be null");
toClose.add(value);
return value;
}
@Override
public void close() throws Exception {
close(null);
}
public void close(@Nullable Exception baseException) throws Exception {
while (true) {
var close = toClose.pollLast();
if (close == null) break;
try {
close.close();
} catch (Exception e) {
if (baseException == null) {
baseException = e;
} else {
baseException.addSuppressed(e);
}
}
}
if (baseException != null) throw baseException;
}
}

View File

@ -15,6 +15,7 @@
import dan200.computercraft.core.metrics.Metric; import dan200.computercraft.core.metrics.Metric;
import dan200.computercraft.core.metrics.MetricsObserver; import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.test.core.filesystem.MemoryMount; import dan200.computercraft.test.core.filesystem.MemoryMount;
import dan200.computercraft.test.core.filesystem.ReadOnlyWritableMount;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -32,7 +33,7 @@ public class BasicEnvironment implements ComputerEnvironment, GlobalEnvironment,
private final WritableMount mount; private final WritableMount mount;
public BasicEnvironment() { public BasicEnvironment() {
this(new MemoryMount()); this(new ReadOnlyWritableMount(new MemoryMount()));
} }
public BasicEnvironment(WritableMount mount) { public BasicEnvironment(WritableMount mount) {
@ -100,7 +101,7 @@ public static Mount createMount(Class<?> klass, String path, String fallback) {
if (!wholeFile.exists()) throw new IllegalStateException("Cannot find ROM mount at " + file); if (!wholeFile.exists()) throw new IllegalStateException("Cannot find ROM mount at " + file);
return new FileMount(wholeFile, 0); return new FileMount(wholeFile.toPath());
} }
} }

View File

@ -6,22 +6,17 @@
package dan200.computercraft.test.core.filesystem; package dan200.computercraft.test.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException; import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.WritableMount; import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.core.apis.handles.ArrayByteChannel; import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.channels.SeekableByteChannel; import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
/** /**
* In-memory file mounts. * A read-only mount {@link Mount} which provides a list of in-memory set of files.
*/ */
public class MemoryMount implements WritableMount { public class MemoryMount implements Mount {
private final Map<String, byte[]> files = new HashMap<>(); private final Map<String, byte[]> files = new HashMap<>();
private final Set<String> directories = new HashSet<>(); private final Set<String> directories = new HashSet<>();
@ -29,69 +24,6 @@ public MemoryMount() {
directories.add(""); directories.add("");
} }
@Override
public void makeDirectory(String path) {
var file = new File(path);
while (file != null) {
directories.add(file.getPath());
file = file.getParentFile();
}
}
@Override
public void delete(String path) {
if (files.containsKey(path)) {
files.remove(path);
} else {
directories.remove(path);
for (var file : files.keySet().toArray(new String[0])) {
if (file.startsWith(path)) {
files.remove(file);
}
}
var parent = new File(path).getParentFile();
if (parent != null) delete(parent.getPath());
}
}
@Override
public void rename(String source, String dest) throws IOException {
throw new IOException("Not supported");
}
@Override
public WritableByteChannel openForWrite(final String path) {
return Channels.newChannel(new ByteArrayOutputStream() {
@Override
public void close() throws IOException {
super.close();
files.put(path, toByteArray());
}
});
}
@Override
public WritableByteChannel openForAppend(final String path) throws IOException {
var stream = new ByteArrayOutputStream() {
@Override
public void close() throws IOException {
super.close();
files.put(path, toByteArray());
}
};
var current = files.get(path);
if (current != null) stream.write(current);
return Channels.newChannel(stream);
}
@Override
public long getRemainingSpace() {
return 1000000L;
}
@Override @Override
public boolean exists(String path) { public boolean exists(String path) {
return files.containsKey(path) || directories.contains(path); return files.containsKey(path) || directories.contains(path);
@ -114,11 +46,6 @@ public long getSize(String path) {
throw new RuntimeException("Not implemented"); throw new RuntimeException("Not implemented");
} }
@Override
public long getCapacity() {
return Long.MAX_VALUE;
}
@Override @Override
public SeekableByteChannel openForRead(String path) throws FileOperationException { public SeekableByteChannel openForRead(String path) throws FileOperationException {
var file = files.get(path); var file = files.get(path);

View File

@ -0,0 +1,128 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.test.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.Mount;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.charset.StandardCharsets;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* The contract that all {@link Mount} implementations must fulfill.
*
* @see WritableMountContract
*/
public interface MountContract {
FileTime EPOCH = FileTime.from(Instant.EPOCH);
FileTime MODIFY_TIME = FileTime.from(Instant.EPOCH.plus(2, ChronoUnit.DAYS));
/**
* Create a skeleton mount. This should contain the following files:
*
* <ul>
* <li>{@code dir/file.lua}, containing {@code print('testing')}.</li>
* <li>{@code f.lua}, containing nothing.</li>
* </ul>
*
* @return The skeleton mount.
*/
Mount createSkeleton() throws IOException;
/**
* Determine if this attributes provided by this mount support file times.
*
* @return Whether this mount supports {@link BasicFileAttributes#lastModifiedTime()}.
*/
default boolean hasFileTimes() {
return true;
}
@Test
default void testIsDirectory() throws IOException {
var mount = createSkeleton();
assertTrue(mount.isDirectory(""), "Root should be directory");
assertTrue(mount.isDirectory("dir"), "dir/ should be directory");
assertFalse(mount.isDirectory("dir/file.lua"), "dir/file.lua should not be a directory");
assertFalse(mount.isDirectory("doesnt/exist"), "doesnt/exist should not be a directory");
}
@Test
default void testExists() throws IOException {
var mount = createSkeleton();
assertTrue(mount.exists(""), "Root should exist");
assertTrue(mount.exists("dir"), "dir/ should exist");
assertFalse(mount.isDirectory("doesnt/exist"), "doesnt/exist should not exist");
}
@Test
default void testList() throws IOException {
var mount = createSkeleton();
List<String> list = new ArrayList<>();
mount.list("", list);
list.sort(Comparator.naturalOrder());
assertEquals(List.of("dir", "f.lua"), list);
}
@Test
default void testOpenFile() throws IOException {
var mount = createSkeleton();
byte[] contents;
try (var stream = mount.openForRead("dir/file.lua")) {
contents = Channels.newInputStream(stream).readAllBytes();
}
assertEquals(new String(contents, StandardCharsets.UTF_8), "print('testing')");
}
@Test
default void testOpenFileFails() throws IOException {
var mount = createSkeleton();
var exn = assertThrows(FileOperationException.class, () -> mount.openForRead("doesnt/exist"));
assertEquals("doesnt/exist", exn.getFilename());
assertEquals("No such file", exn.getMessage());
}
@Test
default void testSize() throws IOException {
var mount = createSkeleton();
assertEquals(0, mount.getSize("f.lua"), "Empty file has 0 size");
assertEquals("print('testing')".length(), mount.getSize("dir/file.lua"));
assertEquals(0, mount.getSize("dir"), "Directory has 0 size");
}
@Test
default void testFileAttributes() throws IOException {
var attributes = createSkeleton().getAttributes("dir/file.lua");
assertFalse(attributes.isDirectory());
assertEquals("print('testing')".length(), attributes.size());
assertEquals(hasFileTimes() ? MODIFY_TIME : EPOCH, attributes.lastModifiedTime());
}
@Test
default void testDirectoryAttributes() throws IOException {
var attributes = createSkeleton().getAttributes("");
assertTrue(attributes.isDirectory());
}
}

View File

@ -0,0 +1,92 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
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

@ -3,7 +3,7 @@
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com * Send enquiries to dratcliffe@gmail.com
*/ */
package dan200.computercraft.core.filesystem; package dan200.computercraft.test.core.filesystem;
import dan200.computercraft.api.filesystem.WritableMount; import dan200.computercraft.api.filesystem.WritableMount;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -14,7 +14,9 @@
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
/** /**
* The contract that all {@link WritableMount}s must fulfill. * The contract that all {@link WritableMount} implementations must fulfill.
*
* @see MountContract
*/ */
public interface WritableMountContract { public interface WritableMountContract {
long CAPACITY = 1_000_000; long CAPACITY = 1_000_000;

View File

@ -8,14 +8,11 @@
import com.google.auto.service.AutoService; import com.google.auto.service.AutoService;
import dan200.computercraft.api.ComputerCraftAPI; import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.api.detail.DetailRegistry; import dan200.computercraft.api.detail.DetailRegistry;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.impl.detail.DetailRegistryImpl; import dan200.computercraft.impl.detail.DetailRegistryImpl;
import dan200.computercraft.shared.computer.core.ResourceMount;
import dan200.computercraft.shared.details.FluidDetails; import dan200.computercraft.shared.details.FluidDetails;
import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant; import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
import net.fabricmc.fabric.api.transfer.v1.storage.StorageView; import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.server.MinecraftServer;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -33,13 +30,6 @@ public String getInstalledVersion() {
.orElse("unknown"); .orElse("unknown");
} }
@Nullable
@Override
public Mount createResourceMount(MinecraftServer server, String domain, String subPath) {
var mount = ResourceMount.get(domain, subPath, server.getResourceManager());
return mount.exists("") ? mount : null;
}
@Override @Override
public DetailRegistry<StorageView<FluidVariant>> getFluidDetailRegistry() { public DetailRegistry<StorageView<FluidVariant>> getFluidDetailRegistry() {
return fluidDetails; return fluidDetails;

View File

@ -8,22 +8,18 @@
import com.google.auto.service.AutoService; import com.google.auto.service.AutoService;
import dan200.computercraft.api.ComputerCraftAPI; import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.api.detail.DetailRegistry; import dan200.computercraft.api.detail.DetailRegistry;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.api.network.wired.WiredElement; import dan200.computercraft.api.network.wired.WiredElement;
import dan200.computercraft.api.peripheral.IPeripheralProvider; import dan200.computercraft.api.peripheral.IPeripheralProvider;
import dan200.computercraft.impl.detail.DetailRegistryImpl; import dan200.computercraft.impl.detail.DetailRegistryImpl;
import dan200.computercraft.shared.computer.core.ResourceMount;
import dan200.computercraft.shared.details.FluidData; import dan200.computercraft.shared.details.FluidData;
import dan200.computercraft.shared.peripheral.generic.GenericPeripheralProvider; import dan200.computercraft.shared.peripheral.generic.GenericPeripheralProvider;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.BlockGetter; import net.minecraft.world.level.BlockGetter;
import net.minecraftforge.common.capabilities.Capability; import net.minecraftforge.common.capabilities.Capability;
import net.minecraftforge.common.util.LazyOptional; import net.minecraftforge.common.util.LazyOptional;
import net.minecraftforge.fluids.FluidStack; import net.minecraftforge.fluids.FluidStack;
import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.ModList;
import net.minecraftforge.server.ServerLifecycleHooks;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -43,13 +39,6 @@ public String getInstalledVersion() {
.orElse("unknown"); .orElse("unknown");
} }
@Override
public @Nullable Mount createResourceMount(MinecraftServer server, String domain, String subPath) {
var manager = ServerLifecycleHooks.getCurrentServer().getResourceManager();
var mount = ResourceMount.get(domain, subPath, manager);
return mount.exists("") ? mount : null;
}
@Override @Override
public void registerPeripheralProvider(IPeripheralProvider provider) { public void registerPeripheralProvider(IPeripheralProvider provider) {
Peripherals.register(provider); Peripherals.register(provider);

View File

@ -98,7 +98,7 @@ def _parse_junit_file(path: pathlib.Path):
print("::group::Full error message") print("::group::Full error message")
print(full_message) print(full_message)
print("::endgroup") print("::endgroup::")
def parse_junit() -> None: def parse_junit() -> None: