瀏覽代碼

first tests with refactored io layers

Sebastian Stenzel 9 年之前
父節點
當前提交
e1b74ce312
共有 19 個文件被更改,包括 859 次插入13 次删除
  1. 1 0
      main/crypto-layer/.gitignore
  2. 35 0
      main/crypto-layer/pom.xml
  3. 44 0
      main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoFile.java
  4. 74 0
      main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoFileSystem.java
  5. 146 0
      main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoFolder.java
  6. 72 0
      main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoNode.java
  7. 4 0
      main/crypto-layer/src/main/java/org/cryptomator/crypto/package-info.java
  8. 38 0
      main/crypto-layer/src/test/java/org/cryptomator/crypto/CryptoFileSystemTest.java
  9. 1 0
      main/filesystem-api/.gitignore
  10. 3 2
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/File.java
  11. 1 1
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/FileSystem.java
  12. 5 5
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/Folder.java
  13. 5 5
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/Node.java
  14. 141 0
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFile.java
  15. 34 0
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFileSystem.java
  16. 99 0
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFolder.java
  17. 61 0
      main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryNode.java
  18. 94 0
      main/filesystem-api/src/test/java/org/cryptomator/filesystem/inmem/InMemoryFileSystemTest.java
  19. 1 0
      main/pom.xml

+ 1 - 0
main/crypto-layer/.gitignore

@@ -0,0 +1 @@
+/target/

+ 35 - 0
main/crypto-layer/pom.xml

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Copyright (c) 2014 Sebastian Stenzel
+  This file is licensed under the terms of the MIT license.
+  See the LICENSE.txt file for more info.
+  
+  Contributors:
+      Sebastian Stenzel - initial API and implementation
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<parent>
+		<groupId>org.cryptomator</groupId>
+		<artifactId>main</artifactId>
+		<version>0.11.0-SNAPSHOT</version>
+	</parent>
+	<artifactId>crypto-layer</artifactId>
+	<name>Crypto Layer</name>
+
+	<dependencies>
+		<dependency>
+			<groupId>org.cryptomator</groupId>
+			<artifactId>filesystem-api</artifactId>
+		</dependency>
+
+		<dependency>
+			<groupId>org.apache.commons</groupId>
+			<artifactId>commons-lang3</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>commons-codec</groupId>
+			<artifactId>commons-codec</artifactId>
+		</dependency>
+	</dependencies>
+</project>

+ 44 - 0
main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoFile.java

@@ -0,0 +1,44 @@
+package org.cryptomator.crypto;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.ReadableFile;
+import org.cryptomator.filesystem.WritableFile;
+
+public class CryptoFile extends CryptoNode implements File {
+
+	private static final String ENCRYPTED_FILE_EXT = ".file";
+
+	public CryptoFile(CryptoFolder parent, String name) {
+		super(parent, name);
+	}
+
+	@Override
+	String encryptedName() {
+		return name() + ENCRYPTED_FILE_EXT;
+	}
+
+	@Override
+	public Instant lastModified() throws UncheckedIOException {
+		// TODO Auto-generated method stub
+		return null;
+	}
+
+	@Override
+	public ReadableFile openReadable(long timeout, TimeUnit unit) throws IOException, TimeoutException {
+		// TODO Auto-generated method stub
+		return null;
+	}
+
+	@Override
+	public WritableFile openWritable(long timeout, TimeUnit unit) throws IOException, TimeoutException {
+		// TODO Auto-generated method stub
+		return null;
+	}
+
+}

+ 74 - 0
main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoFileSystem.java

@@ -0,0 +1,74 @@
+package org.cryptomator.crypto;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.FileSystem;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+
+public class CryptoFileSystem extends CryptoFolder implements FileSystem {
+
+	private static final String DATA_ROOT_DIR = "d";
+	private static final String METADATA_ROOT_DIR = "m";
+	private static final String ROOT_DIR_FILE = "root";
+	private static final String MASTERKEY_FILE = "masterkey.cryptomator";
+	private static final String MASTERKEY_BACKUP_FILE = "masterkey.cryptomator.bkup";
+
+	private final Folder physicalRoot;
+
+	public CryptoFileSystem(Folder physicalRoot) {
+		super(null, "");
+		this.physicalRoot = physicalRoot;
+	}
+
+	@Override
+	File physicalFile() throws IOException {
+		return physicalDataRoot().file(ROOT_DIR_FILE);
+	}
+
+	@Override
+	Folder physicalFolder() throws IOException {
+		// TODO Auto-generated method stub
+		return super.physicalFolder();
+	}
+
+	@Override
+	Folder physicalDataRoot() {
+		return physicalRoot.folder(DATA_ROOT_DIR);
+	}
+
+	@Override
+	Folder physicalMetadataRoot() {
+		return physicalRoot.folder(METADATA_ROOT_DIR);
+	}
+
+	@Override
+	public Optional<CryptoFolder> parent() {
+		return Optional.empty();
+	}
+
+	@Override
+	public boolean exists() {
+		return physicalRoot.exists();
+	}
+
+	@Override
+	public void delete() {
+		// no-op.
+	}
+
+	@Override
+	public String toString() {
+		return "/";
+	}
+
+	@Override
+	public void create(FolderCreateMode mode) throws IOException {
+		physicalDataRoot().create(mode);
+		physicalMetadataRoot().create(mode);
+		super.create(mode);
+	}
+
+}

+ 146 - 0
main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoFolder.java

@@ -0,0 +1,146 @@
+package org.cryptomator.crypto;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+
+import org.apache.commons.codec.binary.Base32;
+import org.apache.commons.lang3.StringUtils;
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+import org.cryptomator.filesystem.Node;
+import org.cryptomator.filesystem.ReadableFile;
+import org.cryptomator.filesystem.WritableFile;
+
+class CryptoFolder extends CryptoNode implements Folder {
+
+	private static final String ENCRYPTED_FILE_EXT = ".file";
+	private static final String ENCRYPTED_DIR_EXT = ".dir";
+
+	private final AtomicReference<String> directoryId = new AtomicReference<>();
+
+	public CryptoFolder(CryptoFolder parent, String name) {
+		super(parent, name);
+	}
+
+	@Override
+	String encryptedName() {
+		return name() + ENCRYPTED_DIR_EXT;
+	}
+
+	protected String getDirectoryId() throws IOException {
+		if (directoryId.get() == null) {
+			File dirFile = physicalFile();
+			if (dirFile.exists()) {
+				try (ReadableFile readable = dirFile.openReadable(1, TimeUnit.SECONDS)) {
+					final ByteBuffer buf = ByteBuffer.allocate(64);
+					readable.read(buf);
+					buf.flip();
+					byte[] bytes = new byte[buf.remaining()];
+					buf.get(bytes);
+					directoryId.set(new String(bytes));
+				} catch (TimeoutException e) {
+					throw new IOException("Failed to lock directory file in time." + dirFile, e);
+				} catch (IOException e) {
+					throw new UncheckedIOException(e);
+				}
+			} else {
+				directoryId.compareAndSet(null, UUID.randomUUID().toString());
+			}
+		}
+		return directoryId.get();
+	}
+
+	File physicalFile() throws IOException {
+		return parent.physicalFolder().file(encryptedName());
+	}
+
+	Folder physicalFolder() throws IOException {
+		final String encryptedThenHashedDirId;
+		try {
+			final byte[] hash = MessageDigest.getInstance("SHA-1").digest(getDirectoryId().getBytes());
+			encryptedThenHashedDirId = new Base32().encodeAsString(hash);
+		} catch (NoSuchAlgorithmException e) {
+			throw new AssertionError("SHA-1 exists in every JVM");
+		}
+		// TODO actual encryption
+		return physicalDataRoot().folder(encryptedThenHashedDirId.substring(0, 2)).folder(encryptedThenHashedDirId.substring(2));
+	}
+
+	@Override
+	public Instant lastModified() {
+		try {
+			return physicalFile().lastModified();
+		} catch (IOException e) {
+			throw new UncheckedIOException(e);
+		}
+	}
+
+	@Override
+	public Stream<? extends Node> children() throws IOException {
+		return Stream.concat(files(), folders());
+	}
+
+	@Override
+	public Stream<CryptoFile> files() throws IOException {
+		return physicalFolder().files().map(File::name).filter(s -> s.endsWith(ENCRYPTED_FILE_EXT)).map(this::decryptFileName).map(this::file);
+	}
+
+	private String decryptFileName(String encryptedFileName) {
+		// TODO Auto-generated method stub
+		return StringUtils.removeEnd(encryptedFileName, ENCRYPTED_FILE_EXT);
+	}
+
+	@Override
+	public CryptoFile file(String name) {
+		return new CryptoFile(this, name);
+	}
+
+	@Override
+	public Stream<CryptoFolder> folders() throws IOException {
+		return physicalFolder().files().map(File::name).filter(s -> s.endsWith(ENCRYPTED_DIR_EXT)).map(this::decryptFolderName).map(this::folder);
+	}
+
+	private String decryptFolderName(String encryptedFolderName) {
+		// TODO Auto-generated method stub
+		return StringUtils.removeEnd(encryptedFolderName, ENCRYPTED_DIR_EXT);
+	}
+
+	@Override
+	public CryptoFolder folder(String name) {
+		return new CryptoFolder(this, name);
+	}
+
+	@Override
+	public void create(FolderCreateMode mode) throws IOException {
+		final File dirFile = physicalFile();
+		if (dirFile.exists()) {
+			return;
+		} else {
+			final String directoryId = getDirectoryId();
+			try (WritableFile writable = dirFile.openWritable(1, TimeUnit.SECONDS)) {
+				final ByteBuffer buf = ByteBuffer.wrap(directoryId.getBytes());
+				writable.write(buf);
+			} catch (TimeoutException e) {
+				throw new IOException("Failed to lock directory file in time." + dirFile, e);
+			}
+			physicalFolder().create(FolderCreateMode.INCLUDING_PARENTS);
+		}
+	}
+
+	@Override
+	public void delete() throws IOException {
+		// TODO Auto-generated method stub
+
+	}
+
+}

+ 72 - 0
main/crypto-layer/src/main/java/org/cryptomator/crypto/CryptoNode.java

@@ -0,0 +1,72 @@
+package org.cryptomator.crypto;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Optional;
+
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.Node;
+
+abstract class CryptoNode implements Node {
+
+	protected final CryptoFolder parent;
+	protected final String name;
+
+	public CryptoNode(CryptoFolder parent, String name) {
+		this.parent = parent;
+		this.name = name;
+	}
+
+	Folder physicalDataRoot() {
+		return parent.physicalDataRoot();
+	}
+
+	Folder physicalMetadataRoot() {
+		return parent.physicalMetadataRoot();
+	}
+
+	@Override
+	public Optional<CryptoFolder> parent() {
+		return Optional.of(parent);
+	}
+
+	@Override
+	public String name() {
+		return name;
+	}
+
+	String encryptedName() {
+		return name();
+	}
+
+	@Override
+	public boolean exists() {
+		try {
+			return parent.children().anyMatch(node -> node.equals(this));
+		} catch (IOException e) {
+			throw new UncheckedIOException(e);
+		}
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + ((name == null) ? 0 : name.hashCode());
+		result = prime * result + ((parent == null) ? 0 : parent.hashCode());
+		return result;
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (obj instanceof CryptoNode) {
+			CryptoNode other = (CryptoNode) obj;
+			return this.getClass() == other.getClass() //
+					&& (this.parent == null && other.parent == null || this.parent.equals(other.parent)) //
+					&& (this.name == null && other.name == null || this.name.equals(other.name));
+		} else {
+			return false;
+		}
+	}
+
+}

+ 4 - 0
main/crypto-layer/src/main/java/org/cryptomator/crypto/package-info.java

@@ -0,0 +1,4 @@
+/**
+ * Provides a decoration layer for the {@link org.cryptomator.filesystem} API, consuming an encrypted file system and providing access to a cleartext filesystem.
+ */
+package org.cryptomator.crypto;

+ 38 - 0
main/crypto-layer/src/test/java/org/cryptomator/crypto/CryptoFileSystemTest.java

@@ -0,0 +1,38 @@
+package org.cryptomator.crypto;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+import org.cryptomator.filesystem.FileSystem;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+import org.cryptomator.filesystem.inmem.InMemoryFileSystem;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class CryptoFileSystemTest {
+
+	@Test
+	public void testFilenameEncryption() throws UncheckedIOException, IOException {
+		// some mock fs:
+		FileSystem physicalFs = new InMemoryFileSystem();
+		Folder dataRoot = physicalFs.folder("d");
+		Assert.assertFalse(dataRoot.exists());
+
+		// init crypto fs:
+		FileSystem fs = new CryptoFileSystem(physicalFs);
+		fs.create(FolderCreateMode.INCLUDING_PARENTS);
+		Assert.assertTrue(dataRoot.exists());
+		Assert.assertEquals(physicalFs.children().count(), 2);
+		Assert.assertEquals(1, dataRoot.files().count()); // ROOT file
+		Assert.assertEquals(1, dataRoot.folders().count()); // ROOT directory
+
+		// add another encrypted folder:
+		Folder testFolder = fs.folder("test");
+		Assert.assertFalse(testFolder.exists());
+		testFolder.create(FolderCreateMode.INCLUDING_PARENTS);
+		Assert.assertTrue(testFolder.exists());
+		Assert.assertEquals(2, dataRoot.folders().count());
+	}
+
+}

+ 1 - 0
main/filesystem-api/.gitignore

@@ -1 +1,2 @@
 /target/
+/target/

+ 3 - 2
main/filesystem-api/src/main/java/org/cryptomator/filesystem/File.java

@@ -7,11 +7,12 @@ package org.cryptomator.filesystem;
 
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 public interface File extends Node {
 
-	ReadableFile openReadable(long timeout, TimeUnit unit) throws IOException;
+	ReadableFile openReadable(long timeout, TimeUnit unit) throws IOException, TimeoutException;
 
-	WritableFile openWritable(long timeout, TimeUnit unit) throws IOException;
+	WritableFile openWritable(long timeout, TimeUnit unit) throws IOException, TimeoutException;
 
 }

+ 1 - 1
main/filesystem-api/src/main/java/org/cryptomator/filesystem/FileSystem.java

@@ -15,7 +15,7 @@ import java.util.Optional;
 public interface FileSystem extends Folder {
 
 	@Override
-	default Optional<Folder> parent() {
+	default Optional<? extends Folder> parent() {
 		return Optional.empty();
 	}
 

+ 5 - 5
main/filesystem-api/src/main/java/org/cryptomator/filesystem/Folder.java

@@ -32,11 +32,11 @@ public interface Folder extends Node {
 	 *             if an {@link IOException} occurs while initializing the
 	 *             stream
 	 */
-	Stream<Node> children() throws IOException;
+	Stream<? extends Node> children() throws IOException;
 
-	File file(String name) throws IOException;
+	File file(String name) throws UncheckedIOException;
 
-	Folder folder(String name) throws IOException;
+	Folder folder(String name) throws UncheckedIOException;
 
 	void create(FolderCreateMode mode) throws IOException;
 
@@ -46,7 +46,7 @@ public interface Folder extends Node {
 	 * @return the result of {@link #children()} filtered to contain only
 	 *         {@link File Files}
 	 */
-	default Stream<File> files() throws IOException {
+	default Stream<? extends File> files() throws IOException {
 		return children() //
 				.filter(File.class::isInstance) //
 				.map(File.class::cast);
@@ -56,7 +56,7 @@ public interface Folder extends Node {
 	 * @return the result of {@link #children()} filtered to contain only
 	 *         {@link Folder Folders}
 	 */
-	default Stream<Folder> folders() throws IOException {
+	default Stream<? extends Folder> folders() throws IOException {
 		return children() //
 				.filter(Folder.class::isInstance) //
 				.map(Folder.class::cast);

+ 5 - 5
main/filesystem-api/src/main/java/org/cryptomator/filesystem/Node.java

@@ -5,7 +5,7 @@
  ******************************************************************************/
 package org.cryptomator.filesystem;
 
-import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.time.Instant;
 import java.util.Optional;
 
@@ -19,12 +19,12 @@ import java.util.Optional;
  */
 public interface Node {
 
-	String name() throws IOException;
+	String name() throws UncheckedIOException;
 
-	Optional<Folder> parent() throws IOException;
+	Optional<? extends Folder> parent() throws UncheckedIOException;
 
-	boolean exists() throws IOException;
+	boolean exists() throws UncheckedIOException;
 
-	Instant lastModified() throws IOException;
+	Instant lastModified() throws UncheckedIOException;
 
 }

+ 141 - 0
main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFile.java

@@ -0,0 +1,141 @@
+package org.cryptomator.filesystem.inmem;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.cryptomator.filesystem.ReadableFile;
+import org.cryptomator.filesystem.WritableFile;
+
+public class InMemoryFile extends InMemoryNode implements ReadableFile, WritableFile {
+
+	private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
+	private ByteBuffer content = ByteBuffer.wrap(new byte[0]);
+
+	InMemoryFile(InMemoryFolder parent, String name, Instant lastModified) {
+		super(parent, name, lastModified);
+	}
+
+	@Override
+	public ReadableFile openReadable(long timeout, TimeUnit unit) throws IOException, TimeoutException {
+		if (!exists()) {
+			throw new FileNotFoundException(this.name() + " does not exist");
+		}
+		try {
+			if (!lock.readLock().tryLock(timeout, unit)) {
+				throw new TimeoutException("Failed to open " + name() + " for reading within time limit.");
+			}
+		} catch (InterruptedException e) {
+			Thread.currentThread().interrupt();
+		}
+		return this;
+	}
+
+	@Override
+	public WritableFile openWritable(long timeout, TimeUnit unit) throws IOException, TimeoutException {
+		try {
+			if (!lock.writeLock().tryLock(timeout, unit)) {
+				throw new TimeoutException("Failed to open " + name() + " for writing within time limit.");
+			}
+		} catch (InterruptedException e) {
+			Thread.currentThread().interrupt();
+		}
+		final InMemoryFolder parent = parent().get();
+		try {
+			parent.children.compute(this.name(), (k, v) -> {
+				if (v != null && v != this) {
+					throw new IllegalStateException("More than one representation of same file");
+				}
+				return this;
+			});
+		} catch (UncheckedIOException e) {
+			throw e.getCause();
+		}
+		return this;
+	}
+
+	@Override
+	public void read(ByteBuffer target) throws IOException {
+		this.read(target, 0);
+	}
+
+	@Override
+	public void read(ByteBuffer target, int position) throws IOException {
+		content.rewind();
+		content.position(position);
+		target.put(content);
+	}
+
+	@Override
+	public void write(ByteBuffer source) throws IOException {
+		this.write(source, content.position());
+	}
+
+	@Override
+	public void write(ByteBuffer source, int position) throws IOException {
+		assert content != null;
+		if (position + source.remaining() > content.remaining()) {
+			// create bigger buffer
+			ByteBuffer tmp = ByteBuffer.allocate(Math.max(position, content.capacity()) + source.remaining());
+			tmp.put(content);
+			content = tmp;
+		}
+		content.position(position);
+		content.put(source);
+	}
+
+	@Override
+	public WritableFile moveTo(WritableFile other) throws IOException {
+		this.copyTo(other);
+		this.delete();
+		return other;
+	}
+
+	@Override
+	public void setLastModified(Instant instant) {
+		this.lastModified = instant;
+	}
+
+	@Override
+	public void delete() {
+		final InMemoryFolder parent = parent().get();
+		parent.children.computeIfPresent(this.name(), (k, v) -> {
+			truncate();
+			// returning null removes the entry.
+			return null;
+		});
+	}
+
+	@Override
+	public void truncate() {
+		content = ByteBuffer.wrap(new byte[0]);
+	}
+
+	@Override
+	public WritableFile copyTo(WritableFile other) throws IOException {
+		content.rewind();
+		other.truncate();
+		other.write(content);
+		return other;
+	}
+
+	@Override
+	public void close() throws IOException {
+		if (lock.isWriteLockedByCurrentThread()) {
+			lock.writeLock().unlock();
+		} else if (lock.getReadHoldCount() > 0) {
+			lock.readLock().unlock();
+		}
+	}
+
+	@Override
+	public String toString() {
+		return parent.toString() + name;
+	}
+
+}

+ 34 - 0
main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFileSystem.java

@@ -0,0 +1,34 @@
+package org.cryptomator.filesystem.inmem;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import org.cryptomator.filesystem.FileSystem;
+
+public class InMemoryFileSystem extends InMemoryFolder implements FileSystem {
+
+	public InMemoryFileSystem() {
+		super(null, "", Instant.now());
+	}
+
+	@Override
+	public Optional<InMemoryFolder> parent() {
+		return Optional.empty();
+	}
+
+	@Override
+	public boolean exists() {
+		return true;
+	}
+
+	@Override
+	public void delete() {
+		// no-op.
+	}
+
+	@Override
+	public String toString() {
+		return "/";
+	}
+
+}

+ 99 - 0
main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFolder.java

@@ -0,0 +1,99 @@
+package org.cryptomator.filesystem.inmem;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.stream.Stream;
+
+import org.apache.commons.io.FileExistsException;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+
+public class InMemoryFolder extends InMemoryNode implements Folder {
+
+	final Map<String, InMemoryNode> children = new TreeMap<>();
+	final Map<String, InMemoryNode> volatileChildren = new HashMap<>();
+
+	InMemoryFolder(InMemoryFolder parent, String name, Instant lastModified) {
+		super(parent, name, lastModified);
+	}
+
+	@Override
+	public Stream<InMemoryNode> children() {
+		return children.values().stream();
+	}
+
+	@Override
+	public InMemoryFile file(String name) {
+		InMemoryNode node = children.get(name);
+		if (node == null) {
+			node = volatileChildren.computeIfAbsent(name, (k) -> {
+				return new InMemoryFile(this, name, Instant.MIN);
+			});
+		}
+		if (node instanceof InMemoryFile) {
+			return (InMemoryFile) node;
+		} else {
+			throw new UncheckedIOException(new FileAlreadyExistsException(name + " exists, but is not a file."));
+		}
+	}
+
+	@Override
+	public InMemoryFolder folder(String name) {
+		InMemoryNode node = children.get(name);
+		if (node == null) {
+			node = volatileChildren.computeIfAbsent(name, (k) -> {
+				return new InMemoryFolder(this, name, Instant.MIN);
+			});
+		}
+		if (node instanceof InMemoryFolder) {
+			return (InMemoryFolder) node;
+		} else {
+			throw new UncheckedIOException(new FileAlreadyExistsException(name + " exists, but is not a folder."));
+		}
+	}
+
+	@Override
+	public void create(FolderCreateMode mode) throws IOException {
+		if (exists()) {
+			return;
+		}
+		if (!parent.exists() && FolderCreateMode.FAIL_IF_PARENT_IS_MISSING.equals(mode)) {
+			throw new FileNotFoundException(parent.name);
+		} else if (!parent.exists() && FolderCreateMode.INCLUDING_PARENTS.equals(mode)) {
+			parent.create(mode);
+		}
+		assert parent.exists();
+		try {
+			parent.children.compute(this.name(), (k, v) -> {
+				if (v == null) {
+					this.lastModified = Instant.now();
+					return this;
+				} else {
+					throw new UncheckedIOException(new FileExistsException(k));
+				}
+			});
+		} catch (UncheckedIOException e) {
+			throw e.getCause();
+		}
+	}
+
+	@Override
+	public void delete() {
+		parent.children.computeIfPresent(name, (k, v) -> {
+			// returning null removes the entry.
+			return null;
+		});
+	}
+
+	@Override
+	public String toString() {
+		return parent.toString() + name + "/";
+	}
+
+}

+ 61 - 0
main/filesystem-api/src/main/java/org/cryptomator/filesystem/inmem/InMemoryNode.java

@@ -0,0 +1,61 @@
+package org.cryptomator.filesystem.inmem;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import org.cryptomator.filesystem.Node;
+
+public class InMemoryNode implements Node {
+
+	protected final InMemoryFolder parent;
+	protected final String name;
+	protected Instant lastModified;
+
+	InMemoryNode(InMemoryFolder parent, String name, Instant lastModified) {
+		this.parent = parent;
+		this.name = name;
+		this.lastModified = lastModified;
+	}
+
+	@Override
+	public String name() {
+		return name;
+	}
+
+	@Override
+	public Optional<InMemoryFolder> parent() {
+		return Optional.of(parent);
+	}
+
+	@Override
+	public boolean exists() {
+		return parent.children().anyMatch(node -> node.equals(this));
+	}
+
+	@Override
+	public Instant lastModified() {
+		return lastModified;
+	}
+
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + ((name == null) ? 0 : name.hashCode());
+		result = prime * result + ((parent == null) ? 0 : parent.hashCode());
+		return result;
+	}
+
+	@Override
+	public boolean equals(Object obj) {
+		if (obj instanceof InMemoryNode) {
+			InMemoryNode other = (InMemoryNode) obj;
+			return this.getClass() == other.getClass() //
+					&& (this.parent == null && other.parent == null || this.parent.equals(other.parent)) //
+					&& (this.name == null && other.name == null || this.name.equals(other.name));
+		} else {
+			return false;
+		}
+	}
+
+}

+ 94 - 0
main/filesystem-api/src/test/java/org/cryptomator/filesystem/inmem/InMemoryFileSystemTest.java

@@ -0,0 +1,94 @@
+package org.cryptomator.filesystem.inmem;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.FileSystem;
+import org.cryptomator.filesystem.Folder;
+import org.cryptomator.filesystem.FolderCreateMode;
+import org.cryptomator.filesystem.ReadableFile;
+import org.cryptomator.filesystem.WritableFile;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class InMemoryFileSystemTest {
+
+	@Test
+	public void testFolderCreation() throws IOException {
+		final FileSystem fs = new InMemoryFileSystem();
+		Folder fooFolder = fs.folder("foo");
+
+		// nothing happened yet:
+		Assert.assertFalse(fooFolder.exists());
+		Assert.assertEquals(0, fs.folders().count());
+
+		// create /foo
+		fooFolder.create(FolderCreateMode.FAIL_IF_PARENT_IS_MISSING);
+		Assert.assertTrue(fooFolder.exists());
+		Assert.assertEquals(1, fs.folders().count());
+
+		// delete /foo
+		fooFolder.delete();
+		Assert.assertFalse(fooFolder.exists());
+		Assert.assertEquals(0, fs.folders().count());
+
+		// create /foo/bar
+		Folder fooBarFolder = fooFolder.folder("bar");
+		Assert.assertFalse(fooBarFolder.exists());
+		fooBarFolder.create(FolderCreateMode.INCLUDING_PARENTS);
+		Assert.assertTrue(fooFolder.exists());
+		Assert.assertTrue(fooBarFolder.exists());
+		Assert.assertEquals(1, fs.folders().count());
+		Assert.assertEquals(1, fooFolder.folders().count());
+	}
+
+	@Test
+	public void testFileReadCopyMoveWrite() throws IOException, TimeoutException {
+		final FileSystem fs = new InMemoryFileSystem();
+		File fooFile = fs.file("foo.txt");
+
+		// nothing happened yet:
+		Assert.assertFalse(fooFile.exists());
+		Assert.assertEquals(0, fs.files().count());
+
+		// write "hello world" to foo
+		try (WritableFile writable = fooFile.openWritable(1, TimeUnit.SECONDS)) {
+			writable.write(ByteBuffer.wrap("hello".getBytes()));
+			writable.write(ByteBuffer.wrap(" ".getBytes()));
+			writable.write(ByteBuffer.wrap("world".getBytes()));
+		}
+		Assert.assertTrue(fooFile.exists());
+
+		// copy foo to bar
+		File barFile = fs.file("bar.txt");
+		try (WritableFile writable = barFile.openWritable(1, TimeUnit.SECONDS)) {
+			try (ReadableFile readable = fooFile.openReadable(1, TimeUnit.SECONDS)) {
+				readable.copyTo(writable);
+			}
+		}
+		Assert.assertTrue(fooFile.exists());
+		Assert.assertTrue(barFile.exists());
+
+		// move bar to baz
+		File bazFile = fs.file("baz.txt");
+		try (WritableFile src = barFile.openWritable(1, TimeUnit.SECONDS)) {
+			try (WritableFile dst = bazFile.openWritable(1, TimeUnit.SECONDS)) {
+				src.moveTo(dst);
+			}
+		}
+		Assert.assertFalse(barFile.exists());
+		Assert.assertTrue(bazFile.exists());
+
+		// read "hello world" from baz
+		final ByteBuffer readBuf = ByteBuffer.allocate(5);
+		try (ReadableFile readable = bazFile.openReadable(1, TimeUnit.SECONDS)) {
+			readable.read(readBuf, 6);
+		}
+		Assert.assertEquals("world", new String(readBuf.array()));
+
+	}
+
+}

+ 1 - 0
main/pom.xml

@@ -202,6 +202,7 @@
 		<module>crypto-aes</module>
 		<module>core</module>
 		<module>ui</module>
+		<module>crypto-layer</module>
 	</modules>
 
 	<profiles>