Skip to content

Commit d8702ce

Browse files
committed
Refactors Dolphin quick access integration for robustness - Fixes #3883
This commit refactors the Dolphin quick access integration to use DOM manipulation for adding and removing bookmarks in the XBEL file. This change aims to improve the reliability and correctness of bookmark management by handling stale entries. Changes - Replaces string-based manipulation of the XBEL file with DOM manipulation using org.w3c.dom. - Introduces methods for loading, validating, and writing the XBEL document. - Implements bookmark creation and removal using XPath queries and DOM operations. - Adds more test cases to DolphinPlacesTest to cover adding and removing bookmarks under different scenarios. Impact - Improves the robustness of the Dolphin quick access integration by using standard XML parsing and manipulation techniques. - The tests are modified to verify correct bookmark management. Fixes: cryptomator/cryptomator#3883
1 parent 42732f4 commit d8702ce

File tree

9 files changed

+587
-57
lines changed

9 files changed

+587
-57
lines changed

.idea/developer-tools.xml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
requires org.purejava.appindicator;
1919
requires org.purejava.kwallet;
2020
requires de.swiesend.secretservice;
21+
requires java.xml;
2122

2223
provides AutoStartProvider with FreedesktopAutoStartService;
2324
provides KeychainAccessProvider with GnomeKeyringKeychainAccess, KDEWalletKeychainAccess;

src/main/java/org/cryptomator/linux/quickaccess/DolphinPlaces.java

Lines changed: 260 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,41 @@
66
import org.cryptomator.integrations.common.Priority;
77
import org.cryptomator.integrations.quickaccess.QuickAccessService;
88
import org.cryptomator.integrations.quickaccess.QuickAccessServiceException;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
import org.w3c.dom.Document;
12+
import org.w3c.dom.Node;
13+
import org.w3c.dom.NodeList;
14+
import org.xml.sax.InputSource;
915
import org.xml.sax.SAXException;
1016

1117
import javax.xml.XMLConstants;
18+
import javax.xml.namespace.QName;
19+
import javax.xml.parsers.DocumentBuilder;
20+
import javax.xml.parsers.DocumentBuilderFactory;
21+
import javax.xml.transform.OutputKeys;
1222
import javax.xml.transform.Source;
23+
import javax.xml.transform.Transformer;
24+
import javax.xml.transform.TransformerFactory;
25+
import javax.xml.transform.dom.DOMSource;
26+
import javax.xml.transform.stream.StreamResult;
1327
import javax.xml.transform.stream.StreamSource;
1428
import javax.xml.validation.SchemaFactory;
1529
import javax.xml.validation.Validator;
30+
import javax.xml.xpath.XPath;
31+
import javax.xml.xpath.XPathConstants;
32+
import javax.xml.xpath.XPathFactory;
33+
import javax.xml.xpath.XPathVariableResolver;
34+
import java.io.ByteArrayInputStream;
1635
import java.io.IOException;
1736
import java.io.StringReader;
37+
import java.io.StringWriter;
38+
import java.nio.charset.StandardCharsets;
1839
import java.nio.file.Files;
1940
import java.nio.file.Path;
20-
import java.util.List;
41+
import java.util.ArrayList;
42+
import java.util.HashMap;
43+
import java.util.Map;
2144
import java.util.UUID;
2245

2346
/**
@@ -29,28 +52,29 @@
2952
@Priority(90)
3053
public class DolphinPlaces extends FileConfiguredQuickAccess implements QuickAccessService {
3154

55+
private static final Logger LOG = LoggerFactory.getLogger(DolphinPlaces.class);
56+
57+
private static final String XBEL_NAMESPACE = "http://www.freedesktop.org/standards/desktop-bookmarks";
3258
private static final int MAX_FILE_SIZE = 1 << 20; //1MiB, xml is quite verbose
33-
private static final Path PLACES_FILE = Path.of(System.getProperty("user.home"), ".local/share/user-places.xbel");
34-
private static final String ENTRY_TEMPLATE = """
35-
<bookmark href=\"%s\">
36-
<title>%s</title>
37-
<info>
38-
<metadata owner=\"http://freedesktop.org\">
39-
<bookmark:icon name="drive-harddisk-encrypted"/>
40-
</metadata>
41-
<metadata owner=\"https://cryptomator.org\">
42-
<id>%s</id>
43-
</metadata>
44-
</info>
45-
</bookmark>""";
59+
private static final String HOME_DIR = System.getProperty("user.home");
60+
private static final String CONFIG_PATH_IN_HOME = ".local/share";
61+
private static final String CONFIG_FILE_NAME = "user-places.xbel";
62+
private static final Path PLACES_FILE = Path.of(HOME_DIR,CONFIG_PATH_IN_HOME, CONFIG_FILE_NAME);
4663

4764
private static final Validator XML_VALIDATOR;
4865

4966
static {
50-
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
67+
5168
try (var schemaDefinition = DolphinPlaces.class.getResourceAsStream("/xbel-1.0.xsd")) {
69+
70+
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
71+
72+
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
73+
factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
74+
5275
Source schemaFile = new StreamSource(schemaDefinition);
5376
XML_VALIDATOR = factory.newSchema(schemaFile).newValidator();
77+
5478
} catch (IOException | SAXException e) {
5579
throw new IllegalStateException("Failed to load included XBEL schema definition file.", e);
5680
}
@@ -61,29 +85,200 @@ public DolphinPlaces() {
6185
super(PLACES_FILE, MAX_FILE_SIZE);
6286
}
6387

88+
public DolphinPlaces(Path configFilePath) {
89+
super(configFilePath.resolve(CONFIG_FILE_NAME), MAX_FILE_SIZE);
90+
}
91+
6492
@Override
6593
EntryAndConfig addEntryToConfig(String config, Path target, String displayName) throws QuickAccessServiceException {
94+
6695
try {
96+
6797
String id = UUID.randomUUID().toString();
68-
//validate
98+
99+
LOG.trace("Adding bookmark for target: '{}', displayName: '{}', id: '{}'", target, displayName, id);
100+
101+
// Validate the existing config before modifying it, if it is invalid
102+
// we should not modify it.
69103
XML_VALIDATOR.validate(new StreamSource(new StringReader(config)));
70-
// modify
71-
int insertIndex = config.lastIndexOf("</xbel"); //cannot be -1 due to validation; we do not match the whole end tag, since between tag name and closing bracket can be whitespaces
72-
var adjustedConfig = config.substring(0, insertIndex) //
73-
+ "\n" //
74-
+ ENTRY_TEMPLATE.formatted(target.toUri(), escapeXML(displayName), id).indent(1) //
75-
+ "\n" //
76-
+ config.substring(insertIndex);
77-
return new EntryAndConfig(new DolphinPlacesEntry(id), adjustedConfig);
78-
} catch (SAXException | IOException e) {
79-
throw new QuickAccessServiceException("Adding entry to KDE places file failed.", e);
104+
105+
Document xmlDocument = loadXmlDocument(config);
106+
107+
NodeList nodeList = extractBookmarksByPath(target, xmlDocument);
108+
109+
removeStaleBookmarks(nodeList);
110+
111+
createBookmark(target, displayName, id, xmlDocument);
112+
113+
XML_VALIDATOR.validate(new DOMSource(xmlDocument));
114+
115+
return new EntryAndConfig(new DolphinPlacesEntry(id), documentToString(xmlDocument));
116+
117+
} catch (SAXException e) {
118+
throw new QuickAccessServiceException("Invalid structure in xbel bookmark file", e);
119+
} catch (IOException e) {
120+
throw new QuickAccessServiceException("Failed reading/writing the xbel bookmark file", e);
121+
}
122+
}
123+
124+
private void removeStaleBookmarks(NodeList nodeList) {
125+
126+
for (int i = nodeList.getLength() - 1; i >= 0; i--) {
127+
Node node = nodeList.item(i);
128+
node.getParentNode().removeChild(node);
129+
}
130+
}
131+
132+
private NodeList extractBookmarksByPath(Path target, Document xmlDocument) throws QuickAccessServiceException {
133+
134+
try {
135+
136+
XPathFactory xpathFactory = XPathFactory.newInstance();
137+
XPath xpath = xpathFactory.newXPath();
138+
139+
SimpleVariableResolver variableResolver = new SimpleVariableResolver();
140+
141+
variableResolver.addVariable(new QName("uri"), target.toUri().toString());
142+
143+
xpath.setXPathVariableResolver(variableResolver);
144+
145+
String expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][@href=$uri]";
146+
147+
return (NodeList) xpath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET);
148+
149+
} catch (Exception e) {
150+
throw new QuickAccessServiceException("Failed to extract bookmarks by path", e);
151+
}
152+
}
153+
154+
private NodeList extractBookmarksById(String id, Document xmlDocument) throws QuickAccessServiceException {
155+
156+
try {
157+
158+
XPathFactory xpathFactory = XPathFactory.newInstance();
159+
XPath xpath = xpathFactory.newXPath();
160+
161+
SimpleVariableResolver variableResolver = new SimpleVariableResolver();
162+
163+
variableResolver.addVariable(new QName("id"), id);
164+
165+
xpath.setXPathVariableResolver(variableResolver);
166+
167+
String expression = "/xbel/bookmark[info/metadata[@owner='https://cryptomator.org']][info/metadata/id[text()=$id]]";
168+
169+
return (NodeList) xpath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET);
170+
171+
} catch (Exception e) {
172+
throw new QuickAccessServiceException("Failed to extract bookmarks by id", e);
80173
}
81174
}
82175

83-
private String escapeXML(String s) {
84-
return s.replace("&","&amp;") //
85-
.replace("<","&lt;") //
86-
.replace(">","&gt;");
176+
private Document loadXmlDocument(String config) throws QuickAccessServiceException {
177+
178+
try {
179+
180+
DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
181+
182+
builderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
183+
builderFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
184+
builderFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
185+
builderFactory.setXIncludeAware(false);
186+
builderFactory.setExpandEntityReferences(false);
187+
builderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
188+
builderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
189+
builderFactory.setNamespaceAware(true);
190+
191+
DocumentBuilder builder = builderFactory.newDocumentBuilder();
192+
193+
// Prevent external entities from being resolved
194+
builder.setEntityResolver((publicId, systemId) -> new InputSource(new StringReader("")));
195+
196+
return builder.parse(new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8)));
197+
198+
} catch (Exception e) {
199+
throw new QuickAccessServiceException("Failed to parse the xbel bookmark file", e);
200+
}
201+
}
202+
203+
private String documentToString(Document xmlDocument) throws QuickAccessServiceException {
204+
205+
try {
206+
207+
StringWriter buf = new StringWriter();
208+
209+
Transformer xform = TransformerFactory.newInstance().newTransformer();
210+
xform.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
211+
xform.setOutputProperty(OutputKeys.INDENT, "yes");
212+
xform.setOutputProperty(OutputKeys.ENCODING, StandardCharsets.UTF_8.name());
213+
xform.transform(new DOMSource(xmlDocument), new StreamResult(buf));
214+
215+
return buf.toString();
216+
217+
} catch (Exception e) {
218+
throw new QuickAccessServiceException("Failed to read document into string", e);
219+
}
220+
}
221+
222+
/**
223+
*
224+
* Adds a xml bookmark element to the specified xml document
225+
*
226+
* <pre>{@code
227+
* <bookmark href="file:///home/someuser/folder1/">
228+
* <title>integrations-linux</title>
229+
* <info>
230+
* <metadata owner="http://freedesktop.org">
231+
* <bookmark:icon name="drive-harddisk-encrypted"/>
232+
* </metadata>
233+
* <metadata owner="https://cryptomator.org">
234+
* <id>sldkf-sadf-sadf-sadf</id>
235+
* </metadata>
236+
* </info>
237+
* </bookmark>
238+
* }</pre>
239+
*
240+
* @param target The mount point of the vault
241+
* @param displayName Caption of the vault link in dolphin
242+
* @param xmlDocument The xbel document to which the bookmark should be added
243+
*
244+
* @throws QuickAccessServiceException if the bookmark could not be created
245+
*/
246+
private void createBookmark(Path target, String displayName, String id, Document xmlDocument) throws QuickAccessServiceException {
247+
248+
try {
249+
var bookmark = xmlDocument.createElement("bookmark");
250+
var title = xmlDocument.createElement("title");
251+
var info = xmlDocument.createElement("info");
252+
var metadataBookmark = xmlDocument.createElement("metadata");
253+
var metadataOwner = xmlDocument.createElement("metadata");
254+
var bookmarkIcon = xmlDocument.createElementNS(XBEL_NAMESPACE, "bookmark:icon");
255+
var idElem = xmlDocument.createElement("id");
256+
257+
bookmark.setAttribute("href", target.toUri().toString());
258+
259+
title.setTextContent(displayName);
260+
261+
bookmark.appendChild(title);
262+
bookmark.appendChild(info);
263+
264+
info.appendChild(metadataBookmark);
265+
info.appendChild(metadataOwner);
266+
267+
metadataBookmark.appendChild(bookmarkIcon);
268+
metadataOwner.appendChild(idElem);
269+
270+
metadataBookmark.setAttribute("owner", "http://freedesktop.org");
271+
272+
bookmarkIcon.setAttribute("name","drive-harddisk-encrypted");
273+
274+
metadataOwner.setAttribute("owner", "https://cryptomator.org");
275+
276+
idElem.setTextContent(id);
277+
xmlDocument.getDocumentElement().appendChild(bookmark);
278+
279+
} catch (Exception e) {
280+
throw new QuickAccessServiceException("Failed to insert bookmark for target: " + target, e);
281+
}
87282
}
88283

89284
private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implements QuickAccessEntry {
@@ -96,42 +291,52 @@ private class DolphinPlacesEntry extends FileConfiguredQuickAccessEntry implemen
96291

97292
@Override
98293
public String removeEntryFromConfig(String config) throws QuickAccessServiceException {
294+
99295
try {
100-
int idIndex = config.lastIndexOf(id);
101-
if (idIndex == -1) {
102-
return config; //assume someone has removed our entry, nothing to do
103-
}
104-
//validate
296+
105297
XML_VALIDATOR.validate(new StreamSource(new StringReader(config)));
106-
//modify
107-
int openingTagIndex = indexOfEntryOpeningTag(config, idIndex);
108-
var contentToWrite1 = config.substring(0, openingTagIndex).stripTrailing();
109298

110-
int closingTagEndIndex = config.indexOf('>', config.indexOf("</bookmark", idIndex));
111-
var part2Tmp = config.substring(closingTagEndIndex + 1).split("\\A\\v+", 2); //removing leading vertical whitespaces, but no indentation
112-
var contentToWrite2 = part2Tmp[part2Tmp.length - 1];
299+
Document xmlDocument = loadXmlDocument(config);
300+
301+
NodeList nodeList = extractBookmarksById(id, xmlDocument);
302+
303+
removeStaleBookmarks(nodeList);
304+
305+
XML_VALIDATOR.validate(new DOMSource(xmlDocument));
306+
307+
return documentToString(xmlDocument);
113308

114-
return contentToWrite1 + "\n" + contentToWrite2;
115309
} catch (IOException | SAXException | IllegalStateException e) {
116310
throw new QuickAccessServiceException("Removing entry from KDE places file failed.", e);
117311
}
118312
}
313+
}
314+
315+
/**
316+
* Resolver in order to define parameter for XPATH expression.
317+
*/
318+
private class SimpleVariableResolver implements XPathVariableResolver {
319+
320+
private final Map<QName, Object> vars = new HashMap<>();
119321

120322
/**
121-
* Returns the start index (inclusive) of the {@link DolphinPlaces#ENTRY_TEMPLATE} entry
122-
* @param placesContent the content of the XBEL places file
123-
* @param idIndex start index (inclusive) of the entrys id tag value
124-
* @return start index of the first bookmark tag, searching backwards from idIndex
323+
* Adds a variable to the resolver.
324+
*
325+
* @param name The name of the variable
326+
* @param value The value of the variable
125327
*/
126-
private int indexOfEntryOpeningTag(String placesContent, int idIndex) {
127-
var xmlWhitespaceChars = List.of(' ', '\t', '\n');
128-
for (char c : xmlWhitespaceChars) {
129-
int idx = placesContent.lastIndexOf("<bookmark" + c, idIndex); //with the whitespace we ensure, that no tags starting with "bookmark" (e.g. bookmarkz) are selected
130-
if (idx != -1) {
131-
return idx;
132-
}
133-
}
134-
throw new IllegalStateException("Found entry id " + id + " in " + PLACES_FILE + ", but it is not a child of <bookmark> tag.");
328+
public void addVariable(QName name, Object value) {
329+
vars.put(name, value);
330+
}
331+
332+
/**
333+
* Resolves a variable by its name.
334+
*
335+
* @param variableName The name of the variable to resolve
336+
* @return The value of the variable, or null if not found
337+
*/
338+
public Object resolveVariable(QName variableName) {
339+
return vars.get(variableName);
135340
}
136341
}
137342

0 commit comments

Comments
 (0)