diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 995b155..c728d22 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -18,6 +18,7 @@ requires org.purejava.appindicator; requires org.purejava.kwallet; requires de.swiesend.secretservice; + requires java.xml; provides AutoStartProvider with FreedesktopAutoStartService; provides KeychainAccessProvider with GnomeKeyringKeychainAccess, KDEWalletKeychainAccess; diff --git a/src/main/java/org/cryptomator/linux/quickaccess/DolphinPlaces.java b/src/main/java/org/cryptomator/linux/quickaccess/DolphinPlaces.java index 6570d88..22cf207 100644 --- a/src/main/java/org/cryptomator/linux/quickaccess/DolphinPlaces.java +++ b/src/main/java/org/cryptomator/linux/quickaccess/DolphinPlaces.java @@ -6,18 +6,40 @@ import org.cryptomator.integrations.common.Priority; import org.cryptomator.integrations.quickaccess.QuickAccessService; import org.cryptomator.integrations.quickaccess.QuickAccessServiceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.DOMException; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; import org.xml.sax.SAXException; import javax.xml.XMLConstants; +import javax.xml.namespace.QName; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; import javax.xml.transform.Source; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; import javax.xml.validation.SchemaFactory; import javax.xml.validation.Validator; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.UUID; /** @@ -29,20 +51,11 @@ @Priority(90) public class DolphinPlaces extends FileConfiguredQuickAccess implements QuickAccessService { + private static final Logger LOG = LoggerFactory.getLogger(DolphinPlaces.class); + + private static final String XBEL_NAMESPACE = "http://www.freedesktop.org/standards/desktop-bookmarks"; private static final int MAX_FILE_SIZE = 1 << 20; //1MiB, xml is quite verbose private static final Path PLACES_FILE = Path.of(System.getProperty("user.home"), ".local/share/user-places.xbel"); - private static final String ENTRY_TEMPLATE = """ - - %s - - - - - - %s - - - """; private static final Validator XML_VALIDATOR; @@ -61,29 +74,157 @@ public DolphinPlaces() { super(PLACES_FILE, MAX_FILE_SIZE); } + public DolphinPlaces(Path configFilePath) { + super(configFilePath, MAX_FILE_SIZE); + } + @Override EntryAndConfig addEntryToConfig(String config, Path target, String displayName) throws QuickAccessServiceException { try { - String id = UUID.randomUUID().toString(); - //validate + var id = UUID.randomUUID().toString(); + LOG.trace("Adding bookmark for target: '{}', displayName: '{}', id: '{}'", target, displayName, id); XML_VALIDATOR.validate(new StreamSource(new StringReader(config))); - // modify - int insertIndex = config.lastIndexOf("",">"); + private void removeStaleBookmarks(NodeList nodeList) { + for (int i = nodeList.getLength() - 1; i >= 0; i--) { + Node node = nodeList.item(i); + node.getParentNode().removeChild(node); + } + } + + private NodeList extractBookmarksByPath(Path target, Document xmlDocument) throws QuickAccessServiceException { + try { + var xpathFactory = XPathFactory.newInstance(); + var xpath = xpathFactory.newXPath(); + xpath.setXPathVariableResolver(v -> { + if (v.equals(new QName("uri"))) { + return target.toUri().toString(); + } + throw new IllegalArgumentException(); + }); + var expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][@href=$uri]"; + return (NodeList) xpath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET); + } catch (XPathExpressionException xee) { + throw new QuickAccessServiceException("Invalid XPath expression", xee); + } + } + + private NodeList extractBookmarksById(String id, Document xmlDocument) throws QuickAccessServiceException { + try { + var xpathFactory = XPathFactory.newInstance(); + var xpath = xpathFactory.newXPath(); + xpath.setXPathVariableResolver(v -> { + if (v.equals(new QName("id"))) { + return id; + } + throw new IllegalArgumentException(); + }); + var expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][info/metadata/id[text()=$id]]"; + return (NodeList) xpath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET); + } catch (XPathExpressionException xee) { + throw new QuickAccessServiceException("Invalid XPath expression", xee); + } + } + + private Document loadXmlDocument(String config) throws QuickAccessServiceException { + try { + var builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + builderFactory.setXIncludeAware(false); + builderFactory.setExpandEntityReferences(false); + builderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + builderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + builderFactory.setNamespaceAware(true); + DocumentBuilder builder = builderFactory.newDocumentBuilder(); + // Prevent external entities from being resolved + builder.setEntityResolver((publicId, systemId) -> new InputSource(new StringReader(""))); + return builder.parse(new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))); + } catch (IOException | SAXException | ParserConfigurationException e) { + throw new QuickAccessServiceException("Error while loading xml file", e); + } + } + + private String documentToString(Document xmlDocument) throws QuickAccessServiceException { + try { + var buf = new StringWriter(); + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC, ""); + transformer.setOutputProperty(OutputKeys.DOCTYPE_SYSTEM, ""); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, StandardCharsets.UTF_8.name()); + transformer.transform(new DOMSource(xmlDocument), new StreamResult(buf)); + var content = buf.toString(); + content = content.replaceFirst("\\s*standalone=\"(yes|no)\"", ""); + content = content.replaceFirst("",""); + return content; + } catch (TransformerException e) { + throw new QuickAccessServiceException("Error while serializing document to string", e); + } + } + + /** + * + * Adds a xml bookmark element to the specified xml document + * + *
{@code
+	 * 
+	 *   integrations-linux
+	 *   
+	 *     
+	 *       
+	 *     
+	 *     
+	 *       sldkf-sadf-sadf-sadf
+	 *     
+	 *   
+	 * 
+	 * }
+ * + * @param target The mount point of the vault + * @param displayName Caption of the vault link in dolphin + * @param xmlDocument The xbel document to which the bookmark should be added + * + * @throws QuickAccessServiceException if the bookmark could not be created + */ + private void createBookmark(Path target, String displayName, String id, Document xmlDocument) throws QuickAccessServiceException { + try { + var bookmark = xmlDocument.createElement("bookmark"); + var title = xmlDocument.createElement("title"); + var info = xmlDocument.createElement("info"); + var metadataBookmark = xmlDocument.createElement("metadata"); + var metadataOwner = xmlDocument.createElement("metadata"); + var bookmarkIcon = xmlDocument.createElementNS(XBEL_NAMESPACE, "bookmark:icon"); + var idElem = xmlDocument.createElement("id"); + bookmark.setAttribute("href", target.toUri().toString()); + title.setTextContent(displayName); + bookmark.appendChild(title); + bookmark.appendChild(info); + info.appendChild(metadataBookmark); + info.appendChild(metadataOwner); + metadataBookmark.appendChild(bookmarkIcon); + metadataOwner.appendChild(idElem); + metadataBookmark.setAttribute("owner", "http://freedesktop.org"); + bookmarkIcon.setAttribute("name","drive-harddisk-encrypted"); + metadataOwner.setAttribute("owner", "https://cryptomator.org"); + idElem.setTextContent(id); + xmlDocument.getDocumentElement().appendChild(bookmark); + } catch (DOMException | IllegalArgumentException e) { + throw new QuickAccessServiceException("Error while creating bookmark for target: " + target, e); + } } private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implements QuickAccessEntry { @@ -97,46 +238,20 @@ private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implemen @Override public String removeEntryFromConfig(String config) throws QuickAccessServiceException { try { - int idIndex = config.lastIndexOf(id); - if (idIndex == -1) { - return config; //assume someone has removed our entry, nothing to do - } - //validate - XML_VALIDATOR.validate(new StreamSource(new StringReader(config))); - //modify - int openingTagIndex = indexOfEntryOpeningTag(config, idIndex); - var contentToWrite1 = config.substring(0, openingTagIndex).stripTrailing(); - - int closingTagEndIndex = config.indexOf('>', config.indexOf(" tag."); - } } @CheckAvailability public static boolean isSupported() { return Files.exists(PLACES_FILE); } -} +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/linux/quickaccess/FileConfiguredQuickAccess.java b/src/main/java/org/cryptomator/linux/quickaccess/FileConfiguredQuickAccess.java index 70ed6c1..44d74fd 100644 --- a/src/main/java/org/cryptomator/linux/quickaccess/FileConfiguredQuickAccess.java +++ b/src/main/java/org/cryptomator/linux/quickaccess/FileConfiguredQuickAccess.java @@ -31,10 +31,20 @@ abstract class FileConfiguredQuickAccess implements QuickAccessService { Runtime.getRuntime().addShutdownHook(new Thread(this::cleanup)); } + /** + * + * Adds the vault path to the quick-access config file + * + * @param target The mount point of the vault + * @param displayName Caption of the vault link + * @return A cleanup reference for vault link removal + * @throws QuickAccessServiceException If the entry could not be added to the quick-access config file + */ @Override public QuickAccessEntry add(Path target, String displayName) throws QuickAccessServiceException { try { modifyLock.lock(); + checkFileSize(); var entryAndConfig = addEntryToConfig(readConfig(), target, displayName); persistConfig(entryAndConfig.config()); return entryAndConfig.entry(); diff --git a/src/test/java/org/cryptomator/linux/quickaccess/DolphinPlacesTest.java b/src/test/java/org/cryptomator/linux/quickaccess/DolphinPlacesTest.java index 0075374..7400c6f 100644 --- a/src/test/java/org/cryptomator/linux/quickaccess/DolphinPlacesTest.java +++ b/src/test/java/org/cryptomator/linux/quickaccess/DolphinPlacesTest.java @@ -1,14 +1,155 @@ package org.cryptomator.linux.quickaccess; -import org.junit.jupiter.api.Assertions; +import org.cryptomator.integrations.quickaccess.QuickAccessServiceException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +import static org.junit.jupiter.api.Assertions.*; public class DolphinPlacesTest { + private static final String UUID_FOLDER_1 = "c4b72799-ca67-4c2e-b727-99ca67dc2e5d"; + private static final String UUID_FOLDER_1_IDENTICAL = "43c6fdb9-626d-468e-86fd-b9626dc68e04d"; + private static final String CAPTION_FOLDER_1 = "folder 1"; + private static final String PATH_FOLDER_1 = "/home/someuser/folder1"; + + private static final String RESOURCE_USER_PLACES = "quickaccess/dolphin/user-places.xbel"; + private static final String RESOURCE_USER_PLACES_MULTIPLE_IDENTICAL = "quickaccess/dolphin/user-places-multiple-identical.xbel"; + private static final String RESOURCE_USER_PLACES_NOT_WELL_FORMED = "quickaccess/dolphin/user-places-not-well-formed.xbel"; + private static final String RESOURCE_USER_PLACES_NOT_VALID = "quickaccess/dolphin/user-places-not-valid.xbel"; + @Test @DisplayName("Class can be loaded and object instantiated") public void testInit() { - Assertions.assertDoesNotThrow(DolphinPlaces::new); + assertDoesNotThrow(() -> { new DolphinPlaces(); }); + } + + @Test + @DisplayName("Adding an identical entry should lead to a replacement of the existing entry") + public void addingAnIdenticalEntryShouldLeadToReplacementOfExistingEntry(@TempDir Path tmpdir) { + + var pathToDoc = loadResourceToDir(RESOURCE_USER_PLACES, tmpdir); + assertTrue(loadFile(pathToDoc).contains(UUID_FOLDER_1)); + assertTrue(loadFile(pathToDoc).contains(CAPTION_FOLDER_1)); + assertDoesNotThrow(() -> { + var entry = new DolphinPlaces(tmpdir.resolve("user-places.xbel")).add(Path.of(PATH_FOLDER_1), CAPTION_FOLDER_1); + assertFalse(loadFile(pathToDoc).contains(UUID_FOLDER_1)); + assertTrue(loadFile(pathToDoc).contains(CAPTION_FOLDER_1)); + entry.remove(); + }); + } + + @Test + @DisplayName("Adding an identical entry should lead to a replacement of multiple existing entries") + public void addingAnIdenticalEntryShouldLeadToReplacementOfMultipleExistingEntry(@TempDir Path tmpdir) { + var pathToDoc = loadResourceToDir(RESOURCE_USER_PLACES_MULTIPLE_IDENTICAL, tmpdir); + assertEquals(1, countOccurrences(loadFile(pathToDoc),UUID_FOLDER_1)); + assertEquals(1, countOccurrences(loadFile(pathToDoc),UUID_FOLDER_1_IDENTICAL)); + assertEquals(2, countOccurrences(loadFile(pathToDoc), CAPTION_FOLDER_1)); + assertDoesNotThrow(() -> { + var entry = new DolphinPlaces(tmpdir.resolve("user-places.xbel")).add(Path.of(PATH_FOLDER_1), CAPTION_FOLDER_1); + assertEquals(0, countOccurrences(loadFile(pathToDoc),UUID_FOLDER_1)); + assertEquals(0, countOccurrences(loadFile(pathToDoc),UUID_FOLDER_1_IDENTICAL)); + assertEquals(1, countOccurrences(loadFile(pathToDoc), CAPTION_FOLDER_1)); + entry.remove(); + }); + assertEquals(0, countOccurrences(loadFile(pathToDoc), CAPTION_FOLDER_1)); + } + + @Test + @DisplayName("Adding should not replace if file is not valid") + public void addingShouldNotReplaceIfFileIsNotValid(@TempDir Path tmpdir) { + var pathToDoc = loadResourceToDir(RESOURCE_USER_PLACES_NOT_VALID, tmpdir); + assertEquals(1, countOccurrences(loadFile(pathToDoc), UUID_FOLDER_1)); + assertEquals(1, countOccurrences(loadFile(pathToDoc), CAPTION_FOLDER_1)); + assertThrows(QuickAccessServiceException.class, () -> { + new DolphinPlaces(tmpdir.resolve("user-places.xbel")).add(Path.of(PATH_FOLDER_1), CAPTION_FOLDER_1); + }); + assertEquals(1, countOccurrences(loadFile(pathToDoc), UUID_FOLDER_1)); + assertEquals(1, countOccurrences(loadFile(pathToDoc), CAPTION_FOLDER_1)); + } + + @Test + @DisplayName("Adding should not replace if file is not well formed") + public void addingShouldNotReplaceIfFileIsNotWellFormed(@TempDir Path tmpdir) { + var pathToDoc = loadResourceToDir(RESOURCE_USER_PLACES_NOT_WELL_FORMED, tmpdir); + assertEquals(1, countOccurrences(loadFile(pathToDoc), UUID_FOLDER_1)); + assertEquals(1, countOccurrences(loadFile(pathToDoc), CAPTION_FOLDER_1)); + assertThrows(QuickAccessServiceException.class, () -> { + new DolphinPlaces(tmpdir.resolve("user-places.xbel")).add(Path.of(PATH_FOLDER_1), CAPTION_FOLDER_1); + }); + assertEquals(1, countOccurrences(loadFile(pathToDoc), UUID_FOLDER_1)); + assertEquals(1, countOccurrences(loadFile(pathToDoc), CAPTION_FOLDER_1)); + } + + @Test + @DisplayName("Invalid characters in caption should be escaped") + public void invalidCharactersInCaptionShouldBeEscaped(@TempDir Path tmpdir) { + var pathToDoc = loadResourceToDir(RESOURCE_USER_PLACES, tmpdir); + assertEquals(0, countOccurrences(loadFile(pathToDoc), "< & >")); + assertDoesNotThrow(() -> { + new DolphinPlaces(tmpdir.resolve("user-places.xbel")).add(Path.of(PATH_FOLDER_1), "< & >"); + }); + assertEquals(1, countOccurrences(loadFile(pathToDoc), "< & >")); + } + + @Test + @DisplayName("The xml file root object should not be changed when adding an entry") + public void xmlFileRootObjectShouldNotBeChangedWhenAddingAnEntry(@TempDir Path tmpdir) throws IOException { + var pathToDoc = loadResourceToDir(RESOURCE_USER_PLACES, tmpdir); + var rootObject = """ + + + + + + """.replaceAll("[\\r\\n\\t]", ""); + assertDoesNotThrow(() -> { + new DolphinPlaces(tmpdir.resolve("user-places.xbel")).add(Path.of(PATH_FOLDER_1), "my-caption"); + }); + var file = Files.readString(pathToDoc).replaceAll("[\\r\\n\\t]", ""); + assertEquals(rootObject, file.substring(0,rootObject.length()), "Root object of the XML file should not be changed when adding an entry"); + } + + + private Path loadResourceToDir(String source, Path targetDir) { + try (var stream = this.getClass().getClassLoader().getResourceAsStream(source)) { + if (stream == null) { + throw new IOException("Resource not found: " + source); + } + Files.copy(stream, targetDir.resolve("user-places.xbel"), StandardCopyOption.REPLACE_EXISTING); + return targetDir.resolve("user-places.xbel"); + } catch (IOException e) { + throw new RuntimeException("Failed to load resource: " + source, e); + } + } + + private String loadFile(Path file) { + if (!Files.exists(file)) { + throw new RuntimeException("File does not exist: " + file); + } + try { + return Files.readString(file); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private int countOccurrences(String content, String searchString) { + int count = 0; + int index = 0; + + while ((index = content.indexOf(searchString, index)) != -1) { + count++; + index += searchString.length(); + } + return count; } } diff --git a/src/test/resources/quickaccess/dolphin/user-places-multiple-identical.xbel b/src/test/resources/quickaccess/dolphin/user-places-multiple-identical.xbel new file mode 100644 index 0000000..c6dc588 --- /dev/null +++ b/src/test/resources/quickaccess/dolphin/user-places-multiple-identical.xbel @@ -0,0 +1,28 @@ + + + + + + folder 1 + + + + + + c4b72799-ca67-4c2e-b727-99ca67dc2e5d + + + + + folder 1 + + + + + + 43c6fdb9-626d-468e-86fd-b9626dc68e04d + + + + \ No newline at end of file diff --git a/src/test/resources/quickaccess/dolphin/user-places-not-valid.xbel b/src/test/resources/quickaccess/dolphin/user-places-not-valid.xbel new file mode 100644 index 0000000..0f2080b --- /dev/null +++ b/src/test/resources/quickaccess/dolphin/user-places-not-valid.xbel @@ -0,0 +1,42 @@ + + + + + + folder 1 + + + + + + c4b72799-ca67-4c2e-b727-99ca67dc2e5d + + + + + folder 2 + + + + + + 50e2de06-d9c7-4f45-a2de-06d9c75f4523 + + + + + folder 3 + + + + + + 8ce76c5d-62b3-44c1-a76c-5d62b3a4c1f2 + + + + + + + diff --git a/src/test/resources/quickaccess/dolphin/user-places-not-well-formed.xbel b/src/test/resources/quickaccess/dolphin/user-places-not-well-formed.xbel new file mode 100644 index 0000000..835fcfc --- /dev/null +++ b/src/test/resources/quickaccess/dolphin/user-places-not-well-formed.xbel @@ -0,0 +1,39 @@ + + + + + + folder 1 + + metadata owner="http://freedesktop.org"> + bookmark:icon name="drive-harddisk-encrypted"/> + /metadata> + + c4b72799-ca67-4c2e-b727-99ca67dc2e5d + + + + + folder 2 + + + + + + 50e2de06-d9c7-4f45-a2de-06d9c75f4523 + + + + + folder 3 + + + + + + 8ce76c5d-62b3-44c1-a76c-5d62b3a4c1f2 + + + + diff --git a/src/test/resources/quickaccess/dolphin/user-places.xbel b/src/test/resources/quickaccess/dolphin/user-places.xbel new file mode 100644 index 0000000..3549620 --- /dev/null +++ b/src/test/resources/quickaccess/dolphin/user-places.xbel @@ -0,0 +1,39 @@ + + + + + + folder 1 + + + + + + c4b72799-ca67-4c2e-b727-99ca67dc2e5d + + + + + folder 2 + + + + + + 50e2de06-d9c7-4f45-a2de-06d9c75f4523 + + + + + folder 3 + + + + + + 8ce76c5d-62b3-44c1-a76c-5d62b3a4c1f2 + + + +