mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-08-01 11:08:23 -04:00
Add DeeplinkAction
for handling URIs Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
38
src/main/java/org/cryptomator/launcher/DeeplinkAction.java
Normal file
38
src/main/java/org/cryptomator/launcher/DeeplinkAction.java
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user