diff --git a/src/main/java/org/cryptomator/launcher/DeeplinkAction.java b/src/main/java/org/cryptomator/launcher/DeeplinkAction.java new file mode 100644 index 000000000..c110ab393 --- /dev/null +++ b/src/main/java/org/cryptomator/launcher/DeeplinkAction.java @@ -0,0 +1,38 @@ +package org.cryptomator.launcher; + +import java.net.URI; +import java.util.Arrays; +import java.util.Optional; + +/** + * Enumerates the supported deeplink actions and maps an incoming {@link URI} to one of them. + *

+ * A deeplink action is identified by the URI's host and path, e.g. {@code cryptomator://vault/create} maps to + * {@link #VAULT_CREATE}. Unknown host/path combinations yield an empty {@link Optional} and should be treated as + * unsupported. New actions are added by extending this enum. + */ +public enum DeeplinkAction { + + VAULT_CREATE("vault", "/create"); + + private final String host; + private final String path; + + DeeplinkAction(String host, String path) { + this.host = host; + this.path = path; + } + + /** + * Matches the given URI against the known deeplink actions. + * + * @param uri the deeplink URI + * @return the matching action, or an empty optional if the host/path is not recognized + */ + public static Optional match(URI uri) { + return Arrays.stream(values()) // + .filter(action -> action.host.equalsIgnoreCase(uri.getHost()) && action.path.equals(uri.getPath())) // + .findFirst(); + } + +} diff --git a/src/main/java/org/cryptomator/launcher/VaultCreateDeeplink.java b/src/main/java/org/cryptomator/launcher/VaultCreateDeeplink.java new file mode 100644 index 000000000..c82987f82 --- /dev/null +++ b/src/main/java/org/cryptomator/launcher/VaultCreateDeeplink.java @@ -0,0 +1,82 @@ +package org.cryptomator.launcher; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parsed representation of a {@code cryptomator://vault/create?name=…&template=…} deeplink. + *

+ * The {@code template} is a Base64URL-encoded ZIP archive holding a ready-made (signed) vault. The {@code name} fixes + * the vault's directory name and is restricted to a single, safe path segment so that resolving it against a + * user-chosen location cannot escape that location. + * + * @param name the fixed vault name (a single, safe path segment) + * @param template the Base64URL-decoded vault template + */ +public record VaultCreateDeeplink(String name, byte[] template) { + + private static final Logger LOG = LoggerFactory.getLogger(VaultCreateDeeplink.class); + + /** + * Parses the query parameters of a {@code vault/create} deeplink. + * + * @param uri the deeplink URI + * @return the parsed deeplink + * @throws IllegalArgumentException if a required parameter is missing, the template is not valid Base64URL, or the + * name is not a single safe path segment + */ + public static VaultCreateDeeplink parse(URI uri) { + var params = parseQuery(uri.getRawQuery()); + var name = params.get("name"); + var templateParam = params.get("template"); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Missing required query parameter 'name'."); + } + if (templateParam == null || templateParam.isEmpty()) { + throw new IllegalArgumentException("Missing required query parameter 'template'."); + } + validateName(name); + byte[] template; + try { + template = Base64.getUrlDecoder().decode(templateParam); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Query parameter 'template' is not valid Base64URL.", e); + } + var leftoverParams = params.keySet().stream().filter(k -> ! (k.equals("template") || k.equals("name"))).toList(); + if(!leftoverParams.isEmpty()) { + LOG.debug("Ignoring unknow parameters {}", leftoverParams); + } + return new VaultCreateDeeplink(name, template); + } + + private static void validateName(String name) { + if (name.contains("/") || name.contains("\\") || name.contains("..") || name.equals(".")) { + throw new IllegalArgumentException("Query parameter 'name' must be a single path segment, but was '" + name + "'."); + } + } + + private static Map parseQuery(String rawQuery) { + var params = new HashMap(); + if (rawQuery == null || rawQuery.isEmpty()) { + return params; + } + for (var pair : rawQuery.split("&")) { + var idx = pair.indexOf('='); + if (idx < 0) { + continue; + } + var key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8); + var value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8); + params.put(key, value); + } + return params; + } + +} diff --git a/src/test/java/org/cryptomator/launcher/DeeplinkActionTest.java b/src/test/java/org/cryptomator/launcher/DeeplinkActionTest.java new file mode 100644 index 000000000..9968a5566 --- /dev/null +++ b/src/test/java/org/cryptomator/launcher/DeeplinkActionTest.java @@ -0,0 +1,40 @@ +package org.cryptomator.launcher; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Optional; + +public class DeeplinkActionTest { + + @Test + @DisplayName("cryptomator://vault/create maps to VAULT_CREATE") + public void testVaultCreateMatches() { + var result = DeeplinkAction.match(URI.create("cryptomator://vault/create?name=foo&template=bar")); + + Assertions.assertEquals(Optional.of(DeeplinkAction.VAULT_CREATE), result); + } + + @Test + @DisplayName("host matching is case-insensitive") + public void testHostCaseInsensitive() { + var result = DeeplinkAction.match(URI.create("cryptomator://VAULT/create")); + + Assertions.assertEquals(Optional.of(DeeplinkAction.VAULT_CREATE), result); + } + + @Test + @DisplayName("an unknown host yields empty") + public void testUnknownHost() { + Assertions.assertEquals(Optional.empty(), DeeplinkAction.match(URI.create("cryptomator://foo/create"))); + } + + @Test + @DisplayName("an unknown path yields empty") + public void testUnknownPath() { + Assertions.assertEquals(Optional.empty(), DeeplinkAction.match(URI.create("cryptomator://vault/bar"))); + } + +} diff --git a/src/test/java/org/cryptomator/launcher/VaultCreateDeeplinkTest.java b/src/test/java/org/cryptomator/launcher/VaultCreateDeeplinkTest.java new file mode 100644 index 000000000..723f528ad --- /dev/null +++ b/src/test/java/org/cryptomator/launcher/VaultCreateDeeplinkTest.java @@ -0,0 +1,69 @@ +package org.cryptomator.launcher; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class VaultCreateDeeplinkTest { + + private static final byte[] TEMPLATE_BYTES = "a-ready-made-vault-zip".getBytes(StandardCharsets.UTF_8); + private static final String TEMPLATE_B64 = Base64.getUrlEncoder().withoutPadding().encodeToString(TEMPLATE_BYTES); + + @Test + @DisplayName("a valid vault/create deeplink is parsed") + public void testValid() { + var inTest = VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault&template=" + TEMPLATE_B64)); + + Assertions.assertEquals("MyVault", inTest.name()); + Assertions.assertArrayEquals(TEMPLATE_BYTES, inTest.template()); + } + + @Test + @DisplayName("a URL-encoded name is decoded") + public void testUrlEncodedName() { + var inTest = VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=My%20Vault&template=" + TEMPLATE_B64)); + + Assertions.assertEquals("My Vault", inTest.name()); + } + + @Test + @DisplayName("a missing name fails") + public void testMissingName() { + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?template=" + TEMPLATE_B64))); + } + + @Test + @DisplayName("a blank name fails") + public void testBlankName() { + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=&template=" + TEMPLATE_B64))); + } + + @Test + @DisplayName("a missing template fails") + public void testMissingTemplate() { + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault"))); + } + + @Test + @DisplayName("an invalid Base64URL template fails") + public void testInvalidTemplate() { + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault&template=@@@"))); + } + + @Test + @DisplayName("a name containing a path separator fails") + public void testNameWithSlash() { + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=foo%2Fbar&template=" + TEMPLATE_B64))); + } + + @Test + @DisplayName("a name with parent-dir traversal fails") + public void testNameWithTraversal() { + Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=..&template=" + TEMPLATE_B64))); + } + +}