Add DeeplinkAction

for handling URIs

Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
Armin Schrenk
2026-07-13 10:27:23 +02:00
parent a3fc50fc7e
commit 77dbf9414a
4 changed files with 229 additions and 0 deletions

View File

@@ -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.
* <p>
* 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<DeeplinkAction> match(URI uri) {
return Arrays.stream(values()) //
.filter(action -> action.host.equalsIgnoreCase(uri.getHost()) && action.path.equals(uri.getPath())) //
.findFirst();
}
}

View File

@@ -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.
* <p>
* 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<String, String> parseQuery(String rawQuery) {
var params = new HashMap<String, String>();
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;
}
}

View File

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

View File

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