From f7cddcaaa8de59d82590eae98e9abf1bee9b819f Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 00:06:02 +0100 Subject: [PATCH 01/10] Reimplement WebDavConnection on httpclient 4 The Slide-based webdavclient4j client has failed with NoClassDefFoundError (org/jdom/input/DOMBuilder) on every PROPFIND since jdom 1 was replaced by jdom2 in 4.5.0, so WebDAV polling and writing did not work at all. Its listing was also broken before that: child paths were built as //, so size and last-modified came back as 0, directories were listed as files and listDirectories was empty. Talk WebDAV directly with httpclient 4: PROPFIND (Depth 0/1), MKCOL and MOVE request classes, a namespace-aware DOM parse of the multistatus with doctypes disallowed, and RFC 1123 dates via DateUtils. A listing is now built from a single Depth 1 PROPFIND. Failed responses are skipped, and the directory's own entry is still found behind a rewriting proxy. listDirectories returns paths that listFiles accepts, so recursive polling works. Basic, Digest and NTLM (DOMAIN\user) are negotiated through one shared context. Credentials are scoped to the configured host and port. The PUT body is buffered so it can be resent after an authentication challenge. MOVE keeps sending Overwrite: F, and failed PUT, DELETE and MOVE responses are still only logged, as before. The configured port and the connector timeout are now passed through. Before, the port was dropped and requests always went to 80/443. TLS hostname verification is now on. commons-httpclient 3 never checked the hostname, so servers whose certificate doesn't match the configured host now fail the handshake. The tests run against an embedded Jetty WebDAV servlet through FileSystemConnectionFactory. Signed-off-by: Nico Piel --- .../FileSystemConnectionFactory.java | 2 +- .../file/filesystems/WebDavConnection.java | 514 +++++++++++++---- .../filesystems/WebDavConnectionTest.java | 531 ++++++++++++++++++ .../file/filesystems/WebDavTestServer.java | 376 +++++++++++++ 4 files changed, 1310 insertions(+), 113 deletions(-) create mode 100644 server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java create mode 100644 server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java index f5ca587344..d70949b359 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/FileSystemConnectionFactory.java @@ -179,7 +179,7 @@ public PooledObject makeObject() throws Exception { } else if (scheme.equals(FileScheme.SMB)) { return new DefaultPooledObject(new SmbFileConnection(host, fileSystemOptions, timeout)); } else if (scheme.equals(FileScheme.WEBDAV)) { - return new DefaultPooledObject(new WebDavConnection(host, secure, fileSystemOptions)); + return new DefaultPooledObject(new WebDavConnection(host, port, secure, fileSystemOptions, timeout)); } else { logger.error("makeObject doesn't handle scheme " + scheme); throw new IOException("Unimplemented or unrecognized scheme"); diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index fc7209ea6a..589a1237a5 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -12,17 +12,57 @@ import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; -import org.apache.commons.httpclient.HttpURL; -import org.apache.commons.httpclient.HttpsURL; import org.apache.commons.io.filefilter.WildcardFileFilter; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.apache.webdav.lib.WebdavFile; -import org.apache.webdav.lib.WebdavResource; +import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.NTCredentials; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.AuthSchemes; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.client.utils.DateUtils; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.entity.BufferedHttpEntity; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.InputStreamEntity; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContexts; +import org.apache.http.util.EntityUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; import com.mirth.connect.connectors.file.FileSystemConnectionOptions; import com.mirth.connect.connectors.file.filters.RegexFilenameFilter; @@ -30,28 +70,36 @@ public class WebDavConnection implements FileSystemConnection { public class WebDavFileInfo implements FileInfo { private String thePath; - private WebdavFile theFile; + private String name; + private String url; + private boolean directory; + private long size; + private long lastModified; - public WebDavFileInfo(String path, WebdavFile theFile) { + public WebDavFileInfo(String path, String name, String url, boolean directory, long size, long lastModified) { this.thePath = path; - this.theFile = theFile; + this.name = name; + this.url = url; + this.directory = directory; + this.size = size; + this.lastModified = lastModified; } public long getLastModified() { - return theFile.lastModified(); + return lastModified; } public String getName() { - return theFile.getName(); + return name; } /** Gets the absolute pathname of the file */ public String getAbsolutePath() { - return theFile.getAbsolutePath(); + return url; } public String getCanonicalPath() throws IOException { - return this.theFile.getCanonicalPath(); + return url; } /** Gets the absolute pathname of the directory holding the file */ @@ -60,51 +108,118 @@ public String getParent() { } public long getSize() { - return theFile.length(); + return size; } public boolean isDirectory() { - return theFile.isDirectory(); + return directory; } public boolean isFile() { - return theFile.isFile(); + return !directory; } public boolean isReadable() { - return theFile.canRead(); + return true; } @Override public void populateSourceMap(Map sourceMap) {} } - private static transient Log logger = LogFactory.getLog(WebDavConnection.class); + /** One response element of a PROPFIND multistatus */ + private static class Resource { + private String path; + private boolean collection; + private long length; + private long lastModified; + private boolean locked; + } - /** The WebDAV client instance */ - private WebdavResource client = null; - private boolean secure = false; - private String username = null; - private String password = null; + private static class HttpPropfind extends HttpEntityEnclosingRequestBase { + private static final String BODY = "" + "" + "" + ""; + + HttpPropfind(URI uri, int depth) { + setURI(uri); + setHeader("Depth", String.valueOf(depth)); + setEntity(new StringEntity(BODY, ContentType.create("application/xml", StandardCharsets.UTF_8))); + } + + @Override + public String getMethod() { + return "PROPFIND"; + } + } - public WebDavConnection(String host, boolean secure, FileSystemConnectionOptions fileSystemOptions) throws Exception { - this.secure = secure; - username = fileSystemOptions.getUsername(); - password = fileSystemOptions.getPassword(); + private static class HttpMkcol extends HttpRequestBase { + HttpMkcol(URI uri) { + setURI(uri); + } - HttpURL url = null; + @Override + public String getMethod() { + return "MKCOL"; + } + } - if (secure) { - url = new HttpsURL("https://" + host); - } else { - url = new HttpURL("http://" + host); + private static class HttpMove extends HttpRequestBase { + HttpMove(URI source, URI destination) { + setURI(source); + setHeader("Destination", destination.toASCIIString()); + // Slide never overwrote an existing target + setHeader("Overwrite", "F"); + } + + @Override + public String getMethod() { + return "MOVE"; } + } + + private static final String DAV = "DAV:"; + private static final Pattern SUCCESS_STATUS = Pattern.compile("^HTTP/\\S+\\s+2\\d\\d"); + private static transient Log logger = LogFactory.getLog(WebDavConnection.class); + + /** The WebDAV client instance */ + private CloseableHttpClient client = null; + /** Shared across requests so an authenticated session is reused */ + private HttpClientContext context = HttpClientContext.create(); + private String baseUrl; + private CloseableHttpResponse readResponse = null; + /** Whether the last path looked up exists, as WebdavResource.exists() reported it */ + private boolean exists = false; + /** Status line of the last failed PROPFIND */ + private String lastStatus = null; + + public WebDavConnection(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) throws Exception { + String username = fileSystemOptions.getUsername(); + String password = fileSystemOptions.getPassword(); + + baseUrl = new HttpHost(host, port, secure ? "https" : "http").toURI(); + + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); if (!username.equals("null")) { - url.setUserinfo(username, password); + // Scope credentials to this port so a redirect elsewhere doesn't get them + int authPort = port > 0 ? port : (secure ? 443 : 80); + credentialsProvider.setCredentials(new AuthScope(host, authPort), new UsernamePasswordCredentials(username, password)); + + // NTLM needs the domain split out of DOMAIN\\user + int separator = username.indexOf('\\'); + String domain = separator < 0 ? null : username.substring(0, separator); + credentialsProvider.setCredentials(new AuthScope(host, authPort, AuthScope.ANY_REALM, AuthSchemes.NTLM), new NTCredentials(username.substring(separator + 1), password, null, domain)); } - client = new WebdavResource(url); + // The schemes httpclient 3 negotiated, in its order of preference + RequestConfig requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).build(); + + client = HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault())).build(); + + try { + stat("/"); + } catch (IOException e) { + logger.debug(e); + } } @Override @@ -124,49 +239,39 @@ public List listFiles(String fromDir, String filenamePattern, boolean public List listDirectories(String fromDir) throws Exception { List directories = new ArrayList(); for (FileInfo directory : list(fromDir, false, null, false)) { - directories.add(directory.getCanonicalPath()); + directories.add(getFullPath(directory.getParent(), directory.getName())); } return directories; } private List list(String fromDir, boolean files, FilenameFilter filenameFilter, boolean ignoreDot) throws Exception { - client.setPath(fromDir); - WebdavResource[] resources = client.listWebdavResources(); - - if (resources == null || resources.length == 0) { - return new ArrayList(); + String dirPath = normalize(getFullPath(fromDir, "")); + List resources = propfind(fromDir.isEmpty() ? "/" : fromDir, 1); + if (resources == null) { + throw new IOException("Unable to list directory: '" + fromDir + "': " + lastStatus); } - List fileInfoList = new ArrayList(resources.length); - for (int i = 0; i < resources.length; i++) { - - WebdavFile file = null; - String filePath = getFullPath(fromDir, resources[i].getPath()); - - if (secure) { + // The directory itself is part of a Depth 1 response + int self = selfIndex(resources, dirPath); - HttpsURL hrl = new HttpsURL("https://" + client.getHost() + filePath); - if (!username.equals("null")) { - hrl.setUserinfo(username, password); - } - file = new WebdavFile(hrl); - - } else { + List fileInfoList = new ArrayList(resources.size()); + for (int i = 0; i < resources.size(); i++) { + if (i == self) { + continue; + } - HttpURL hrl = new HttpURL("http://" + client.getHost() + filePath); - if (!username.equals("null")) { - hrl.setUserinfo(username, password); - } - file = new WebdavFile(hrl); + Resource resource = resources.get(i); + String path = normalize(resource.path); - } + String name = path.substring(path.lastIndexOf('/') + 1); + WebDavFileInfo file = new WebDavFileInfo(fromDir, name, uri(getFullPath(fromDir, name)).toString(), resource.collection, resource.length, resource.lastModified); if (files) { if (file.isFile() && filenameFilter.accept(null, file.getName()) && !(ignoreDot && file.getName().startsWith("."))) { - fileInfoList.add(new WebDavFileInfo(fromDir, file)); + fileInfoList.add(file); } } else if (file.isDirectory()) { - fileInfoList.add(new WebDavFileInfo(fromDir, file)); + fileInfoList.add(file); } } @@ -176,8 +281,7 @@ private List list(String fromDir, boolean files, FilenameFilter filena @Override public boolean exists(String file, String path) { try { - client.setPath(getFullPath(path, file)); - return client.exists(); + return stat(getFullPath(path, file)) != null; } catch (IOException e) { return false; } @@ -187,18 +291,29 @@ public boolean exists(String file, String path) { public InputStream readFile(String file, String fromDir, Map sourceMap) throws Exception { String fullPath = getFullPath(fromDir, file); - client.setPath(fullPath); - if (client.isCollection()) { + Resource resource = stat(fullPath); + if (resource != null && resource.collection) { logger.error("Invalid filepath: " + fullPath); throw new Exception("Invalid Path"); } - return client.getMethodData(); + closeReadFile(); + CloseableHttpResponse response = client.execute(new HttpGet(uri(fullPath)), context); + if (!isSuccess(response)) { + response.close(); + throw new IOException("Couldn't get file"); + } + + readResponse = response; + return response.getEntity().getContent(); } @Override public void closeReadFile() throws Exception { - // irrelevant + if (readResponse != null) { + readResponse.close(); + readResponse = null; + } } @Override @@ -211,29 +326,22 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, String fullPath = getFullPath(toDir, file); // first check if the toDir exists. - client.setPath(toDir); - - if (!client.exists()) { + Resource dir = stat(toDir); + if (dir == null) { // create the directory. - client.mkcolMethod(toDir); + execute(new HttpMkcol(uri(toDir))); logger.info("Destination directory does not exist. Creating directory: '" + toDir + "'"); - - if (!client.putMethod(fullPath, is)) { - logger.error("Unable to write file: '" + fullPath); - } - - } else { - + } else if (!dir.collection) { // make sure it's a directory, not a file. - if (!client.isCollection()) { - throw new Exception("The destination directory path is invalid: '" + client.getPath() + "'"); - } else { - // valid directory. now write the file. - if (!client.putMethod(fullPath, is)) { - logger.error("Unable to write file: '" + fullPath); - } - } + throw new Exception("The destination directory path is invalid: '" + toDir + "'"); + } + + HttpPut put = new HttpPut(uri(fullPath)); + // Buffered so the PUT can be repeated after an authentication challenge + put.setEntity(new BufferedHttpEntity(new InputStreamEntity(is, contentLength))); + if (!execute(put)) { + logger.error("Unable to write file: '" + fullPath); } } @@ -241,7 +349,7 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, public void delete(String file, String fromDir, boolean mayNotExist) throws Exception { String fullPath = getFullPath(fromDir, file); - if (!client.deleteMethod(fullPath)) { + if (!execute(new HttpDelete(uri(fullPath)))) { if (!mayNotExist) { logger.error("Unable to delete file: '" + fullPath + "'"); } @@ -254,35 +362,25 @@ public void move(String fromName, String fromDir, String toName, String toDir) t String targetPath = getFullPath(toDir, toName); // first check if the toDir exists. - client.setPath(toDir); - - if (!client.exists()) { + Resource dir = stat(toDir); + if (dir == null) { // create the directory. and then move. - client.mkcolMethod(toDir); + execute(new HttpMkcol(uri(toDir))); logger.info("Move-To directory does not exist. Creating directory: '" + toDir + "'"); - - if (!client.moveMethod(sourcePath, targetPath)) { - logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'"); - } - - } else { - + } else if (!dir.collection) { // make sure it's a directory, not a file. - if (!client.isCollection()) { - throw new Exception("The move-to directory path is invalid: '" + client.getPath() + "'"); - } else { - // valid directory. now move the file. - if (!client.moveMethod(sourcePath, targetPath)) { - logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'"); - } - } + throw new Exception("The move-to directory path is invalid: '" + toDir + "'"); + } + + if (!execute(new HttpMove(uri(sourcePath), uri(targetPath)))) { + logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'"); } } @Override public boolean isConnected() { - return client != null && client.exists(); + return client != null && exists; } @Override @@ -301,24 +399,25 @@ public void passivate() { @Override public void destroy() { try { + closeReadFile(); if (client != null) { client.close(); } - } catch (IOException e) { + } catch (Exception e) { logger.debug(e); } } @Override public boolean isValid() { - return client != null && client.exists(); + return client != null && exists; } @Override public boolean canRead(String readDir) { try { - client.setPath(readDir); - return client.exists() && client.isCollection(); + Resource dir = stat(readDir); + return dir != null && dir.collection; } catch (IOException e) { logger.debug(e); return false; @@ -328,14 +427,205 @@ public boolean canRead(String readDir) { @Override public boolean canWrite(String writeDir) { try { - client.setPath(writeDir); - return client.exists() && client.isCollection() && !client.isLocked(); + Resource dir = stat(writeDir); + return dir != null && dir.collection && !dir.locked; } catch (IOException e) { logger.debug(e); return false; } } + /** Returns the resource at path, or null if it can't be found */ + private Resource stat(String path) throws IOException { + List resources = propfind(path, 0); + return resources == null || resources.isEmpty() ? null : resources.get(0); + } + + /** Returns the resources of a PROPFIND multistatus, or null if the request failed */ + private List propfind(String path, int depth) throws IOException { + try (CloseableHttpResponse response = client.execute(new HttpPropfind(uri(path), depth), context)) { + exists = response.getStatusLine().getStatusCode() == HttpStatus.SC_MULTI_STATUS; + if (!exists) { + lastStatus = response.getStatusLine().toString(); + EntityUtils.consume(response.getEntity()); + return null; + } + + try (InputStream in = response.getEntity().getContent()) { + return parseMultistatus(newDocumentBuilder().parse(in)); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Invalid PROPFIND response for '" + path + "'", e); + } + } + } + + private static List parseMultistatus(Document document) { + List resources = new ArrayList(); + NodeList responses = document.getElementsByTagNameNS(DAV, "response"); + + for (int i = 0; i < responses.getLength(); i++) { + Element response = (Element) responses.item(i); + Element href = child(response, "href"); + if (href == null) { + continue; + } + + // A response-level status means there are no properties, e.g. 403 + Element responseStatus = child(response, "status"); + if (responseStatus != null && !isSuccess(responseStatus)) { + continue; + } + boolean found = responseStatus != null; + + Resource resource = new Resource(); + resource.path = hrefPath(href.getTextContent().trim()); + + // Only properties from a 2xx propstat are present + for (Element propstat : children(response, "propstat")) { + Element prop = child(propstat, "prop"); + if (prop == null || !isSuccess(child(propstat, "status"))) { + continue; + } + found = true; + + Element resourceType = child(prop, "resourcetype"); + if (resourceType != null && child(resourceType, "collection") != null) { + resource.collection = true; + } + + Element contentLength = child(prop, "getcontentlength"); + if (contentLength != null) { + try { + resource.length = Long.parseLong(contentLength.getTextContent().trim()); + } catch (NumberFormatException e) {} + } + + Element lastModified = child(prop, "getlastmodified"); + if (lastModified != null) { + Date date = DateUtils.parseDate(lastModified.getTextContent().trim()); + if (date != null) { + resource.lastModified = date.getTime(); + } + } + + Element lockDiscovery = child(prop, "lockdiscovery"); + if (lockDiscovery != null) { + for (Element activeLock : children(lockDiscovery, "activelock")) { + Element lockType = child(activeLock, "locktype"); + if (lockType != null && child(lockType, "write") != null) { + resource.locked = true; + } + } + } + } + + if (found) { + resources.add(resource); + } + } + + return resources; + } + + private static boolean isSuccess(Element status) { + return status == null || SUCCESS_STATUS.matcher(status.getTextContent().trim()).find(); + } + + /** + * Finds the requested directory's own entry. Behind a rewriting proxy its href carries a + * different prefix, so fall back to the entry every other entry lives under. + */ + private static int selfIndex(List resources, String dirPath) { + for (int i = 0; i < resources.size(); i++) { + if (normalize(resources.get(i).path).equalsIgnoreCase(dirPath)) { + return i; + } + } + + for (int i = 0; i < resources.size(); i++) { + String path = normalize(resources.get(i).path); + String prefix = path.endsWith("/") ? path : path + "/"; + boolean parent = true; + for (int j = 0; j < resources.size() && parent; j++) { + parent = j == i || normalize(resources.get(j).path).startsWith(prefix); + } + if (parent) { + return i; + } + } + + return -1; + } + + private static DocumentBuilder newDocumentBuilder() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder(); + } + + private static Element child(Element parent, String localName) { + List children = children(parent, localName); + return children.isEmpty() ? null : children.get(0); + } + + private static List children(Element parent, String localName) { + List children = new ArrayList(); + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node.getNodeType() == Node.ELEMENT_NODE && DAV.equals(node.getNamespaceURI()) && localName.equals(node.getLocalName())) { + children.add((Element) node); + } + } + return children; + } + + /** Decodes an href, which may be an absolute URL or an absolute path */ + private static String hrefPath(String href) { + int schemeEnd = href.indexOf("://"); + if (schemeEnd >= 0) { + int pathStart = href.indexOf('/', schemeEnd + 3); + href = pathStart < 0 ? "/" : href.substring(pathStart); + } + try { + // Paths don't use form encoding, so keep '+' as is + return URLDecoder.decode(href.replace("+", "%2B"), "UTF-8"); + } catch (Exception e) { + return href; + } + } + + private boolean execute(HttpUriRequest request) throws IOException { + try (CloseableHttpResponse response = client.execute(request, context)) { + EntityUtils.consume(response.getEntity()); + return isSuccess(response); + } + } + + private static boolean isSuccess(HttpResponse response) { + int statusCode = response.getStatusLine().getStatusCode(); + return statusCode >= 200 && statusCode < 300; + } + + private URI uri(String path) throws IOException { + try { + return new URIBuilder(baseUrl).setPath(path).build(); + } catch (URISyntaxException e) { + throw new IOException(e); + } + } + + private static String normalize(String path) { + path = ("/" + path).replaceAll("/+", "/"); + return path.length() > 1 && path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + private String getFullPath(String dir, String file) { return ("/" + dir + "/" + file).replaceAll("//", "/"); } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java new file mode 100644 index 0000000000..38c271f5bf --- /dev/null +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -0,0 +1,531 @@ +package com.mirth.connect.connectors.file.filesystems; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import javax.net.ssl.SSLContext; + +import org.apache.commons.io.IOUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.mirth.connect.connectors.file.FileScheme; +import com.mirth.connect.connectors.file.FileSystemConnectionOptions; +import com.mirth.connect.connectors.file.filesystems.WebDavTestServer.Auth; + +public class WebDavConnectionTest { + + private static final long MODIFIED = 1577934245000L; // 2020-01-02T03:04:05Z + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private WebDavTestServer server; + private WebDavConnection connection; + + @Before + public void setUp() throws Exception { + File root = tmp.newFolder("dav"); + write(root, "in/a.txt", "alpha"); + write(root, "in/b.txt", "bravo!"); + write(root, "in/c.hl7", "MSH|"); + write(root, "in/.hidden.txt", "x"); + write(root, "in/sub/d.txt", "delta"); + new File(root, "in/sub2").mkdirs(); + new File(root, "out").mkdirs(); + server = new WebDavTestServer(root); + } + + @After + public void tearDown() throws Exception { + if (connection != null) { + connection.destroy(); + } + server.stop(); + } + + private WebDavConnection connect(Auth auth, String username, String password) throws Exception { + server.start(auth, false); + return open(false, username, password, 5000); + } + + /** Goes through the factory so the connector's host/port/timeout handling is covered */ + private WebDavConnection open(boolean secure, String username, String password, int timeout) throws Exception { + FileSystemConnectionFactory factory = new FileSystemConnectionFactory(FileScheme.WEBDAV, new FileSystemConnectionOptions(false, username, password, null), "localhost", server.port(), false, secure, timeout); + connection = (WebDavConnection) factory.makeObject().getObject(); + return connection; + } + + private WebDavConnection connect() throws Exception { + return connect(Auth.NONE, "null", "null"); + } + + @Test + public void listFilesWithWildcard() throws Exception { + connect(); + assertEquals(List.of("a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, true))); + assertEquals(List.of(".hidden.txt", "a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, false))); + assertEquals(List.of("a.txt", "b.txt", "c.hl7"), names(connection.listFiles("/in", " *.txt , *.hl7 ", false, true))); + } + + @Test + public void listFilesWithRegex() throws Exception { + connect(); + assertEquals(List.of("a.txt", "c.hl7"), names(connection.listFiles("/in", "[ac]\\..*", true, true))); + } + + @Test + public void listFilesExcludesDirectories() throws Exception { + connect(); + assertEquals(List.of(".hidden.txt", "a.txt", "b.txt", "c.hl7"), names(connection.listFiles("/in", "*", false, false))); + } + + @Test + public void listFilesOfEmptyOrMissingDirectory() throws Exception { + connect(); + assertTrue(connection.listFiles("/in/sub2", "*", false, false).isEmpty()); + try { + connection.listFiles("/missing", "*", false, false); + fail(); + } catch (Exception e) { + // expected + } + } + + @Test + public void fileInfoAttributes() throws Exception { + connect(); + FileInfo info = connection.listFiles("/in", "b.txt", false, true).get(0); + assertEquals("b.txt", info.getName()); + assertEquals("/in", info.getParent()); + assertEquals(6, info.getSize()); + assertEquals(MODIFIED, info.getLastModified()); + assertTrue(info.isFile()); + assertFalse(info.isDirectory()); + assertTrue(info.isReadable()); + assertEquals("http://" + server.authority() + "/in/b.txt", info.getAbsolutePath()); + assertEquals("http://" + server.authority() + "/in/b.txt", info.getCanonicalPath()); + } + + @Test + public void listFilesInRoot() throws Exception { + connect(); + write(server.root, "top.txt", "top"); + assertEquals(List.of("top.txt"), names(connection.listFiles("/", "*.txt", false, true))); + assertEquals(List.of("top.txt"), names(connection.listFiles("", "*.txt", false, true))); + } + + @Test + public void listDirectories() throws Exception { + connect(); + List directories = connection.listDirectories("/in"); + Collections.sort(directories); + assertEquals(List.of("/in/sub", "/in/sub2"), directories); + } + + @Test + public void listDirectoriesRoundTrip() throws Exception { + connect(); + // FileReceiver feeds listDirectories() results straight back into listFiles() + String sub = connection.listDirectories("/in").stream().filter(d -> d.endsWith("/sub")).findFirst().get(); + List files = connection.listFiles(sub, "*", false, false); + assertEquals(List.of("d.txt"), names(files)); + assertEquals("/in/sub", files.get(0).getParent()); + assertEquals("delta", read(connection.readFile(files.get(0).getName(), files.get(0).getParent(), new HashMap<>()))); + } + + @Test + public void exists() throws Exception { + connect(); + assertTrue(connection.exists("a.txt", "/in")); + assertTrue(connection.exists("sub", "/in")); + assertTrue(connection.exists("a.txt", "in/")); + assertFalse(connection.exists("nope.txt", "/in")); + assertFalse(connection.exists("a.txt", "/missing")); + } + + @Test + public void readFile() throws Exception { + connect(); + assertEquals("alpha", read(connection.readFile("a.txt", "/in", new HashMap<>()))); + connection.closeReadFile(); + } + + @Test + public void readCollectionFails() throws Exception { + connect(); + try { + connection.readFile("sub", "/in", new HashMap<>()); + fail(); + } catch (Exception e) { + assertEquals("Invalid Path", e.getMessage()); + } + } + + @Test(expected = Exception.class) + public void readMissingFileFails() throws Exception { + connect(); + connection.readFile("nope.txt", "/in", new HashMap<>()); + } + + @Test + public void writeFileToExistingDirectory() throws Exception { + connect(); + assertFalse(connection.canAppend()); + writeFile("new.txt", "/out", "hello"); + assertEquals("hello", content("out/new.txt")); + assertFalse(server.requests().stream().anyMatch(r -> r.startsWith("MKCOL"))); + + writeFile("new.txt", "/out", "replaced"); + assertEquals("replaced", content("out/new.txt")); + } + + @Test + public void writeFileCreatesMissingDirectory() throws Exception { + connect(); + writeFile("new.txt", "/created", "hello"); + assertEquals("hello", content("created/new.txt")); + assertTrue(server.requests().contains("MKCOL /created")); + } + + @Test + public void writeFileIntoFileFails() throws Exception { + connect(); + try { + writeFile("new.txt", "/in/a.txt", "hello"); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage(), e.getMessage().startsWith("The destination directory path is invalid: '/in/a.txt'")); + } + } + + @Test + public void writeFileUnknownLength() throws Exception { + connect(); + byte[] bytes = "streamed".getBytes(StandardCharsets.UTF_8); + connection.writeFile("s.txt", "/out", false, new ByteArrayInputStream(bytes), -1, new HashMap<>()); + assertEquals("streamed", content("out/s.txt")); + } + + @Test + public void delete() throws Exception { + connect(); + connection.delete("a.txt", "/in", false); + assertFalse(new File(server.root, "in/a.txt").exists()); + + // A failed delete is only logged + connection.delete("a.txt", "/in", false); + connection.delete("a.txt", "/in", true); + } + + @Test + public void moveToExistingDirectory() throws Exception { + connect(); + connection.move("a.txt", "/in", "moved.txt", "/out"); + assertFalse(new File(server.root, "in/a.txt").exists()); + assertEquals("alpha", content("out/moved.txt")); + } + + @Test + public void moveCreatesMissingDirectory() throws Exception { + connect(); + connection.move("a.txt", "/in", "moved.txt", "/archive"); + assertTrue(server.requests().contains("MKCOL /archive")); + assertEquals("alpha", content("archive/moved.txt")); + } + + @Test + public void moveIntoFileFails() throws Exception { + connect(); + try { + connection.move("a.txt", "/in", "moved.txt", "/in/b.txt"); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage(), e.getMessage().startsWith("The move-to directory path is invalid: '/in/b.txt'")); + } + assertTrue(new File(server.root, "in/a.txt").exists()); + } + + @Test + public void moveDoesNotOverwrite() throws Exception { + connect(); + connection.move("a.txt", "/in", "b.txt", "/in"); + assertEquals("alpha", content("in/a.txt")); + assertEquals("bravo!", content("in/b.txt")); + assertEquals(List.of("http://" + server.authority() + "/in/b.txt"), server.destinations); + } + + @Test + public void moveMissingFileIsOnlyLogged() throws Exception { + connect(); + connection.move("nope.txt", "/in", "moved.txt", "/out"); + assertFalse(new File(server.root, "out/moved.txt").exists()); + } + + @Test + public void canReadAndCanWrite() throws Exception { + connect(); + assertTrue(connection.canRead("/in")); + assertTrue(connection.canWrite("/in")); + assertFalse(connection.canRead("/in/a.txt")); + assertFalse(connection.canWrite("/in/a.txt")); + assertFalse(connection.canRead("/missing")); + assertFalse(connection.canWrite("/missing")); + + server.lockedPaths.add("/out"); + assertTrue(connection.canRead("/out")); + assertFalse(connection.canWrite("/out")); + } + + @Test + public void externalEntitiesAreNotResolved() throws Exception { + connect(); + File secret = tmp.newFile("secret.txt"); + Files.write(secret.toPath(), "leaked".getBytes(StandardCharsets.UTF_8)); + server.propfindOverride = "]>" + + "/in/&x;" + + "HTTP/1.1 200 OK"; + try { + List files = connection.listFiles("/in", "*", false, false); + assertFalse(names(files).contains("leaked")); + } catch (Exception e) { + // rejecting the document outright is fine too + } + } + + @Test + public void putIsRetriedWhenOnlyWritesAreChallenged() throws Exception { + connect(Auth.BASIC_WRITES, WebDavTestServer.USER, WebDavTestServer.PASSWORD); + // The PROPFIND before the PUT succeeds anonymously, so the PUT itself gets the 401 + writeFile("challenged.txt", "/out", "body"); + assertEquals("body", content("out/challenged.txt")); + byte[] bytes = "streamed".getBytes(StandardCharsets.UTF_8); + connection.writeFile("chunked.txt", "/out", false, new ByteArrayInputStream(bytes), -1, new HashMap<>()); + assertEquals("streamed", content("out/chunked.txt")); + connection.move("challenged.txt", "/out", "moved.txt", "/out"); + assertEquals("body", content("out/moved.txt")); + } + + @Test + public void prettyPrintedMultistatus() throws Exception { + connect(); + server.prettyPrint = true; + FileInfo info = connection.listFiles("/in", "b.txt", false, true).get(0); + assertEquals(6, info.getSize()); + assertEquals(MODIFIED, info.getLastModified()); + assertEquals(List.of(".hidden.txt", "a.txt", "b.txt", "c.hl7"), names(connection.listFiles("/in", "*", false, false))); + assertEquals(2, connection.listDirectories("/in").size()); + assertTrue(connection.canWrite("/in")); + assertFalse(connection.canRead("/in/a.txt")); + } + + @Test + public void failedResponsesAreSkipped() throws Exception { + connect(); + server.propfindOverride = multistatus( + "/in/" + propstat("", "200 OK") + "", + "/in/ok.txt" + propstat("3", "200 OK") + "", + "/in/forbidden.txtHTTP/1.1 403 Forbidden", + "/in/gone.txt" + propstat("", "404 Not Found") + ""); + List files = connection.listFiles("/in", "*", false, false); + assertEquals(List.of("ok.txt"), names(files)); + assertEquals(3, files.get(0).getSize()); + } + + @Test + public void selfEntryBehindRewritingProxy() throws Exception { + connect(); + server.propfindOverride = multistatus( + "https://proxy.example/dav/in/" + propstat("", "200 OK") + "", + "https://proxy.example/dav/in/a.txt" + propstat("", "200 OK") + "", + "https://proxy.example/dav/in/sub/" + propstat("", "200 OK") + ""); + assertEquals(List.of("a.txt"), names(connection.listFiles("/in", "*", false, false))); + assertEquals(List.of("/in/sub"), connection.listDirectories("/in")); + + // An empty directory behind the proxy only lists itself + server.propfindOverride = multistatus("/dav/in/" + propstat("", "200 OK") + ""); + assertEquals(List.of(), connection.listDirectories("/in")); + } + + @Test + public void listingErrorIncludesStatus() throws Exception { + connect(Auth.BASIC, WebDavTestServer.USER, "wrong"); + try { + connection.listFiles("/in", "*", false, false); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("401")); + } + } + + @Test + public void timeout() throws Exception { + server.start(Auth.NONE, false); + open(false, "null", "null", 200); + server.delayMillis = 2000; + long start = System.currentTimeMillis(); + try { + connection.listFiles("/in", "*", false, false); + fail(); + } catch (java.net.SocketTimeoutException e) { + // expected + } + assertTrue(System.currentTimeMillis() - start < 1500); + } + + @Test + public void ipv6LiteralHost() throws Exception { + connection = new WebDavConnection("[::1]", 1, false, new FileSystemConnectionOptions(false, "null", "null", null), 1000); + assertFalse(connection.exists("a.txt", "/in")); + } + + @Test + public void connectedAndValid() throws Exception { + connect(); + assertTrue(connection.isConnected()); + assertTrue(connection.isValid()); + } + + @Test + public void nullUsernameSendsNoCredentials() throws Exception { + connect(Auth.NONE, "null", "null"); + connection.listFiles("/in", "*", false, false); + connection.readFile("a.txt", "/in", new HashMap<>()).close(); + assertTrue(server.authorizations.stream().allMatch(String::isEmpty)); + } + + @Test + public void basicAuth() throws Exception { + connect(Auth.BASIC, WebDavTestServer.USER, WebDavTestServer.PASSWORD); + exerciseAuthenticated(); + assertTrue(server.authorizations.contains("Basic")); + } + + @Test + public void digestAuth() throws Exception { + connect(Auth.DIGEST, WebDavTestServer.USER, WebDavTestServer.PASSWORD); + exerciseAuthenticated(); + assertTrue(server.authorizations.contains("Digest")); + } + + @Test + public void wrongPassword() throws Exception { + connect(Auth.BASIC, WebDavTestServer.USER, "wrong"); + assertFalse(connection.isValid()); + assertFalse(connection.exists("a.txt", "/in")); + assertFalse(connection.canRead("/in")); + assertTrue(server.requests().isEmpty()); + } + + @Test + public void https() throws Exception { + server.start(Auth.BASIC, true); + SSLContext previous = SSLContext.getDefault(); + SSLContext.setDefault(server.clientSslContext()); + try { + open(true, WebDavTestServer.USER, WebDavTestServer.PASSWORD, 5000); + FileInfo info = connection.listFiles("/in", "a.txt", false, true).get(0); + assertEquals("https://" + server.authority() + "/in/a.txt", info.getCanonicalPath()); + assertEquals("alpha", read(connection.readFile("a.txt", "/in", new HashMap<>()))); + writeFile("tls.txt", "/out", "secure"); + assertEquals("secure", content("out/tls.txt")); + } finally { + SSLContext.setDefault(previous); + } + } + + @Test + public void pathEncoding() throws Exception { + connect(); + String dir = "/spa ce/ü+ß"; + String name = "a b ü+%.txt"; + new File(server.root, dir).mkdirs(); + write(server.root, dir + "/" + name, "encoded"); + + assertTrue(connection.exists(name, dir)); + assertTrue(connection.canWrite(dir)); + List files = connection.listFiles(dir, "*.txt", false, true); + assertEquals(List.of(name), names(files)); + assertEquals(dir, files.get(0).getParent()); + assertEquals(7, files.get(0).getSize()); + assertEquals("encoded", read(connection.readFile(name, dir, new HashMap<>()))); + + writeFile("n ü.txt", "/new dir ü", "made"); + assertEquals("made", content("new dir ü/n ü.txt")); + + connection.move(name, dir, "m ü.txt", "/new dir ü"); + assertEquals("encoded", content("new dir ü/m ü.txt")); + + connection.delete("m ü.txt", "/new dir ü", false); + assertFalse(new File(server.root, "new dir ü/m ü.txt").exists()); + + String sub = connection.listDirectories("/spa ce").get(0); + assertEquals(List.of(), names(connection.listFiles(sub, "*", false, false))); + } + + private void exerciseAuthenticated() throws Exception { + assertTrue(connection.isValid()); + assertEquals(List.of("a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, true))); + assertEquals("alpha", read(connection.readFile("a.txt", "/in", new HashMap<>()))); + writeFile("auth.txt", "/newdir", "authed"); + assertEquals("authed", content("newdir/auth.txt")); + connection.move("auth.txt", "/newdir", "auth2.txt", "/newdir"); + connection.delete("auth2.txt", "/newdir", false); + assertFalse(new File(server.root, "newdir/auth2.txt").exists()); + } + + private static String multistatus(String... responses) { + return "" + String.join("", responses) + ""; + } + + private static String propstat(String props, String status) { + return "" + props + "HTTP/1.1 " + status + ""; + } + + private void writeFile(String name, String dir, String content) throws Exception { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + connection.writeFile(name, dir, false, new ByteArrayInputStream(bytes), bytes.length, new HashMap<>()); + } + + private String content(String path) throws Exception { + return new String(Files.readAllBytes(new File(server.root, path).toPath()), StandardCharsets.UTF_8); + } + + private static String read(InputStream in) throws Exception { + try (InputStream is = in) { + return IOUtils.toString(is, StandardCharsets.UTF_8); + } + } + + private static void write(File root, String path, String content) throws Exception { + File file = new File(root, path); + file.getParentFile().mkdirs(); + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + Files.setLastModifiedTime(file.toPath(), FileTime.fromMillis(MODIFIED)); + } + + private static List names(List files) { + List names = new ArrayList<>(); + for (FileInfo file : files) { + names.add(file.getName()); + } + Collections.sort(names); + return names; + } +} diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java new file mode 100644 index 0000000000..aafdffd31a --- /dev/null +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java @@ -0,0 +1,376 @@ +package com.mirth.connect.connectors.file.filesystems; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.FileUtils; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.eclipse.jetty.security.ConstraintMapping; +import org.eclipse.jetty.security.ConstraintSecurityHandler; +import org.eclipse.jetty.security.HashLoginService; +import org.eclipse.jetty.security.UserStore; +import org.eclipse.jetty.security.authentication.BasicAuthenticator; +import org.eclipse.jetty.security.authentication.DigestAuthenticator; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.util.security.Constraint; +import org.eclipse.jetty.util.security.Credential; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +/** + * Minimal file-backed WebDAV server for WebDavConnection tests. Implements just + * enough of RFC 4918 (PROPFIND depth 0/1, GET, PUT, DELETE, MKCOL, MOVE) and + * records every request so tests can assert on the wire traffic. + */ +class WebDavTestServer { + + enum Auth { + NONE, BASIC, DIGEST, + /** Basic, but GET, PROPFIND and OPTIONS are allowed anonymously */ + BASIC_WRITES + } + + static final String USER = "dav user"; + static final String PASSWORD = "p@ss:word"; + + private static final DateTimeFormatter RFC_1123 = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC); + + final File root; + final List requests = Collections.synchronizedList(new ArrayList<>()); + final List authorizations = Collections.synchronizedList(new ArrayList<>()); + final Set lockedPaths = Collections.synchronizedSet(new HashSet<>()); + final List destinations = Collections.synchronizedList(new ArrayList<>()); + /** When set, returned verbatim for every PROPFIND */ + volatile String propfindOverride; + /** Indent the multistatus and put whitespace around status lines */ + volatile boolean prettyPrint; + /** Delay before answering any request */ + volatile long delayMillis; + + private Server server; + private int port; + private SSLContext clientSslContext; + + WebDavTestServer(File root) { + this.root = root; + } + + void start(Auth auth, boolean tls) throws Exception { + server = new Server(); + + ServerConnector connector; + if (tls) { + KeyStore keyStore = selfSignedKeyStore(); + SslContextFactory.Server ssl = new SslContextFactory.Server(); + ssl.setKeyStore(keyStore); + ssl.setKeyStorePassword("changeit"); + HttpConfiguration config = new HttpConfiguration(); + config.addCustomizer(new SecureRequestCustomizer(false)); + connector = new ServerConnector(server, new SslConnectionFactory(ssl, "http/1.1"), new HttpConnectionFactory(config)); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); + clientSslContext = SSLContext.getInstance("TLS"); + clientSslContext.init(null, tmf.getTrustManagers(), null); + } else { + connector = new ServerConnector(server); + } + connector.setHost("127.0.0.1"); + connector.setPort(0); + server.addConnector(connector); + + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); + context.setContextPath("/"); + context.addServlet(new ServletHolder(new DavServlet()), "/*"); + + if (auth != Auth.NONE) { + UserStore userStore = new UserStore(); + userStore.addUser(USER, Credential.getCredential(PASSWORD), new String[] { "dav" }); + HashLoginService loginService = new HashLoginService("dav"); + loginService.setUserStore(userStore); + + boolean digest = auth == Auth.DIGEST; + Constraint constraint = new Constraint(digest ? Constraint.__DIGEST_AUTH : Constraint.__BASIC_AUTH, "dav"); + constraint.setAuthenticate(true); + ConstraintMapping mapping = new ConstraintMapping(); + mapping.setConstraint(constraint); + mapping.setPathSpec("/*"); + if (auth == Auth.BASIC_WRITES) { + mapping.setMethodOmissions(new String[] { "GET", "PROPFIND", "OPTIONS" }); + } + + ConstraintSecurityHandler security = new ConstraintSecurityHandler(); + security.setLoginService(loginService); + security.setAuthenticator(digest ? new DigestAuthenticator() : new BasicAuthenticator()); + security.addConstraintMapping(mapping); + context.setSecurityHandler(security); + } + + // Record before the security handler so 401s show up too + server.setHandler(new org.eclipse.jetty.server.handler.HandlerWrapper() { + { + setHandler(context); + } + + @Override + public void handle(String target, org.eclipse.jetty.server.Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, javax.servlet.ServletException { + String authorization = request.getHeader("Authorization"); + authorizations.add(authorization == null ? "" : authorization.split(" ")[0]); + super.handle(target, baseRequest, request, response); + } + }); + + server.start(); + port = connector.getLocalPort(); + } + + void stop() throws Exception { + if (server != null) { + server.stop(); + } + } + + int port() { + return port; + } + + /** host:port as it appears in URLs */ + String authority() { + return "localhost:" + port; + } + + SSLContext clientSslContext() { + return clientSslContext; + } + + /** Requests seen by the servlet (i.e. after authentication), as "METHOD /decoded/path". */ + List requests() { + synchronized (requests) { + return new ArrayList<>(requests); + } + } + + private static KeyStore selfSignedKeyStore() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + + X500Name name = new X500Name("CN=localhost"); + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 86_400_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name, BigInteger.valueOf(System.nanoTime()), notBefore, notAfter, name, keyPair.getPublic()); + builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(new GeneralName[] { + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1") })); + X509Certificate cert = new JcaX509CertificateConverter().getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()))); + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry("server", keyPair.getPrivate(), "changeit".toCharArray(), new X509Certificate[] { cert }); + return keyStore; + } + + private class DavServlet extends HttpServlet { + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { + String path = decode(req.getRequestURI()); + requests.add(req.getMethod() + " " + path); + if (delayMillis > 0) { + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) { + return; + } + } + File file = resolve(path); + + switch (req.getMethod()) { + case "PROPFIND": + propfind(req, resp, path, file); + break; + case "GET": + if (!file.isFile()) { + resp.sendError(404); + return; + } + try (OutputStream out = resp.getOutputStream()) { + Files.copy(file.toPath(), out); + } + break; + case "PUT": + if (!file.getParentFile().isDirectory()) { + resp.sendError(409); + return; + } + boolean existed = file.exists(); + try (InputStream in = req.getInputStream()) { + Files.copy(in, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + resp.setStatus(existed ? 204 : 201); + break; + case "MKCOL": + if (file.exists()) { + resp.sendError(405); + } else if (!file.getParentFile().isDirectory()) { + resp.sendError(409); + } else { + file.mkdir(); + resp.setStatus(201); + } + break; + case "DELETE": + if (!file.exists()) { + resp.sendError(404); + return; + } + FileUtils.forceDelete(file); + resp.setStatus(204); + break; + case "MOVE": + String destination = req.getHeader("Destination"); + destinations.add(destination); + File target = resolve(decode(URI.create(destination).getRawPath())); + if (!file.exists()) { + resp.sendError(404); + } else if (!target.getParentFile().isDirectory()) { + resp.sendError(409); + } else if (target.exists() && "F".equals(req.getHeader("Overwrite"))) { + resp.sendError(412); + } else { + boolean replaced = target.exists(); + Files.move(file.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + resp.setStatus(replaced ? 204 : 201); + } + break; + default: + resp.sendError(405); + } + } + + private void propfind(HttpServletRequest req, HttpServletResponse resp, String path, File file) throws IOException { + if (!file.exists()) { + resp.sendError(404); + return; + } + // Drain the request body + req.getInputStream().readAllBytes(); + if (propfindOverride != null) { + resp.setStatus(207); + resp.getOutputStream().write(propfindOverride.getBytes(StandardCharsets.UTF_8)); + return; + } + + // Mixed prefixes on purpose: clients must match on namespace, not prefix + StringBuilder xml = new StringBuilder("\n"); + xml.append(""); + appendResponse(xml, path, file); + if (file.isDirectory() && !"0".equals(req.getHeader("Depth"))) { + try (Stream children = Files.list(file.toPath())) { + for (Path child : (Iterable) children.sorted()::iterator) { + appendResponse(xml, (path.endsWith("/") ? path : path + "/") + child.getFileName(), child.toFile()); + } + } + } + xml.append(""); + + resp.setStatus(207); + resp.setContentType("application/xml; charset=utf-8"); + resp.getOutputStream().write(xml.toString().getBytes(StandardCharsets.UTF_8)); + } + + private void appendResponse(StringBuilder xml, String path, File file) { + String href = encode(path); + if (file.isDirectory() && !href.endsWith("/")) { + href += "/"; + } + xml.append("").append(href).append(""); + if (file.isDirectory()) { + xml.append(""); + } else { + xml.append(""); + xml.append("").append(file.length()).append(""); + } + xml.append("").append(RFC_1123.format(new Date(file.lastModified()).toInstant())).append(""); + String normalized = path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path; + if (lockedPaths.contains(normalized)) { + xml.append(""); + xml.append("0someoneSecond-3600"); + xml.append("opaquelocktoken:1234"); + } else { + xml.append(""); + } + xml.append("HTTP/1.1 200 OK"); + if (prettyPrint) { + String pretty = xml.toString().replace("><", ">\n <").replace("HTTP/1.1 200 OK", "\n HTTP/1.1 200 OK\n "); + xml.setLength(0); + xml.append(pretty); + } + } + + private File resolve(String path) { + return new File(root, path); + } + } + + private static String decode(String rawPath) { + try { + return URLDecoder.decode(rawPath.replace("+", "%2B"), "UTF-8"); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } + + private static String encode(String path) { + StringBuilder encoded = new StringBuilder(); + for (byte b : path.getBytes(StandardCharsets.UTF_8)) { + int c = b & 0xff; + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || "/-._~".indexOf(c) >= 0) { + encoded.append((char) c); + } else { + encoded.append('%').append(String.format("%02X", c)); + } + } + return encoded.toString(); + } +} From 9be087a73f1a0dfc59b8fd00884ebbb03b4399d6 Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 00:06:03 +0100 Subject: [PATCH 02/10] Remove webdavclient4j WebDavConnection no longer uses it. Signed-off-by: Nico Piel --- server/docs/thirdparty/THIRD-PARTY-README.txt | 1 - .../file/webdavclient4j-core-0.92.jar | Bin 192186 -> 0 bytes .../connect/connectors/file/destination.xml | 1 - .../mirth/connect/connectors/file/source.xml | 1 - 4 files changed, 3 deletions(-) delete mode 100644 server/lib/extensions/file/webdavclient4j-core-0.92.jar diff --git a/server/docs/thirdparty/THIRD-PARTY-README.txt b/server/docs/thirdparty/THIRD-PARTY-README.txt index e8a549a155..6012117e3c 100644 --- a/server/docs/thirdparty/THIRD-PARTY-README.txt +++ b/server/docs/thirdparty/THIRD-PARTY-README.txt @@ -42,7 +42,6 @@ The following are distributed under the terms of the Apache License version 2.0 StAXON 1.3 [incl. CLI] (https://github.com/beckchr/staxon) Swagger Core 2.0.10 [incl. CLI] (https://github.com/swagger-api/swagger-core) Swagger UI 2.2.10 (http://swagger.io/swagger-ui/) - webdavclient4j 0.92 (http://sourceforge.net/projects/webdavclient4j/) Zip4j 1.3.3 (http://www.lingala.net/zip4j/) diff --git a/server/lib/extensions/file/webdavclient4j-core-0.92.jar b/server/lib/extensions/file/webdavclient4j-core-0.92.jar deleted file mode 100644 index 8bd3b5f73ebd58a2f4e8c0c6083c033d3351b7f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 192186 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

t-Jrq413SFp|sKw+O^dnYu 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 X>uKVH!3N4L;Ex;#>h>>zi-r6j2I(I{ diff --git a/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml b/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml index f3c9326bf4..51f4d993df 100644 --- a/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml +++ b/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml @@ -12,7 +12,6 @@ - file diff --git a/server/src/main/resources/com/mirth/connect/connectors/file/source.xml b/server/src/main/resources/com/mirth/connect/connectors/file/source.xml index 8a36863918..4c26f1bfd3 100644 --- a/server/src/main/resources/com/mirth/connect/connectors/file/source.xml +++ b/server/src/main/resources/com/mirth/connect/connectors/file/source.xml @@ -13,7 +13,6 @@ - file From 0dd0a84f10b312aaaafb9624280c64cccb5b425e Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 12:14:24 +0100 Subject: [PATCH 03/10] Stream WebDAV PUT bodies instead of buffering them BufferedHttpEntity copied every upload into extra full-size heap buffers before the PUT started, on top of the payload FileDispatcher already holds. Stream the body again and send Expect: 100-continue instead: an authentication challenge then arrives before any of the body is sent, so the PUT can still be retried after a 401. Signed-off-by: Nico Piel --- .../connectors/file/filesystems/WebDavConnection.java | 10 ++++++---- .../file/filesystems/WebDavConnectionTest.java | 2 ++ .../connectors/file/filesystems/WebDavTestServer.java | 4 ++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index 589a1237a5..d30aec83f0 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -50,7 +50,6 @@ import org.apache.http.client.utils.DateUtils; import org.apache.http.client.utils.URIBuilder; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.entity.BufferedHttpEntity; import org.apache.http.entity.ContentType; import org.apache.http.entity.InputStreamEntity; import org.apache.http.entity.StringEntity; @@ -185,6 +184,7 @@ public String getMethod() { private CloseableHttpClient client = null; /** Shared across requests so an authenticated session is reused */ private HttpClientContext context = HttpClientContext.create(); + private RequestConfig requestConfig; private String baseUrl; private CloseableHttpResponse readResponse = null; /** Whether the last path looked up exists, as WebdavResource.exists() reported it */ @@ -211,7 +211,7 @@ public WebDavConnection(String host, int port, boolean secure, FileSystemConnect } // The schemes httpclient 3 negotiated, in its order of preference - RequestConfig requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).build(); + requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).build(); client = HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault())).build(); @@ -338,8 +338,10 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, } HttpPut put = new HttpPut(uri(fullPath)); - // Buffered so the PUT can be repeated after an authentication challenge - put.setEntity(new BufferedHttpEntity(new InputStreamEntity(is, contentLength))); + // The body is streamed, so it can't be sent twice. With Expect: 100-continue an + // authentication challenge arrives before the body, and the PUT can still be retried. + put.setConfig(RequestConfig.copy(requestConfig).setExpectContinueEnabled(true).build()); + put.setEntity(new InputStreamEntity(is, contentLength)); if (!execute(put)) { logger.error("Unable to write file: '" + fullPath); } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index 38c271f5bf..2fe4949da4 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -320,6 +320,8 @@ public void putIsRetriedWhenOnlyWritesAreChallenged() throws Exception { assertEquals("streamed", content("out/chunked.txt")); connection.move("challenged.txt", "/out", "moved.txt", "/out"); assertEquals("body", content("out/moved.txt")); + // Streamed, not buffered: the retry relies on 100-continue holding the body back + assertTrue(server.putExpectHeaders.stream().allMatch("100-continue"::equalsIgnoreCase)); } @Test diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java index aafdffd31a..f670c8a262 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java @@ -80,6 +80,7 @@ enum Auth { final List authorizations = Collections.synchronizedList(new ArrayList<>()); final Set lockedPaths = Collections.synchronizedSet(new HashSet<>()); final List destinations = Collections.synchronizedList(new ArrayList<>()); + final List putExpectHeaders = Collections.synchronizedList(new ArrayList<>()); /** When set, returned verbatim for every PROPFIND */ volatile String propfindOverride; /** Indent the multistatus and put whitespace around status lines */ @@ -156,6 +157,9 @@ void start(Auth auth, boolean tls) throws Exception { public void handle(String target, org.eclipse.jetty.server.Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, javax.servlet.ServletException { String authorization = request.getHeader("Authorization"); authorizations.add(authorization == null ? "" : authorization.split(" ")[0]); + if ("PUT".equals(request.getMethod())) { + putExpectHeaders.add(String.valueOf(request.getHeader("Expect"))); + } super.handle(target, baseRequest, request, response); } }); From f17d3cb65bbece286b539e634c237776b1d7f1e4 Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 12:02:02 +0100 Subject: [PATCH 04/10] Retry WebDAV PROPFIND with a trailing slash after a redirect Apache mod_dav answers a PROPFIND on a collection without its trailing slash with 301 to the slashed URL, and httpclient does not follow redirects for PROPFIND. Every directory check against Apache therefore failed, including the connection test. Signed-off-by: Nico Piel --- .../file/filesystems/WebDavConnection.java | 8 +++++++- .../file/filesystems/WebDavConnectionTest.java | 16 ++++++++++++++++ .../file/filesystems/WebDavTestServer.java | 8 ++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index d30aec83f0..e12d42121f 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -446,10 +446,16 @@ private Resource stat(String path) throws IOException { /** Returns the resources of a PROPFIND multistatus, or null if the request failed */ private List propfind(String path, int depth) throws IOException { try (CloseableHttpResponse response = client.execute(new HttpPropfind(uri(path), depth), context)) { - exists = response.getStatusLine().getStatusCode() == HttpStatus.SC_MULTI_STATUS; + int statusCode = response.getStatusLine().getStatusCode(); + exists = statusCode == HttpStatus.SC_MULTI_STATUS; if (!exists) { lastStatus = response.getStatusLine().toString(); EntityUtils.consume(response.getEntity()); + + // Apache redirects a collection requested without its trailing slash + if (statusCode >= 300 && statusCode < 400 && !path.endsWith("/")) { + return propfind(path + "/", depth); + } return null; } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index 2fe4949da4..93b93a3e6e 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -397,6 +397,22 @@ public void ipv6LiteralHost() throws Exception { assertFalse(connection.exists("a.txt", "/in")); } + @Test + public void collectionWithoutTrailingSlashIsRedirected() throws Exception { + connect(Auth.BASIC, WebDavTestServer.USER, WebDavTestServer.PASSWORD); + server.slashRedirect = true; + assertTrue(connection.canRead("/in")); + assertTrue(connection.canWrite("/in")); + assertTrue(connection.exists("sub", "/in")); + assertEquals(List.of("a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, true))); + assertEquals(List.of("d.txt"), names(connection.listFiles(connection.listDirectories("/in").get(0), "*", false, false))); + writeFile("redirected.txt", "/out", "ok"); + assertEquals("ok", content("out/redirected.txt")); + connection.move("redirected.txt", "/out", "moved.txt", "/in/sub"); + assertEquals("ok", content("in/sub/moved.txt")); + assertTrue(server.requests().contains("PROPFIND /in/")); + } + @Test public void connectedAndValid() throws Exception { connect(); diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java index f670c8a262..21449052ea 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java @@ -87,6 +87,8 @@ enum Auth { volatile boolean prettyPrint; /** Delay before answering any request */ volatile long delayMillis; + /** Redirect a collection requested without a trailing slash, like Apache's DirectorySlash */ + volatile boolean slashRedirect; private Server server; private int port; @@ -229,6 +231,12 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws } File file = resolve(path); + if (slashRedirect && file.isDirectory() && !req.getRequestURI().endsWith("/")) { + resp.setStatus(301); + resp.setHeader("Location", req.getRequestURL() + "/"); + return; + } + switch (req.getMethod()) { case "PROPFIND": propfind(req, resp, path, file); From e7d9a696381e36aa437fa91d22191d7887127e3b Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 12:21:11 +0100 Subject: [PATCH 05/10] Keep WebDAV test file names ASCII on disk The CI container runs with a POSIX locale, where the JVM cannot create files with non-ASCII names, so pathEncoding failed with InvalidPathException before reaching the connector. The test server now stores names percent-encoded on disk and decodes them when listing. Signed-off-by: Nico Piel --- .../filesystems/WebDavConnectionTest.java | 22 +++++++++---------- .../file/filesystems/WebDavTestServer.java | 12 ++++++++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index 93b93a3e6e..c20eef9fd7 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -47,8 +47,8 @@ public void setUp() throws Exception { write(root, "in/c.hl7", "MSH|"); write(root, "in/.hidden.txt", "x"); write(root, "in/sub/d.txt", "delta"); - new File(root, "in/sub2").mkdirs(); - new File(root, "out").mkdirs(); + WebDavTestServer.file(root, "in/sub2").mkdirs(); + WebDavTestServer.file(root, "out").mkdirs(); server = new WebDavTestServer(root); } @@ -227,7 +227,7 @@ public void writeFileUnknownLength() throws Exception { public void delete() throws Exception { connect(); connection.delete("a.txt", "/in", false); - assertFalse(new File(server.root, "in/a.txt").exists()); + assertFalse(WebDavTestServer.file(server.root, "in/a.txt").exists()); // A failed delete is only logged connection.delete("a.txt", "/in", false); @@ -238,7 +238,7 @@ public void delete() throws Exception { public void moveToExistingDirectory() throws Exception { connect(); connection.move("a.txt", "/in", "moved.txt", "/out"); - assertFalse(new File(server.root, "in/a.txt").exists()); + assertFalse(WebDavTestServer.file(server.root, "in/a.txt").exists()); assertEquals("alpha", content("out/moved.txt")); } @@ -259,7 +259,7 @@ public void moveIntoFileFails() throws Exception { } catch (Exception e) { assertTrue(e.getMessage(), e.getMessage().startsWith("The move-to directory path is invalid: '/in/b.txt'")); } - assertTrue(new File(server.root, "in/a.txt").exists()); + assertTrue(WebDavTestServer.file(server.root, "in/a.txt").exists()); } @Test @@ -275,7 +275,7 @@ public void moveDoesNotOverwrite() throws Exception { public void moveMissingFileIsOnlyLogged() throws Exception { connect(); connection.move("nope.txt", "/in", "moved.txt", "/out"); - assertFalse(new File(server.root, "out/moved.txt").exists()); + assertFalse(WebDavTestServer.file(server.root, "out/moved.txt").exists()); } @Test @@ -473,7 +473,7 @@ public void pathEncoding() throws Exception { connect(); String dir = "/spa ce/ü+ß"; String name = "a b ü+%.txt"; - new File(server.root, dir).mkdirs(); + WebDavTestServer.file(server.root, dir).mkdirs(); write(server.root, dir + "/" + name, "encoded"); assertTrue(connection.exists(name, dir)); @@ -491,7 +491,7 @@ public void pathEncoding() throws Exception { assertEquals("encoded", content("new dir ü/m ü.txt")); connection.delete("m ü.txt", "/new dir ü", false); - assertFalse(new File(server.root, "new dir ü/m ü.txt").exists()); + assertFalse(WebDavTestServer.file(server.root, "new dir ü/m ü.txt").exists()); String sub = connection.listDirectories("/spa ce").get(0); assertEquals(List.of(), names(connection.listFiles(sub, "*", false, false))); @@ -505,7 +505,7 @@ private void exerciseAuthenticated() throws Exception { assertEquals("authed", content("newdir/auth.txt")); connection.move("auth.txt", "/newdir", "auth2.txt", "/newdir"); connection.delete("auth2.txt", "/newdir", false); - assertFalse(new File(server.root, "newdir/auth2.txt").exists()); + assertFalse(WebDavTestServer.file(server.root, "newdir/auth2.txt").exists()); } private static String multistatus(String... responses) { @@ -522,7 +522,7 @@ private void writeFile(String name, String dir, String content) throws Exception } private String content(String path) throws Exception { - return new String(Files.readAllBytes(new File(server.root, path).toPath()), StandardCharsets.UTF_8); + return new String(Files.readAllBytes(WebDavTestServer.file(server.root, path).toPath()), StandardCharsets.UTF_8); } private static String read(InputStream in) throws Exception { @@ -532,7 +532,7 @@ private static String read(InputStream in) throws Exception { } private static void write(File root, String path, String content) throws Exception { - File file = new File(root, path); + File file = WebDavTestServer.file(root, path); file.getParentFile().mkdirs(); Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); Files.setLastModifiedTime(file.toPath(), FileTime.fromMillis(MODIFIED)); diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java index 21449052ea..8021a25ab2 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java @@ -320,7 +320,7 @@ private void propfind(HttpServletRequest req, HttpServletResponse resp, String p if (file.isDirectory() && !"0".equals(req.getHeader("Depth"))) { try (Stream children = Files.list(file.toPath())) { for (Path child : (Iterable) children.sorted()::iterator) { - appendResponse(xml, (path.endsWith("/") ? path : path + "/") + child.getFileName(), child.toFile()); + appendResponse(xml, (path.endsWith("/") ? path : path + "/") + decode(child.getFileName().toString()), child.toFile()); } } } @@ -361,10 +361,18 @@ private void appendResponse(StringBuilder xml, String path, File file) { } private File resolve(String path) { - return new File(root, path); + return file(root, path); } } + /** + * The file backing a decoded DAV path. Names are stored percent-encoded so they stay ASCII on + * disk, which works whatever charset the JVM uses for file names (e.g. a POSIX locale in CI). + */ + static File file(File root, String path) { + return new File(root, encode(path)); + } + private static String decode(String rawPath) { try { return URLDecoder.decode(rawPath.replace("+", "%2B"), "UTF-8"); From afaef4e706b96e17aa52f0672d6bcd535ce0ebeb Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 12:37:52 +0100 Subject: [PATCH 06/10] Resend WebDAV PUT bodies by resetting the stream Expect: 100-continue does not guarantee that an authentication challenge arrives before the body. nginx with request buffering answers 100 Continue and reads the whole body before the upstream 401, so the streamed PUT could not be retried and failed with NonRepeatableRequestException. FileDispatcher passes a ByteArrayInputStream, so make that entity repeatable by resetting the stream before each send. It is still not copied. Other streams keep the Expect: 100-continue fallback. Signed-off-by: Nico Piel --- .../file/filesystems/WebDavConnection.java | 40 +++++++++++++++++-- .../filesystems/WebDavConnectionTest.java | 26 +++++++++++- .../file/filesystems/WebDavTestServer.java | 6 +++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index e12d42121f..c215637ea0 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -9,9 +9,11 @@ package com.mirth.connect.connectors.file.filesystems; +import java.io.ByteArrayInputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; @@ -175,6 +177,32 @@ public String getMethod() { } } + /** + * Streams a ByteArrayInputStream, which is what FileDispatcher passes, and resends it by + * resetting the stream. The PUT can then be retried after an authentication challenge without + * copying the payload, even when a proxy has already read the body. + */ + private static class ResettableEntity extends InputStreamEntity { + private final ByteArrayInputStream in; + + ResettableEntity(ByteArrayInputStream in, long length) { + super(in, length); + this.in = in; + in.mark(0); + } + + @Override + public boolean isRepeatable() { + return true; + } + + @Override + public void writeTo(OutputStream out) throws IOException { + in.reset(); + super.writeTo(out); + } + } + private static final String DAV = "DAV:"; private static final Pattern SUCCESS_STATUS = Pattern.compile("^HTTP/\\S+\\s+2\\d\\d"); @@ -338,10 +366,14 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, } HttpPut put = new HttpPut(uri(fullPath)); - // The body is streamed, so it can't be sent twice. With Expect: 100-continue an - // authentication challenge arrives before the body, and the PUT can still be retried. - put.setConfig(RequestConfig.copy(requestConfig).setExpectContinueEnabled(true).build()); - put.setEntity(new InputStreamEntity(is, contentLength)); + if (is instanceof ByteArrayInputStream) { + put.setEntity(new ResettableEntity((ByteArrayInputStream) is, contentLength)); + } else { + // Other streams can't be resent. Expect: 100-continue usually gets an authentication + // challenge before the body, but a buffering proxy may still consume it first. + put.setConfig(RequestConfig.copy(requestConfig).setExpectContinueEnabled(true).build()); + put.setEntity(new InputStreamEntity(is, contentLength)); + } if (!execute(put)) { logger.error("Unable to write file: '" + fullPath); } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index c20eef9fd7..d1da3d1657 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -320,7 +320,31 @@ public void putIsRetriedWhenOnlyWritesAreChallenged() throws Exception { assertEquals("streamed", content("out/chunked.txt")); connection.move("challenged.txt", "/out", "moved.txt", "/out"); assertEquals("body", content("out/moved.txt")); - // Streamed, not buffered: the retry relies on 100-continue holding the body back + // A ByteArrayInputStream is resent by resetting it, so 100-continue isn't needed + assertTrue(server.putExpectHeaders.stream().noneMatch("100-continue"::equalsIgnoreCase)); + } + + @Test + public void putIsRetriedWhenTheBodyIsConsumedBeforeTheChallenge() throws Exception { + connect(Auth.BASIC_WRITES, WebDavTestServer.USER, WebDavTestServer.PASSWORD); + server.consumeUnauthenticatedBodies = true; + // FileDispatcher hands over a ByteArrayInputStream + writeFile("challenged.txt", "/out", "body"); + assertEquals("body", content("out/challenged.txt")); + } + + @Test + public void nonResettableStreamUsesExpectContinue() throws Exception { + connect(Auth.BASIC_WRITES, WebDavTestServer.USER, WebDavTestServer.PASSWORD); + byte[] bytes = "streamed".getBytes(StandardCharsets.UTF_8); + InputStream in = new java.io.FilterInputStream(new ByteArrayInputStream(bytes)) { + @Override + public boolean markSupported() { + return false; + } + }; + connection.writeFile("stream.txt", "/out", false, in, bytes.length, new HashMap<>()); + assertEquals("streamed", content("out/stream.txt")); assertTrue(server.putExpectHeaders.stream().allMatch("100-continue"::equalsIgnoreCase)); } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java index 8021a25ab2..d1f6ae01ac 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java @@ -87,6 +87,8 @@ enum Auth { volatile boolean prettyPrint; /** Delay before answering any request */ volatile long delayMillis; + /** Read an unauthenticated PUT body before the 401, like nginx with request buffering */ + volatile boolean consumeUnauthenticatedBodies; /** Redirect a collection requested without a trailing slash, like Apache's DirectorySlash */ volatile boolean slashRedirect; @@ -161,6 +163,10 @@ public void handle(String target, org.eclipse.jetty.server.Request baseRequest, authorizations.add(authorization == null ? "" : authorization.split(" ")[0]); if ("PUT".equals(request.getMethod())) { putExpectHeaders.add(String.valueOf(request.getHeader("Expect"))); + if (consumeUnauthenticatedBodies && authorization == null) { + // Answers 100 Continue and takes the whole body + request.getInputStream().readAllBytes(); + } } super.handle(target, baseRequest, request, response); } From 154b1f3126707ca0516ba4f2e4cdb5144bbccbab Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 16:55:20 +0100 Subject: [PATCH 07/10] Build WebDavConnection on Sardine Review asked not to maintain a WebDAV client in the engine. Sardine 5.10 now does the requests, multistatus parsing, PROPFIND redirects and authentication; WebDavConnection only maps its results onto the file connector. Sardine leaves lockdiscovery out of DavResource, so a small subclass issues the PROPFIND and keeps the raw responses. 5.10 is the last release on javax.xml.bind, which the engine already ships, so its httpclient and JAXB dependencies are left out. Also normalize listDirectories() paths, which came back as "//in" for the root. Signed-off-by: Nico Piel --- gradle/libs.versions.toml | 1 + gradle/vendored-layout.json | 1 + gradle/verification-metadata.xml | 8 + server/build.gradle | 2 + server/docs/thirdparty/THIRD-PARTY-README.txt | 1 + .../file/filesystems/WebDavConnection.java | 314 ++++++------------ .../connect/connectors/file/destination.xml | 1 + .../mirth/connect/connectors/file/source.xml | 1 + .../filesystems/WebDavConnectionTest.java | 1 + 9 files changed, 114 insertions(+), 216 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9eb0f3a5db..f376ea1e4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -240,6 +240,7 @@ reactive-streams = { module = "org.reactivestreams:reactive-streams", version = reflections = { module = "org.reflections:reflections", version = "0.9.10" } rhino = { module = "org.mozilla:rhino", version = "1.7.13" } saaj-impl = { module = "com.sun.xml.messaging.saaj:saaj-impl", version = "1.4.0" } +sardine = { module = "com.github.lookfirst:sardine", version = "5.10" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-log4j12 = { module = "org.slf4j:slf4j-log4j12", version.ref = "slf4j" } snakeyaml = { module = "org.yaml:snakeyaml", version = "2.5" } diff --git a/gradle/vendored-layout.json b/gradle/vendored-layout.json index 7bffae2a4d..a9a805f6c7 100644 --- a/gradle/vendored-layout.json +++ b/gradle/vendored-layout.json @@ -379,6 +379,7 @@ "xpp3:xpp3": "" }, "server-extensions": { + "com.github.lookfirst:sardine": "extensions/file", "com.lowagie:itext": "extensions/doc", "com.lowagie:itext-rtf": "extensions/doc", "com.openhtmltopdf:openhtmltopdf-core": "extensions/doc", diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a55f913c97..1b2f4627cf 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -243,6 +243,14 @@ + + + + + + + + diff --git a/server/build.gradle b/server/build.gradle index d928023bb5..9e81757d97 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -9,6 +9,8 @@ import java.text.SimpleDateFormat dependencies { implementation libs.bundles.server.main implementation libs.bundles.server.extensions + // the engine already ships httpclient and JAXB, so leave out Sardine's copies + implementation(libs.sardine) { transitive = false } // classified artifact: the catalog entry carries the version, the // classifier is selected here implementation(variantOf(libs.netty.transport.native.epoll) { classifier('linux-x86_64') }) diff --git a/server/docs/thirdparty/THIRD-PARTY-README.txt b/server/docs/thirdparty/THIRD-PARTY-README.txt index 6012117e3c..bbc47fe02f 100644 --- a/server/docs/thirdparty/THIRD-PARTY-README.txt +++ b/server/docs/thirdparty/THIRD-PARTY-README.txt @@ -38,6 +38,7 @@ The following are distributed under the terms of the Apache License version 2.0 Netty 4.1.137 Not-Going-To-Be-Commons-SSL 0.3.18 Quartz Scheduler 2.3.2 + Sardine 5.10 (https://github.com/lookfirst/sardine) SQLite JDBC Driver 3.43.2.1 (https://github.com/xerial/sqlite-jdbc) StAXON 1.3 [incl. CLI] (https://github.com/beckchr/staxon) Swagger Core 2.0.10 [incl. CLI] (https://github.com/swagger-api/swagger-core) diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index c215637ea0..76ab7fe645 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -14,9 +14,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.net.URI; import java.net.URISyntaxException; -import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -25,46 +23,36 @@ import java.util.Map; import java.util.regex.Pattern; -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; - import org.apache.commons.io.filefilter.WildcardFileFilter; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.http.HttpHost; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; import org.apache.http.auth.AuthScope; import org.apache.http.auth.NTCredentials; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.HttpResponseException; import org.apache.http.client.config.AuthSchemes; import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpDelete; -import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPut; -import org.apache.http.client.methods.HttpRequestBase; -import org.apache.http.client.methods.HttpUriRequest; -import org.apache.http.client.protocol.HttpClientContext; -import org.apache.http.client.utils.DateUtils; import org.apache.http.client.utils.URIBuilder; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import org.apache.http.entity.ContentType; import org.apache.http.entity.InputStreamEntity; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; -import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.ssl.SSLContexts; -import org.apache.http.util.EntityUtils; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; +import com.github.sardine.DavResource; +import com.github.sardine.impl.SardineImpl; +import com.github.sardine.impl.SardineRedirectStrategy; +import com.github.sardine.impl.handler.MultiStatusResponseHandler; +import com.github.sardine.impl.methods.HttpPropFind; +import com.github.sardine.model.Activelock; +import com.github.sardine.model.Prop; +import com.github.sardine.model.Propstat; +import com.github.sardine.model.Response; import com.mirth.connect.connectors.file.FileSystemConnectionOptions; import com.mirth.connect.connectors.file.filters.RegexFilenameFilter; @@ -137,43 +125,19 @@ private static class Resource { private boolean locked; } - private static class HttpPropfind extends HttpEntityEnclosingRequestBase { - private static final String BODY = "" + "" + "" + ""; - - HttpPropfind(URI uri, int depth) { - setURI(uri); - setHeader("Depth", String.valueOf(depth)); - setEntity(new StringEntity(BODY, ContentType.create("application/xml", StandardCharsets.UTF_8))); - } - - @Override - public String getMethod() { - return "PROPFIND"; - } - } + /** Sardine leaves lockdiscovery out of DavResource, so keep the raw responses */ + private static class Client extends SardineImpl { + private static final String PROPFIND = "" + "" + "" + ""; - private static class HttpMkcol extends HttpRequestBase { - HttpMkcol(URI uri) { - setURI(uri); + Client(HttpClientBuilder builder) { + super(builder); } - @Override - public String getMethod() { - return "MKCOL"; - } - } - - private static class HttpMove extends HttpRequestBase { - HttpMove(URI source, URI destination) { - setURI(source); - setHeader("Destination", destination.toASCIIString()); - // Slide never overwrote an existing target - setHeader("Overwrite", "F"); - } - - @Override - public String getMethod() { - return "MOVE"; + List propfind(String url, int depth) throws IOException { + HttpPropFind propfind = new HttpPropFind(url); + propfind.setDepth(String.valueOf(depth)); + propfind.setEntity(new StringEntity(PROPFIND, ContentType.create("application/xml", StandardCharsets.UTF_8))); + return execute(propfind, new MultiStatusResponseHandler()).getResponse(); } } @@ -186,7 +150,7 @@ private static class ResettableEntity extends InputStreamEntity { private final ByteArrayInputStream in; ResettableEntity(ByteArrayInputStream in, long length) { - super(in, length); + super(in, length, ContentType.APPLICATION_OCTET_STREAM); this.in = in; in.mark(0); } @@ -203,18 +167,14 @@ public void writeTo(OutputStream out) throws IOException { } } - private static final String DAV = "DAV:"; private static final Pattern SUCCESS_STATUS = Pattern.compile("^HTTP/\\S+\\s+2\\d\\d"); private static transient Log logger = LogFactory.getLog(WebDavConnection.class); /** The WebDAV client instance */ - private CloseableHttpClient client = null; - /** Shared across requests so an authenticated session is reused */ - private HttpClientContext context = HttpClientContext.create(); - private RequestConfig requestConfig; + private Client client = null; private String baseUrl; - private CloseableHttpResponse readResponse = null; + private InputStream readStream = null; /** Whether the last path looked up exists, as WebdavResource.exists() reported it */ private boolean exists = false; /** Status line of the last failed PROPFIND */ @@ -239,9 +199,9 @@ public WebDavConnection(String host, int port, boolean secure, FileSystemConnect } // The schemes httpclient 3 negotiated, in its order of preference - requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).build(); + RequestConfig requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).setExpectContinueEnabled(false).build(); - client = HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault())).build(); + client = new Client(HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setRedirectStrategy(new SardineRedirectStrategy()).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault()))); try { stat("/"); @@ -267,7 +227,7 @@ public List listFiles(String fromDir, String filenamePattern, boolean public List listDirectories(String fromDir) throws Exception { List directories = new ArrayList(); for (FileInfo directory : list(fromDir, false, null, false)) { - directories.add(getFullPath(directory.getParent(), directory.getName())); + directories.add(normalize(getFullPath(directory.getParent(), directory.getName()))); } return directories; } @@ -292,7 +252,7 @@ private List list(String fromDir, boolean files, FilenameFilter filena String path = normalize(resource.path); String name = path.substring(path.lastIndexOf('/') + 1); - WebDavFileInfo file = new WebDavFileInfo(fromDir, name, uri(getFullPath(fromDir, name)).toString(), resource.collection, resource.length, resource.lastModified); + WebDavFileInfo file = new WebDavFileInfo(fromDir, name, url(getFullPath(fromDir, name)), resource.collection, resource.length, resource.lastModified); if (files) { if (file.isFile() && filenameFilter.accept(null, file.getName()) && !(ignoreDot && file.getName().startsWith("."))) { @@ -326,21 +286,19 @@ public InputStream readFile(String file, String fromDir, Map sou } closeReadFile(); - CloseableHttpResponse response = client.execute(new HttpGet(uri(fullPath)), context); - if (!isSuccess(response)) { - response.close(); - throw new IOException("Couldn't get file"); + try { + readStream = client.get(url(fullPath)); + } catch (HttpResponseException e) { + throw new IOException("Couldn't get file", e); } - - readResponse = response; - return response.getEntity().getContent(); + return readStream; } @Override public void closeReadFile() throws Exception { - if (readResponse != null) { - readResponse.close(); - readResponse = null; + if (readStream != null) { + readStream.close(); + readStream = null; } } @@ -358,24 +316,23 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, if (dir == null) { // create the directory. - execute(new HttpMkcol(uri(toDir))); + createDirectory(toDir); logger.info("Destination directory does not exist. Creating directory: '" + toDir + "'"); } else if (!dir.collection) { // make sure it's a directory, not a file. throw new Exception("The destination directory path is invalid: '" + toDir + "'"); } - HttpPut put = new HttpPut(uri(fullPath)); - if (is instanceof ByteArrayInputStream) { - put.setEntity(new ResettableEntity((ByteArrayInputStream) is, contentLength)); - } else { - // Other streams can't be resent. Expect: 100-continue usually gets an authentication - // challenge before the body, but a buffering proxy may still consume it first. - put.setConfig(RequestConfig.copy(requestConfig).setExpectContinueEnabled(true).build()); - put.setEntity(new InputStreamEntity(is, contentLength)); - } - if (!execute(put)) { - logger.error("Unable to write file: '" + fullPath); + try { + if (is instanceof ByteArrayInputStream) { + client.put(url(fullPath), new ResettableEntity((ByteArrayInputStream) is, contentLength), null, false); + } else { + // Other streams can't be resent. Expect: 100-continue usually gets an authentication + // challenge before the body, but a buffering proxy may still consume it first. + client.put(url(fullPath), new InputStreamEntity(is, contentLength, ContentType.APPLICATION_OCTET_STREAM), null, true); + } + } catch (HttpResponseException e) { + logger.error("Unable to write file: '" + fullPath, e); } } @@ -383,9 +340,11 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, public void delete(String file, String fromDir, boolean mayNotExist) throws Exception { String fullPath = getFullPath(fromDir, file); - if (!execute(new HttpDelete(uri(fullPath)))) { + try { + client.delete(url(fullPath)); + } catch (HttpResponseException e) { if (!mayNotExist) { - logger.error("Unable to delete file: '" + fullPath + "'"); + logger.error("Unable to delete file: '" + fullPath + "'", e); } } } @@ -400,15 +359,18 @@ public void move(String fromName, String fromDir, String toName, String toDir) t if (dir == null) { // create the directory. and then move. - execute(new HttpMkcol(uri(toDir))); + createDirectory(toDir); logger.info("Move-To directory does not exist. Creating directory: '" + toDir + "'"); } else if (!dir.collection) { // make sure it's a directory, not a file. throw new Exception("The move-to directory path is invalid: '" + toDir + "'"); } - if (!execute(new HttpMove(uri(sourcePath), uri(targetPath)))) { - logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'"); + try { + // Slide never overwrote an existing target + client.move(url(sourcePath), url(targetPath), false); + } catch (HttpResponseException e) { + logger.error("Unable to move file: '" + sourcePath + "' to '" + targetPath + "'", e); } } @@ -435,7 +397,7 @@ public void destroy() { try { closeReadFile(); if (client != null) { - client.close(); + client.shutdown(); } } catch (Exception e) { logger.debug(e); @@ -477,100 +439,66 @@ private Resource stat(String path) throws IOException { /** Returns the resources of a PROPFIND multistatus, or null if the request failed */ private List propfind(String path, int depth) throws IOException { - try (CloseableHttpResponse response = client.execute(new HttpPropfind(uri(path), depth), context)) { - int statusCode = response.getStatusLine().getStatusCode(); - exists = statusCode == HttpStatus.SC_MULTI_STATUS; - if (!exists) { - lastStatus = response.getStatusLine().toString(); - EntityUtils.consume(response.getEntity()); - - // Apache redirects a collection requested without its trailing slash - if (statusCode >= 300 && statusCode < 400 && !path.endsWith("/")) { - return propfind(path + "/", depth); - } - return null; - } - - try (InputStream in = response.getEntity().getContent()) { - return parseMultistatus(newDocumentBuilder().parse(in)); - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException("Invalid PROPFIND response for '" + path + "'", e); - } + List responses; + try { + responses = client.propfind(url(path), depth); + exists = true; + } catch (HttpResponseException e) { + exists = false; + lastStatus = e.getMessage(); + return null; } - } - private static List parseMultistatus(Document document) { List resources = new ArrayList(); - NodeList responses = document.getElementsByTagNameNS(DAV, "response"); - - for (int i = 0; i < responses.getLength(); i++) { - Element response = (Element) responses.item(i); - Element href = child(response, "href"); - if (href == null) { + for (Response response : responses) { + DavResource davResource; + try { + davResource = new DavResource(response); + } catch (URISyntaxException e) { continue; } // A response-level status means there are no properties, e.g. 403 - Element responseStatus = child(response, "status"); - if (responseStatus != null && !isSuccess(responseStatus)) { + int status = davResource.getStatusCode(); + if (status < 200 || status >= 300) { continue; } - boolean found = responseStatus != null; + // Only count responses with a 2xx propstat, or none at all + boolean found = response.getStatus() != null; Resource resource = new Resource(); - resource.path = hrefPath(href.getTextContent().trim()); - - // Only properties from a 2xx propstat are present - for (Element propstat : children(response, "propstat")) { - Element prop = child(propstat, "prop"); - if (prop == null || !isSuccess(child(propstat, "status"))) { + for (Propstat propstat : response.getPropstat()) { + Prop prop = propstat.getProp(); + if (prop == null || !isSuccess(propstat.getStatus())) { continue; } found = true; - Element resourceType = child(prop, "resourcetype"); - if (resourceType != null && child(resourceType, "collection") != null) { - resource.collection = true; - } - - Element contentLength = child(prop, "getcontentlength"); - if (contentLength != null) { - try { - resource.length = Long.parseLong(contentLength.getTextContent().trim()); - } catch (NumberFormatException e) {} - } - - Element lastModified = child(prop, "getlastmodified"); - if (lastModified != null) { - Date date = DateUtils.parseDate(lastModified.getTextContent().trim()); - if (date != null) { - resource.lastModified = date.getTime(); - } - } - - Element lockDiscovery = child(prop, "lockdiscovery"); - if (lockDiscovery != null) { - for (Element activeLock : children(lockDiscovery, "activelock")) { - Element lockType = child(activeLock, "locktype"); - if (lockType != null && child(lockType, "write") != null) { + if (prop.getLockdiscovery() != null) { + for (Activelock activeLock : prop.getLockdiscovery().getActivelock()) { + if (activeLock.getLocktype() != null && activeLock.getLocktype().getWrite() != null) { resource.locked = true; } } } } - - if (found) { - resources.add(resource); + if (!found) { + continue; } + + resource.path = davResource.getPath(); + resource.collection = davResource.isDirectory(); + resource.length = Math.max(davResource.getContentLength(), 0); + Date modified = davResource.getModified(); + resource.lastModified = modified == null ? 0 : modified.getTime(); + resources.add(resource); } return resources; } - private static boolean isSuccess(Element status) { - return status == null || SUCCESS_STATUS.matcher(status.getTextContent().trim()).find(); + private static boolean isSuccess(String status) { + return status == null || SUCCESS_STATUS.matcher(status.trim()).find(); } /** @@ -599,63 +527,17 @@ private static int selfIndex(List resources, String dirPath) { return -1; } - private static DocumentBuilder newDocumentBuilder() throws Exception { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - return factory.newDocumentBuilder(); - } - - private static Element child(Element parent, String localName) { - List children = children(parent, localName); - return children.isEmpty() ? null : children.get(0); - } - - private static List children(Element parent, String localName) { - List children = new ArrayList(); - for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { - if (node.getNodeType() == Node.ELEMENT_NODE && DAV.equals(node.getNamespaceURI()) && localName.equals(node.getLocalName())) { - children.add((Element) node); - } - } - return children; - } - - /** Decodes an href, which may be an absolute URL or an absolute path */ - private static String hrefPath(String href) { - int schemeEnd = href.indexOf("://"); - if (schemeEnd >= 0) { - int pathStart = href.indexOf('/', schemeEnd + 3); - href = pathStart < 0 ? "/" : href.substring(pathStart); - } + private void createDirectory(String path) throws IOException { try { - // Paths don't use form encoding, so keep '+' as is - return URLDecoder.decode(href.replace("+", "%2B"), "UTF-8"); - } catch (Exception e) { - return href; - } - } - - private boolean execute(HttpUriRequest request) throws IOException { - try (CloseableHttpResponse response = client.execute(request, context)) { - EntityUtils.consume(response.getEntity()); - return isSuccess(response); + client.createDirectory(url(path)); + } catch (HttpResponseException e) { + logger.debug(e); } } - private static boolean isSuccess(HttpResponse response) { - int statusCode = response.getStatusLine().getStatusCode(); - return statusCode >= 200 && statusCode < 300; - } - - private URI uri(String path) throws IOException { + private String url(String path) throws IOException { try { - return new URIBuilder(baseUrl).setPath(path).build(); + return new URIBuilder(baseUrl).setPath(path).build().toASCIIString(); } catch (URISyntaxException e) { throw new IOException(e); } diff --git a/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml b/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml index 51f4d993df..6abd9fb84a 100644 --- a/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml +++ b/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml @@ -12,6 +12,7 @@ + file diff --git a/server/src/main/resources/com/mirth/connect/connectors/file/source.xml b/server/src/main/resources/com/mirth/connect/connectors/file/source.xml index 4c26f1bfd3..939eb50966 100644 --- a/server/src/main/resources/com/mirth/connect/connectors/file/source.xml +++ b/server/src/main/resources/com/mirth/connect/connectors/file/source.xml @@ -13,6 +13,7 @@ + file diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index d1da3d1657..e65852be17 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -137,6 +137,7 @@ public void listDirectories() throws Exception { List directories = connection.listDirectories("/in"); Collections.sort(directories); assertEquals(List.of("/in/sub", "/in/sub2"), directories); + assertTrue(connection.listDirectories("/").contains("/in")); } @Test From 097e2e6b4e679b1cac2e26984444ef6a3e41c51a Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Fri, 30 Oct 2026 17:55:54 +0100 Subject: [PATCH 08/10] Use plain Sardine for WebDAV and drop the test server Review asked to stop extending Sardine and to drop the embedded WebDAV server: - canWrite() no longer checks for write locks. Checking first is racy anyway, and a failed write is logged when it happens. - PUT uses Sardine's own streaming put with Expect: 100-continue, so an authentication challenge normally arrives before the body. Over TLS, Basic credentials are also sent preemptively, so the body never has to be resent after a Basic challenge. Plain HTTP keeps negotiating, so a Digest server never sees the password in cleartext. - The Jetty WebDAV test server is replaced by a unit test that mocks the Sardine interface, like the other file system tests. Signed-off-by: Nico Piel --- .../file/filesystems/WebDavConnection.java | 229 ++----- .../filesystems/WebDavConnectionTest.java | 568 +++--------------- .../file/filesystems/WebDavTestServer.java | 402 ------------- 3 files changed, 151 insertions(+), 1048 deletions(-) delete mode 100644 server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index 76ab7fe645..da530e656b 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -9,19 +9,15 @@ package com.mirth.connect.connectors.file.filesystems; -import java.io.ByteArrayInputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.Date; +import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.regex.Pattern; import org.apache.commons.io.filefilter.WildcardFileFilter; import org.apache.commons.logging.Log; @@ -36,23 +32,15 @@ import org.apache.http.client.config.RequestConfig; import org.apache.http.client.utils.URIBuilder; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.InputStreamEntity; -import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; -import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.ssl.SSLContexts; import com.github.sardine.DavResource; +import com.github.sardine.Sardine; import com.github.sardine.impl.SardineImpl; import com.github.sardine.impl.SardineRedirectStrategy; -import com.github.sardine.impl.handler.MultiStatusResponseHandler; -import com.github.sardine.impl.methods.HttpPropFind; -import com.github.sardine.model.Activelock; -import com.github.sardine.model.Prop; -import com.github.sardine.model.Propstat; -import com.github.sardine.model.Response; + import com.mirth.connect.connectors.file.FileSystemConnectionOptions; import com.mirth.connect.connectors.file.filters.RegexFilenameFilter; @@ -116,80 +104,40 @@ public boolean isReadable() { public void populateSourceMap(Map sourceMap) {} } - /** One response element of a PROPFIND multistatus */ - private static class Resource { - private String path; - private boolean collection; - private long length; - private long lastModified; - private boolean locked; - } - - /** Sardine leaves lockdiscovery out of DavResource, so keep the raw responses */ - private static class Client extends SardineImpl { - private static final String PROPFIND = "" + "" + "" + ""; - - Client(HttpClientBuilder builder) { - super(builder); - } - - List propfind(String url, int depth) throws IOException { - HttpPropFind propfind = new HttpPropFind(url); - propfind.setDepth(String.valueOf(depth)); - propfind.setEntity(new StringEntity(PROPFIND, ContentType.create("application/xml", StandardCharsets.UTF_8))); - return execute(propfind, new MultiStatusResponseHandler()).getResponse(); - } - } - - /** - * Streams a ByteArrayInputStream, which is what FileDispatcher passes, and resends it by - * resetting the stream. The PUT can then be retried after an authentication challenge without - * copying the payload, even when a proxy has already read the body. - */ - private static class ResettableEntity extends InputStreamEntity { - private final ByteArrayInputStream in; - - ResettableEntity(ByteArrayInputStream in, long length) { - super(in, length, ContentType.APPLICATION_OCTET_STREAM); - this.in = in; - in.mark(0); - } - - @Override - public boolean isRepeatable() { - return true; - } - - @Override - public void writeTo(OutputStream out) throws IOException { - in.reset(); - super.writeTo(out); - } - } - - private static final Pattern SUCCESS_STATUS = Pattern.compile("^HTTP/\\S+\\s+2\\d\\d"); - private static transient Log logger = LogFactory.getLog(WebDavConnection.class); /** The WebDAV client instance */ - private Client client = null; + private Sardine client = null; private String baseUrl; private InputStream readStream = null; /** Whether the last path looked up exists, as WebdavResource.exists() reported it */ private boolean exists = false; - /** Status line of the last failed PROPFIND */ + /** Status of the last failed PROPFIND */ private String lastStatus = null; public WebDavConnection(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) throws Exception { + this(createClient(host, port, secure, fileSystemOptions, timeout), new HttpHost(host, port, secure ? "https" : "http").toURI()); + } + + WebDavConnection(Sardine client, String baseUrl) { + this.client = client; + this.baseUrl = baseUrl; + + try { + stat("/"); + } catch (IOException e) { + logger.debug(e); + } + } + + private static Sardine createClient(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) { String username = fileSystemOptions.getUsername(); String password = fileSystemOptions.getPassword(); - - baseUrl = new HttpHost(host, port, secure ? "https" : "http").toURI(); + int authPort = port > 0 ? port : (secure ? 443 : 80); CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); if (!username.equals("null")) { // Scope credentials to this port so a redirect elsewhere doesn't get them - int authPort = port > 0 ? port : (secure ? 443 : 80); credentialsProvider.setCredentials(new AuthScope(host, authPort), new UsernamePasswordCredentials(username, password)); // NTLM needs the domain split out of DOMAIN\\user @@ -201,13 +149,13 @@ public WebDavConnection(String host, int port, boolean secure, FileSystemConnect // The schemes httpclient 3 negotiated, in its order of preference RequestConfig requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).setExpectContinueEnabled(false).build(); - client = new Client(HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setRedirectStrategy(new SardineRedirectStrategy()).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault()))); - - try { - stat("/"); - } catch (IOException e) { - logger.debug(e); + SardineImpl sardine = new SardineImpl(HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setRedirectStrategy(new SardineRedirectStrategy()).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault()))); + if (secure && !username.equals("null")) { + // Send Basic up front so a streamed PUT doesn't have to be resent. Only over TLS, so a + // server that would have negotiated Digest never sees the password in cleartext. + sardine.enablePreemptiveAuthentication(host, authPort, authPort); } + return sardine; } @Override @@ -234,7 +182,7 @@ public List listDirectories(String fromDir) throws Exception { private List list(String fromDir, boolean files, FilenameFilter filenameFilter, boolean ignoreDot) throws Exception { String dirPath = normalize(getFullPath(fromDir, "")); - List resources = propfind(fromDir.isEmpty() ? "/" : fromDir, 1); + List resources = propfind(fromDir.isEmpty() ? "/" : fromDir, 1); if (resources == null) { throw new IOException("Unable to list directory: '" + fromDir + "': " + lastStatus); } @@ -244,15 +192,16 @@ private List list(String fromDir, boolean files, FilenameFilter filena List fileInfoList = new ArrayList(resources.size()); for (int i = 0; i < resources.size(); i++) { - if (i == self) { + DavResource resource = resources.get(i); + // Skip the directory itself and entries without properties, e.g. 403 + if (i == self || resource.getStatusCode() < 200 || resource.getStatusCode() >= 300) { continue; } - Resource resource = resources.get(i); - String path = normalize(resource.path); - - String name = path.substring(path.lastIndexOf('/') + 1); - WebDavFileInfo file = new WebDavFileInfo(fromDir, name, url(getFullPath(fromDir, name)), resource.collection, resource.length, resource.lastModified); + String name = resource.getName(); + long size = Math.max(resource.getContentLength(), 0); + long lastModified = resource.getModified() == null ? 0 : resource.getModified().getTime(); + WebDavFileInfo file = new WebDavFileInfo(fromDir, name, url(getFullPath(fromDir, name)), resource.isDirectory(), size, lastModified); if (files) { if (file.isFile() && filenameFilter.accept(null, file.getName()) && !(ignoreDot && file.getName().startsWith("."))) { @@ -279,8 +228,8 @@ public boolean exists(String file, String path) { public InputStream readFile(String file, String fromDir, Map sourceMap) throws Exception { String fullPath = getFullPath(fromDir, file); - Resource resource = stat(fullPath); - if (resource != null && resource.collection) { + DavResource resource = stat(fullPath); + if (resource != null && resource.isDirectory()) { logger.error("Invalid filepath: " + fullPath); throw new Exception("Invalid Path"); } @@ -312,25 +261,19 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, String fullPath = getFullPath(toDir, file); // first check if the toDir exists. - Resource dir = stat(toDir); + DavResource dir = stat(toDir); if (dir == null) { // create the directory. createDirectory(toDir); logger.info("Destination directory does not exist. Creating directory: '" + toDir + "'"); - } else if (!dir.collection) { + } else if (!dir.isDirectory()) { // make sure it's a directory, not a file. throw new Exception("The destination directory path is invalid: '" + toDir + "'"); } try { - if (is instanceof ByteArrayInputStream) { - client.put(url(fullPath), new ResettableEntity((ByteArrayInputStream) is, contentLength), null, false); - } else { - // Other streams can't be resent. Expect: 100-continue usually gets an authentication - // challenge before the body, but a buffering proxy may still consume it first. - client.put(url(fullPath), new InputStreamEntity(is, contentLength, ContentType.APPLICATION_OCTET_STREAM), null, true); - } + client.put(url(fullPath), is, "application/octet-stream", true, contentLength); } catch (HttpResponseException e) { logger.error("Unable to write file: '" + fullPath, e); } @@ -355,13 +298,13 @@ public void move(String fromName, String fromDir, String toName, String toDir) t String targetPath = getFullPath(toDir, toName); // first check if the toDir exists. - Resource dir = stat(toDir); + DavResource dir = stat(toDir); if (dir == null) { // create the directory. and then move. createDirectory(toDir); logger.info("Move-To directory does not exist. Creating directory: '" + toDir + "'"); - } else if (!dir.collection) { + } else if (!dir.isDirectory()) { // make sure it's a directory, not a file. throw new Exception("The move-to directory path is invalid: '" + toDir + "'"); } @@ -411,20 +354,19 @@ public boolean isValid() { @Override public boolean canRead(String readDir) { - try { - Resource dir = stat(readDir); - return dir != null && dir.collection; - } catch (IOException e) { - logger.debug(e); - return false; - } + return isDirectory(readDir); } @Override public boolean canWrite(String writeDir) { + // A failed write is reported when it happens + return isDirectory(writeDir); + } + + private boolean isDirectory(String path) { try { - Resource dir = stat(writeDir); - return dir != null && dir.collection && !dir.locked; + DavResource dir = stat(path); + return dir != null && dir.isDirectory(); } catch (IOException e) { logger.debug(e); return false; @@ -432,92 +374,41 @@ public boolean canWrite(String writeDir) { } /** Returns the resource at path, or null if it can't be found */ - private Resource stat(String path) throws IOException { - List resources = propfind(path, 0); + private DavResource stat(String path) throws IOException { + List resources = propfind(path, 0); return resources == null || resources.isEmpty() ? null : resources.get(0); } - /** Returns the resources of a PROPFIND multistatus, or null if the request failed */ - private List propfind(String path, int depth) throws IOException { - List responses; + /** Returns the resources of a PROPFIND, or null if the request failed */ + private List propfind(String path, int depth) throws IOException { try { - responses = client.propfind(url(path), depth); + List resources = client.list(url(path), depth, Collections.emptySet()); exists = true; + return resources; } catch (HttpResponseException e) { exists = false; lastStatus = e.getMessage(); return null; } - - List resources = new ArrayList(); - for (Response response : responses) { - DavResource davResource; - try { - davResource = new DavResource(response); - } catch (URISyntaxException e) { - continue; - } - - // A response-level status means there are no properties, e.g. 403 - int status = davResource.getStatusCode(); - if (status < 200 || status >= 300) { - continue; - } - - // Only count responses with a 2xx propstat, or none at all - boolean found = response.getStatus() != null; - Resource resource = new Resource(); - for (Propstat propstat : response.getPropstat()) { - Prop prop = propstat.getProp(); - if (prop == null || !isSuccess(propstat.getStatus())) { - continue; - } - found = true; - - if (prop.getLockdiscovery() != null) { - for (Activelock activeLock : prop.getLockdiscovery().getActivelock()) { - if (activeLock.getLocktype() != null && activeLock.getLocktype().getWrite() != null) { - resource.locked = true; - } - } - } - } - if (!found) { - continue; - } - - resource.path = davResource.getPath(); - resource.collection = davResource.isDirectory(); - resource.length = Math.max(davResource.getContentLength(), 0); - Date modified = davResource.getModified(); - resource.lastModified = modified == null ? 0 : modified.getTime(); - resources.add(resource); - } - - return resources; - } - - private static boolean isSuccess(String status) { - return status == null || SUCCESS_STATUS.matcher(status.trim()).find(); } /** * Finds the requested directory's own entry. Behind a rewriting proxy its href carries a * different prefix, so fall back to the entry every other entry lives under. */ - private static int selfIndex(List resources, String dirPath) { + private static int selfIndex(List resources, String dirPath) { for (int i = 0; i < resources.size(); i++) { - if (normalize(resources.get(i).path).equalsIgnoreCase(dirPath)) { + if (normalize(resources.get(i).getPath()).equalsIgnoreCase(dirPath)) { return i; } } for (int i = 0; i < resources.size(); i++) { - String path = normalize(resources.get(i).path); + String path = normalize(resources.get(i).getPath()); String prefix = path.endsWith("/") ? path : path + "/"; boolean parent = true; for (int j = 0; j < resources.size() && parent; j++) { - parent = j == i || normalize(resources.get(j).path).startsWith(prefix); + parent = j == i || normalize(resources.get(j).getPath()).startsWith(prefix); } if (parent) { return i; diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index e65852be17..4802af8bcb 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -4,173 +4,104 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; -import java.io.File; import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.attribute.FileTime; import java.util.ArrayList; -import java.util.Collections; +import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.List; -import javax.net.ssl.SSLContext; - -import org.apache.commons.io.IOUtils; -import org.junit.After; import org.junit.Before; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import com.mirth.connect.connectors.file.FileScheme; +import com.github.sardine.DavResource; +import com.github.sardine.Sardine; +import com.github.sardine.impl.SardineException; import com.mirth.connect.connectors.file.FileSystemConnectionOptions; -import com.mirth.connect.connectors.file.filesystems.WebDavTestServer.Auth; public class WebDavConnectionTest { - private static final long MODIFIED = 1577934245000L; // 2020-01-02T03:04:05Z - - @Rule - public TemporaryFolder tmp = new TemporaryFolder(); + private static final String BASE = "http://dav.example:8080"; - private WebDavTestServer server; + private Sardine sardine; private WebDavConnection connection; @Before public void setUp() throws Exception { - File root = tmp.newFolder("dav"); - write(root, "in/a.txt", "alpha"); - write(root, "in/b.txt", "bravo!"); - write(root, "in/c.hl7", "MSH|"); - write(root, "in/.hidden.txt", "x"); - write(root, "in/sub/d.txt", "delta"); - WebDavTestServer.file(root, "in/sub2").mkdirs(); - WebDavTestServer.file(root, "out").mkdirs(); - server = new WebDavTestServer(root); - } - - @After - public void tearDown() throws Exception { - if (connection != null) { - connection.destroy(); - } - server.stop(); + sardine = mock(Sardine.class); + // Anything not set up below doesn't exist + when(sardine.list(anyString(), anyInt(), any())).thenThrow(new SardineException("Unexpected response", 404, "Not Found")); + propfind("/", 0, dir("/")); + connection = new WebDavConnection(sardine, BASE); } - private WebDavConnection connect(Auth auth, String username, String password) throws Exception { - server.start(auth, false); - return open(false, username, password, 5000); - } + @Test + public void listFiles() throws Exception { + DavResource forbidden = file("/in/forbidden.txt", 0); + when(forbidden.getStatusCode()).thenReturn(403); + propfind("/in", 1, dir("/in/"), file("/in/a.txt", 5), file("/in/b.hl7", 6), file("/in/.hidden.txt", 1), dir("/in/sub/"), forbidden); - /** Goes through the factory so the connector's host/port/timeout handling is covered */ - private WebDavConnection open(boolean secure, String username, String password, int timeout) throws Exception { - FileSystemConnectionFactory factory = new FileSystemConnectionFactory(FileScheme.WEBDAV, new FileSystemConnectionOptions(false, username, password, null), "localhost", server.port(), false, secure, timeout); - connection = (WebDavConnection) factory.makeObject().getObject(); - return connection; - } + List files = connection.listFiles("/in", "*.txt", false, true); + assertEquals(Arrays.asList("a.txt"), names(files)); + FileInfo a = files.get(0); + assertEquals(5, a.getSize()); + assertEquals(1000, a.getLastModified()); + assertEquals("/in", a.getParent()); + assertEquals(BASE + "/in/a.txt", a.getAbsolutePath()); - private WebDavConnection connect() throws Exception { - return connect(Auth.NONE, "null", "null"); + assertEquals(Arrays.asList("a.txt", "b.hl7"), names(connection.listFiles("/in", "^[ab]\\..*", true, false))); + assertEquals(Arrays.asList("/in/sub"), connection.listDirectories("/in")); } @Test - public void listFilesWithWildcard() throws Exception { - connect(); - assertEquals(List.of("a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, true))); - assertEquals(List.of(".hidden.txt", "a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, false))); - assertEquals(List.of("a.txt", "b.txt", "c.hl7"), names(connection.listFiles("/in", " *.txt , *.hl7 ", false, true))); + public void listRoot() throws Exception { + propfind("/", 1, dir("/"), dir("/in/")); + assertEquals(Arrays.asList("/in"), connection.listDirectories("/")); } @Test - public void listFilesWithRegex() throws Exception { - connect(); - assertEquals(List.of("a.txt", "c.hl7"), names(connection.listFiles("/in", "[ac]\\..*", true, true))); - } - - @Test - public void listFilesExcludesDirectories() throws Exception { - connect(); - assertEquals(List.of(".hidden.txt", "a.txt", "b.txt", "c.hl7"), names(connection.listFiles("/in", "*", false, false))); + public void selfEntryBehindRewritingProxy() throws Exception { + propfind("/in", 1, dir("/dav/in/"), file("/dav/in/a.txt", 1), dir("/dav/in/sub/")); + assertEquals(Arrays.asList("a.txt"), names(connection.listFiles("/in", "*", false, false))); + assertEquals(Arrays.asList("/in/sub"), connection.listDirectories("/in")); } @Test - public void listFilesOfEmptyOrMissingDirectory() throws Exception { - connect(); - assertTrue(connection.listFiles("/in/sub2", "*", false, false).isEmpty()); + public void listingErrorIncludesStatus() throws Exception { try { connection.listFiles("/missing", "*", false, false); fail(); } catch (Exception e) { - // expected + assertTrue(e.getMessage(), e.getMessage().contains("404")); } } @Test - public void fileInfoAttributes() throws Exception { - connect(); - FileInfo info = connection.listFiles("/in", "b.txt", false, true).get(0); - assertEquals("b.txt", info.getName()); - assertEquals("/in", info.getParent()); - assertEquals(6, info.getSize()); - assertEquals(MODIFIED, info.getLastModified()); - assertTrue(info.isFile()); - assertFalse(info.isDirectory()); - assertTrue(info.isReadable()); - assertEquals("http://" + server.authority() + "/in/b.txt", info.getAbsolutePath()); - assertEquals("http://" + server.authority() + "/in/b.txt", info.getCanonicalPath()); - } - - @Test - public void listFilesInRoot() throws Exception { - connect(); - write(server.root, "top.txt", "top"); - assertEquals(List.of("top.txt"), names(connection.listFiles("/", "*.txt", false, true))); - assertEquals(List.of("top.txt"), names(connection.listFiles("", "*.txt", false, true))); - } - - @Test - public void listDirectories() throws Exception { - connect(); - List directories = connection.listDirectories("/in"); - Collections.sort(directories); - assertEquals(List.of("/in/sub", "/in/sub2"), directories); - assertTrue(connection.listDirectories("/").contains("/in")); - } - - @Test - public void listDirectoriesRoundTrip() throws Exception { - connect(); - // FileReceiver feeds listDirectories() results straight back into listFiles() - String sub = connection.listDirectories("/in").stream().filter(d -> d.endsWith("/sub")).findFirst().get(); - List files = connection.listFiles(sub, "*", false, false); - assertEquals(List.of("d.txt"), names(files)); - assertEquals("/in/sub", files.get(0).getParent()); - assertEquals("delta", read(connection.readFile(files.get(0).getName(), files.get(0).getParent(), new HashMap<>()))); - } - - @Test - public void exists() throws Exception { - connect(); + public void existsAndCanRead() throws Exception { + propfind("/in", 0, dir("/in/")); + propfind("/in/a.txt", 0, file("/in/a.txt", 1)); assertTrue(connection.exists("a.txt", "/in")); - assertTrue(connection.exists("sub", "/in")); - assertTrue(connection.exists("a.txt", "in/")); - assertFalse(connection.exists("nope.txt", "/in")); - assertFalse(connection.exists("a.txt", "/missing")); + assertFalse(connection.exists("b.txt", "/in")); + assertTrue(connection.canRead("/in")); + assertTrue(connection.canWrite("/in")); + assertFalse(connection.canRead("/in/a.txt")); + assertFalse(connection.canWrite("/missing")); } @Test public void readFile() throws Exception { - connect(); - assertEquals("alpha", read(connection.readFile("a.txt", "/in", new HashMap<>()))); - connection.closeReadFile(); - } - - @Test - public void readCollectionFails() throws Exception { - connect(); + propfind("/in/sub", 0, dir("/in/sub/")); try { connection.readFile("sub", "/in", new HashMap<>()); fail(); @@ -179,388 +110,72 @@ public void readCollectionFails() throws Exception { } } - @Test(expected = Exception.class) - public void readMissingFileFails() throws Exception { - connect(); - connection.readFile("nope.txt", "/in", new HashMap<>()); - } - - @Test - public void writeFileToExistingDirectory() throws Exception { - connect(); - assertFalse(connection.canAppend()); - writeFile("new.txt", "/out", "hello"); - assertEquals("hello", content("out/new.txt")); - assertFalse(server.requests().stream().anyMatch(r -> r.startsWith("MKCOL"))); - - writeFile("new.txt", "/out", "replaced"); - assertEquals("replaced", content("out/new.txt")); - } - @Test public void writeFileCreatesMissingDirectory() throws Exception { - connect(); - writeFile("new.txt", "/created", "hello"); - assertEquals("hello", content("created/new.txt")); - assertTrue(server.requests().contains("MKCOL /created")); + InputStream in = new ByteArrayInputStream(new byte[3]); + connection.writeFile("a.txt", "/out", false, in, 3, new HashMap<>()); + verify(sardine).createDirectory(BASE + "/out"); + verify(sardine).put(BASE + "/out/a.txt", in, "application/octet-stream", true, 3); } @Test public void writeFileIntoFileFails() throws Exception { - connect(); + propfind("/out", 0, file("/out", 1)); try { - writeFile("new.txt", "/in/a.txt", "hello"); + connection.writeFile("a.txt", "/out", false, new ByteArrayInputStream(new byte[0]), 0, new HashMap<>()); fail(); } catch (Exception e) { - assertTrue(e.getMessage(), e.getMessage().startsWith("The destination directory path is invalid: '/in/a.txt'")); + assertEquals("The destination directory path is invalid: '/out'", e.getMessage()); } - } - - @Test - public void writeFileUnknownLength() throws Exception { - connect(); - byte[] bytes = "streamed".getBytes(StandardCharsets.UTF_8); - connection.writeFile("s.txt", "/out", false, new ByteArrayInputStream(bytes), -1, new HashMap<>()); - assertEquals("streamed", content("out/s.txt")); - } - - @Test - public void delete() throws Exception { - connect(); - connection.delete("a.txt", "/in", false); - assertFalse(WebDavTestServer.file(server.root, "in/a.txt").exists()); - - // A failed delete is only logged - connection.delete("a.txt", "/in", false); - connection.delete("a.txt", "/in", true); - } - - @Test - public void moveToExistingDirectory() throws Exception { - connect(); - connection.move("a.txt", "/in", "moved.txt", "/out"); - assertFalse(WebDavTestServer.file(server.root, "in/a.txt").exists()); - assertEquals("alpha", content("out/moved.txt")); - } - - @Test - public void moveCreatesMissingDirectory() throws Exception { - connect(); - connection.move("a.txt", "/in", "moved.txt", "/archive"); - assertTrue(server.requests().contains("MKCOL /archive")); - assertEquals("alpha", content("archive/moved.txt")); - } - - @Test - public void moveIntoFileFails() throws Exception { - connect(); - try { - connection.move("a.txt", "/in", "moved.txt", "/in/b.txt"); - fail(); - } catch (Exception e) { - assertTrue(e.getMessage(), e.getMessage().startsWith("The move-to directory path is invalid: '/in/b.txt'")); - } - assertTrue(WebDavTestServer.file(server.root, "in/a.txt").exists()); + verify(sardine, never()).put(anyString(), any(InputStream.class), anyString(), eq(true), anyLong()); } @Test public void moveDoesNotOverwrite() throws Exception { - connect(); - connection.move("a.txt", "/in", "b.txt", "/in"); - assertEquals("alpha", content("in/a.txt")); - assertEquals("bravo!", content("in/b.txt")); - assertEquals(List.of("http://" + server.authority() + "/in/b.txt"), server.destinations); - } - - @Test - public void moveMissingFileIsOnlyLogged() throws Exception { - connect(); - connection.move("nope.txt", "/in", "moved.txt", "/out"); - assertFalse(WebDavTestServer.file(server.root, "out/moved.txt").exists()); - } - - @Test - public void canReadAndCanWrite() throws Exception { - connect(); - assertTrue(connection.canRead("/in")); - assertTrue(connection.canWrite("/in")); - assertFalse(connection.canRead("/in/a.txt")); - assertFalse(connection.canWrite("/in/a.txt")); - assertFalse(connection.canRead("/missing")); - assertFalse(connection.canWrite("/missing")); - - server.lockedPaths.add("/out"); - assertTrue(connection.canRead("/out")); - assertFalse(connection.canWrite("/out")); - } - - @Test - public void externalEntitiesAreNotResolved() throws Exception { - connect(); - File secret = tmp.newFile("secret.txt"); - Files.write(secret.toPath(), "leaked".getBytes(StandardCharsets.UTF_8)); - server.propfindOverride = "]>" - + "/in/&x;" - + "HTTP/1.1 200 OK"; - try { - List files = connection.listFiles("/in", "*", false, false); - assertFalse(names(files).contains("leaked")); - } catch (Exception e) { - // rejecting the document outright is fine too - } - } - - @Test - public void putIsRetriedWhenOnlyWritesAreChallenged() throws Exception { - connect(Auth.BASIC_WRITES, WebDavTestServer.USER, WebDavTestServer.PASSWORD); - // The PROPFIND before the PUT succeeds anonymously, so the PUT itself gets the 401 - writeFile("challenged.txt", "/out", "body"); - assertEquals("body", content("out/challenged.txt")); - byte[] bytes = "streamed".getBytes(StandardCharsets.UTF_8); - connection.writeFile("chunked.txt", "/out", false, new ByteArrayInputStream(bytes), -1, new HashMap<>()); - assertEquals("streamed", content("out/chunked.txt")); - connection.move("challenged.txt", "/out", "moved.txt", "/out"); - assertEquals("body", content("out/moved.txt")); - // A ByteArrayInputStream is resent by resetting it, so 100-continue isn't needed - assertTrue(server.putExpectHeaders.stream().noneMatch("100-continue"::equalsIgnoreCase)); - } - - @Test - public void putIsRetriedWhenTheBodyIsConsumedBeforeTheChallenge() throws Exception { - connect(Auth.BASIC_WRITES, WebDavTestServer.USER, WebDavTestServer.PASSWORD); - server.consumeUnauthenticatedBodies = true; - // FileDispatcher hands over a ByteArrayInputStream - writeFile("challenged.txt", "/out", "body"); - assertEquals("body", content("out/challenged.txt")); - } - - @Test - public void nonResettableStreamUsesExpectContinue() throws Exception { - connect(Auth.BASIC_WRITES, WebDavTestServer.USER, WebDavTestServer.PASSWORD); - byte[] bytes = "streamed".getBytes(StandardCharsets.UTF_8); - InputStream in = new java.io.FilterInputStream(new ByteArrayInputStream(bytes)) { - @Override - public boolean markSupported() { - return false; - } - }; - connection.writeFile("stream.txt", "/out", false, in, bytes.length, new HashMap<>()); - assertEquals("streamed", content("out/stream.txt")); - assertTrue(server.putExpectHeaders.stream().allMatch("100-continue"::equalsIgnoreCase)); - } - - @Test - public void prettyPrintedMultistatus() throws Exception { - connect(); - server.prettyPrint = true; - FileInfo info = connection.listFiles("/in", "b.txt", false, true).get(0); - assertEquals(6, info.getSize()); - assertEquals(MODIFIED, info.getLastModified()); - assertEquals(List.of(".hidden.txt", "a.txt", "b.txt", "c.hl7"), names(connection.listFiles("/in", "*", false, false))); - assertEquals(2, connection.listDirectories("/in").size()); - assertTrue(connection.canWrite("/in")); - assertFalse(connection.canRead("/in/a.txt")); - } - - @Test - public void failedResponsesAreSkipped() throws Exception { - connect(); - server.propfindOverride = multistatus( - "/in/" + propstat("", "200 OK") + "", - "/in/ok.txt" + propstat("3", "200 OK") + "", - "/in/forbidden.txtHTTP/1.1 403 Forbidden", - "/in/gone.txt" + propstat("", "404 Not Found") + ""); - List files = connection.listFiles("/in", "*", false, false); - assertEquals(List.of("ok.txt"), names(files)); - assertEquals(3, files.get(0).getSize()); - } - - @Test - public void selfEntryBehindRewritingProxy() throws Exception { - connect(); - server.propfindOverride = multistatus( - "https://proxy.example/dav/in/" + propstat("", "200 OK") + "", - "https://proxy.example/dav/in/a.txt" + propstat("", "200 OK") + "", - "https://proxy.example/dav/in/sub/" + propstat("", "200 OK") + ""); - assertEquals(List.of("a.txt"), names(connection.listFiles("/in", "*", false, false))); - assertEquals(List.of("/in/sub"), connection.listDirectories("/in")); - - // An empty directory behind the proxy only lists itself - server.propfindOverride = multistatus("/dav/in/" + propstat("", "200 OK") + ""); - assertEquals(List.of(), connection.listDirectories("/in")); - } - - @Test - public void listingErrorIncludesStatus() throws Exception { - connect(Auth.BASIC, WebDavTestServer.USER, "wrong"); - try { - connection.listFiles("/in", "*", false, false); - fail(); - } catch (Exception e) { - assertTrue(e.getMessage(), e.getMessage().contains("401")); - } - } - - @Test - public void timeout() throws Exception { - server.start(Auth.NONE, false); - open(false, "null", "null", 200); - server.delayMillis = 2000; - long start = System.currentTimeMillis(); - try { - connection.listFiles("/in", "*", false, false); - fail(); - } catch (java.net.SocketTimeoutException e) { - // expected - } - assertTrue(System.currentTimeMillis() - start < 1500); + propfind("/done", 0, dir("/done/")); + connection.move("a.txt", "/in", "a.txt", "/done"); + verify(sardine, never()).createDirectory(anyString()); + verify(sardine).move(BASE + "/in/a.txt", BASE + "/done/a.txt", false); } @Test - public void ipv6LiteralHost() throws Exception { - connection = new WebDavConnection("[::1]", 1, false, new FileSystemConnectionOptions(false, "null", "null", null), 1000); - assertFalse(connection.exists("a.txt", "/in")); + public void pathsAreEncoded() throws Exception { + connection.delete("a b#?.txt", "/in", false); + verify(sardine).delete(BASE + "/in/a%20b%23%3F.txt"); } @Test - public void collectionWithoutTrailingSlashIsRedirected() throws Exception { - connect(Auth.BASIC, WebDavTestServer.USER, WebDavTestServer.PASSWORD); - server.slashRedirect = true; - assertTrue(connection.canRead("/in")); - assertTrue(connection.canWrite("/in")); - assertTrue(connection.exists("sub", "/in")); - assertEquals(List.of("a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, true))); - assertEquals(List.of("d.txt"), names(connection.listFiles(connection.listDirectories("/in").get(0), "*", false, false))); - writeFile("redirected.txt", "/out", "ok"); - assertEquals("ok", content("out/redirected.txt")); - connection.move("redirected.txt", "/out", "moved.txt", "/in/sub"); - assertEquals("ok", content("in/sub/moved.txt")); - assertTrue(server.requests().contains("PROPFIND /in/")); - } - - @Test - public void connectedAndValid() throws Exception { - connect(); - assertTrue(connection.isConnected()); - assertTrue(connection.isValid()); + public void unreachableServerIsInvalid() throws Exception { + WebDavConnection unreachable = new WebDavConnection("localhost", 1, false, new FileSystemConnectionOptions(false, "null", "null", null), 1000); + assertFalse(unreachable.isValid()); + assertFalse(unreachable.exists("a.txt", "/in")); } - @Test - public void nullUsernameSendsNoCredentials() throws Exception { - connect(Auth.NONE, "null", "null"); - connection.listFiles("/in", "*", false, false); - connection.readFile("a.txt", "/in", new HashMap<>()).close(); - assertTrue(server.authorizations.stream().allMatch(String::isEmpty)); + private void propfind(String path, int depth, DavResource... resources) throws Exception { + when(sardine.list(eq(BASE + path), eq(depth), any())).thenReturn(Arrays.asList(resources)); } - @Test - public void basicAuth() throws Exception { - connect(Auth.BASIC, WebDavTestServer.USER, WebDavTestServer.PASSWORD); - exerciseAuthenticated(); - assertTrue(server.authorizations.contains("Basic")); - } - - @Test - public void digestAuth() throws Exception { - connect(Auth.DIGEST, WebDavTestServer.USER, WebDavTestServer.PASSWORD); - exerciseAuthenticated(); - assertTrue(server.authorizations.contains("Digest")); + private static DavResource dir(String path) { + DavResource resource = resource(path); + when(resource.isDirectory()).thenReturn(true); + when(resource.getContentLength()).thenReturn(-1L); + return resource; } - @Test - public void wrongPassword() throws Exception { - connect(Auth.BASIC, WebDavTestServer.USER, "wrong"); - assertFalse(connection.isValid()); - assertFalse(connection.exists("a.txt", "/in")); - assertFalse(connection.canRead("/in")); - assertTrue(server.requests().isEmpty()); - } - - @Test - public void https() throws Exception { - server.start(Auth.BASIC, true); - SSLContext previous = SSLContext.getDefault(); - SSLContext.setDefault(server.clientSslContext()); - try { - open(true, WebDavTestServer.USER, WebDavTestServer.PASSWORD, 5000); - FileInfo info = connection.listFiles("/in", "a.txt", false, true).get(0); - assertEquals("https://" + server.authority() + "/in/a.txt", info.getCanonicalPath()); - assertEquals("alpha", read(connection.readFile("a.txt", "/in", new HashMap<>()))); - writeFile("tls.txt", "/out", "secure"); - assertEquals("secure", content("out/tls.txt")); - } finally { - SSLContext.setDefault(previous); - } - } - - @Test - public void pathEncoding() throws Exception { - connect(); - String dir = "/spa ce/ü+ß"; - String name = "a b ü+%.txt"; - WebDavTestServer.file(server.root, dir).mkdirs(); - write(server.root, dir + "/" + name, "encoded"); - - assertTrue(connection.exists(name, dir)); - assertTrue(connection.canWrite(dir)); - List files = connection.listFiles(dir, "*.txt", false, true); - assertEquals(List.of(name), names(files)); - assertEquals(dir, files.get(0).getParent()); - assertEquals(7, files.get(0).getSize()); - assertEquals("encoded", read(connection.readFile(name, dir, new HashMap<>()))); - - writeFile("n ü.txt", "/new dir ü", "made"); - assertEquals("made", content("new dir ü/n ü.txt")); - - connection.move(name, dir, "m ü.txt", "/new dir ü"); - assertEquals("encoded", content("new dir ü/m ü.txt")); - - connection.delete("m ü.txt", "/new dir ü", false); - assertFalse(WebDavTestServer.file(server.root, "new dir ü/m ü.txt").exists()); - - String sub = connection.listDirectories("/spa ce").get(0); - assertEquals(List.of(), names(connection.listFiles(sub, "*", false, false))); - } - - private void exerciseAuthenticated() throws Exception { - assertTrue(connection.isValid()); - assertEquals(List.of("a.txt", "b.txt"), names(connection.listFiles("/in", "*.txt", false, true))); - assertEquals("alpha", read(connection.readFile("a.txt", "/in", new HashMap<>()))); - writeFile("auth.txt", "/newdir", "authed"); - assertEquals("authed", content("newdir/auth.txt")); - connection.move("auth.txt", "/newdir", "auth2.txt", "/newdir"); - connection.delete("auth2.txt", "/newdir", false); - assertFalse(WebDavTestServer.file(server.root, "newdir/auth2.txt").exists()); - } - - private static String multistatus(String... responses) { - return "" + String.join("", responses) + ""; - } - - private static String propstat(String props, String status) { - return "" + props + "HTTP/1.1 " + status + ""; - } - - private void writeFile(String name, String dir, String content) throws Exception { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - connection.writeFile(name, dir, false, new ByteArrayInputStream(bytes), bytes.length, new HashMap<>()); - } - - private String content(String path) throws Exception { - return new String(Files.readAllBytes(WebDavTestServer.file(server.root, path).toPath()), StandardCharsets.UTF_8); - } - - private static String read(InputStream in) throws Exception { - try (InputStream is = in) { - return IOUtils.toString(is, StandardCharsets.UTF_8); - } + private static DavResource file(String path, long length) { + DavResource resource = resource(path); + when(resource.getContentLength()).thenReturn(length); + when(resource.getModified()).thenReturn(new Date(1000)); + return resource; } - private static void write(File root, String path, String content) throws Exception { - File file = WebDavTestServer.file(root, path); - file.getParentFile().mkdirs(); - Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); - Files.setLastModifiedTime(file.toPath(), FileTime.fromMillis(MODIFIED)); + private static DavResource resource(String path) { + DavResource resource = mock(DavResource.class); + String name = path.replaceAll("/$", ""); + when(resource.getPath()).thenReturn(path); + when(resource.getName()).thenReturn(name.substring(name.lastIndexOf('/') + 1)); + when(resource.getStatusCode()).thenReturn(200); + return resource; } private static List names(List files) { @@ -568,7 +183,6 @@ private static List names(List files) { for (FileInfo file : files) { names.add(file.getName()); } - Collections.sort(names); return names; } } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java deleted file mode 100644 index d1f6ae01ac..0000000000 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavTestServer.java +++ /dev/null @@ -1,402 +0,0 @@ -package com.mirth.connect.connectors.file.filesystems; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.math.BigInteger; -import java.net.URI; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.KeyStore; -import java.security.cert.X509Certificate; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Stream; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManagerFactory; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.apache.commons.io.FileUtils; -import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.GeneralName; -import org.bouncycastle.asn1.x509.GeneralNames; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.eclipse.jetty.security.ConstraintMapping; -import org.eclipse.jetty.security.ConstraintSecurityHandler; -import org.eclipse.jetty.security.HashLoginService; -import org.eclipse.jetty.security.UserStore; -import org.eclipse.jetty.security.authentication.BasicAuthenticator; -import org.eclipse.jetty.security.authentication.DigestAuthenticator; -import org.eclipse.jetty.server.HttpConfiguration; -import org.eclipse.jetty.server.HttpConnectionFactory; -import org.eclipse.jetty.server.SecureRequestCustomizer; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.SslConnectionFactory; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; -import org.eclipse.jetty.util.security.Constraint; -import org.eclipse.jetty.util.security.Credential; -import org.eclipse.jetty.util.ssl.SslContextFactory; - -/** - * Minimal file-backed WebDAV server for WebDavConnection tests. Implements just - * enough of RFC 4918 (PROPFIND depth 0/1, GET, PUT, DELETE, MKCOL, MOVE) and - * records every request so tests can assert on the wire traffic. - */ -class WebDavTestServer { - - enum Auth { - NONE, BASIC, DIGEST, - /** Basic, but GET, PROPFIND and OPTIONS are allowed anonymously */ - BASIC_WRITES - } - - static final String USER = "dav user"; - static final String PASSWORD = "p@ss:word"; - - private static final DateTimeFormatter RFC_1123 = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC); - - final File root; - final List requests = Collections.synchronizedList(new ArrayList<>()); - final List authorizations = Collections.synchronizedList(new ArrayList<>()); - final Set lockedPaths = Collections.synchronizedSet(new HashSet<>()); - final List destinations = Collections.synchronizedList(new ArrayList<>()); - final List putExpectHeaders = Collections.synchronizedList(new ArrayList<>()); - /** When set, returned verbatim for every PROPFIND */ - volatile String propfindOverride; - /** Indent the multistatus and put whitespace around status lines */ - volatile boolean prettyPrint; - /** Delay before answering any request */ - volatile long delayMillis; - /** Read an unauthenticated PUT body before the 401, like nginx with request buffering */ - volatile boolean consumeUnauthenticatedBodies; - /** Redirect a collection requested without a trailing slash, like Apache's DirectorySlash */ - volatile boolean slashRedirect; - - private Server server; - private int port; - private SSLContext clientSslContext; - - WebDavTestServer(File root) { - this.root = root; - } - - void start(Auth auth, boolean tls) throws Exception { - server = new Server(); - - ServerConnector connector; - if (tls) { - KeyStore keyStore = selfSignedKeyStore(); - SslContextFactory.Server ssl = new SslContextFactory.Server(); - ssl.setKeyStore(keyStore); - ssl.setKeyStorePassword("changeit"); - HttpConfiguration config = new HttpConfiguration(); - config.addCustomizer(new SecureRequestCustomizer(false)); - connector = new ServerConnector(server, new SslConnectionFactory(ssl, "http/1.1"), new HttpConnectionFactory(config)); - - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(keyStore); - clientSslContext = SSLContext.getInstance("TLS"); - clientSslContext.init(null, tmf.getTrustManagers(), null); - } else { - connector = new ServerConnector(server); - } - connector.setHost("127.0.0.1"); - connector.setPort(0); - server.addConnector(connector); - - ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); - context.setContextPath("/"); - context.addServlet(new ServletHolder(new DavServlet()), "/*"); - - if (auth != Auth.NONE) { - UserStore userStore = new UserStore(); - userStore.addUser(USER, Credential.getCredential(PASSWORD), new String[] { "dav" }); - HashLoginService loginService = new HashLoginService("dav"); - loginService.setUserStore(userStore); - - boolean digest = auth == Auth.DIGEST; - Constraint constraint = new Constraint(digest ? Constraint.__DIGEST_AUTH : Constraint.__BASIC_AUTH, "dav"); - constraint.setAuthenticate(true); - ConstraintMapping mapping = new ConstraintMapping(); - mapping.setConstraint(constraint); - mapping.setPathSpec("/*"); - if (auth == Auth.BASIC_WRITES) { - mapping.setMethodOmissions(new String[] { "GET", "PROPFIND", "OPTIONS" }); - } - - ConstraintSecurityHandler security = new ConstraintSecurityHandler(); - security.setLoginService(loginService); - security.setAuthenticator(digest ? new DigestAuthenticator() : new BasicAuthenticator()); - security.addConstraintMapping(mapping); - context.setSecurityHandler(security); - } - - // Record before the security handler so 401s show up too - server.setHandler(new org.eclipse.jetty.server.handler.HandlerWrapper() { - { - setHandler(context); - } - - @Override - public void handle(String target, org.eclipse.jetty.server.Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, javax.servlet.ServletException { - String authorization = request.getHeader("Authorization"); - authorizations.add(authorization == null ? "" : authorization.split(" ")[0]); - if ("PUT".equals(request.getMethod())) { - putExpectHeaders.add(String.valueOf(request.getHeader("Expect"))); - if (consumeUnauthenticatedBodies && authorization == null) { - // Answers 100 Continue and takes the whole body - request.getInputStream().readAllBytes(); - } - } - super.handle(target, baseRequest, request, response); - } - }); - - server.start(); - port = connector.getLocalPort(); - } - - void stop() throws Exception { - if (server != null) { - server.stop(); - } - } - - int port() { - return port; - } - - /** host:port as it appears in URLs */ - String authority() { - return "localhost:" + port; - } - - SSLContext clientSslContext() { - return clientSslContext; - } - - /** Requests seen by the servlet (i.e. after authentication), as "METHOD /decoded/path". */ - List requests() { - synchronized (requests) { - return new ArrayList<>(requests); - } - } - - private static KeyStore selfSignedKeyStore() throws Exception { - KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(2048); - KeyPair keyPair = generator.generateKeyPair(); - - X500Name name = new X500Name("CN=localhost"); - Date notBefore = new Date(System.currentTimeMillis() - 60_000); - Date notAfter = new Date(System.currentTimeMillis() + 86_400_000); - JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name, BigInteger.valueOf(System.nanoTime()), notBefore, notAfter, name, keyPair.getPublic()); - builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(new GeneralName[] { - new GeneralName(GeneralName.dNSName, "localhost"), - new GeneralName(GeneralName.iPAddress, "127.0.0.1") })); - X509Certificate cert = new JcaX509CertificateConverter().getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()))); - - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - keyStore.load(null, null); - keyStore.setKeyEntry("server", keyPair.getPrivate(), "changeit".toCharArray(), new X509Certificate[] { cert }); - return keyStore; - } - - private class DavServlet extends HttpServlet { - - @Override - protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { - String path = decode(req.getRequestURI()); - requests.add(req.getMethod() + " " + path); - if (delayMillis > 0) { - try { - Thread.sleep(delayMillis); - } catch (InterruptedException e) { - return; - } - } - File file = resolve(path); - - if (slashRedirect && file.isDirectory() && !req.getRequestURI().endsWith("/")) { - resp.setStatus(301); - resp.setHeader("Location", req.getRequestURL() + "/"); - return; - } - - switch (req.getMethod()) { - case "PROPFIND": - propfind(req, resp, path, file); - break; - case "GET": - if (!file.isFile()) { - resp.sendError(404); - return; - } - try (OutputStream out = resp.getOutputStream()) { - Files.copy(file.toPath(), out); - } - break; - case "PUT": - if (!file.getParentFile().isDirectory()) { - resp.sendError(409); - return; - } - boolean existed = file.exists(); - try (InputStream in = req.getInputStream()) { - Files.copy(in, file.toPath(), StandardCopyOption.REPLACE_EXISTING); - } - resp.setStatus(existed ? 204 : 201); - break; - case "MKCOL": - if (file.exists()) { - resp.sendError(405); - } else if (!file.getParentFile().isDirectory()) { - resp.sendError(409); - } else { - file.mkdir(); - resp.setStatus(201); - } - break; - case "DELETE": - if (!file.exists()) { - resp.sendError(404); - return; - } - FileUtils.forceDelete(file); - resp.setStatus(204); - break; - case "MOVE": - String destination = req.getHeader("Destination"); - destinations.add(destination); - File target = resolve(decode(URI.create(destination).getRawPath())); - if (!file.exists()) { - resp.sendError(404); - } else if (!target.getParentFile().isDirectory()) { - resp.sendError(409); - } else if (target.exists() && "F".equals(req.getHeader("Overwrite"))) { - resp.sendError(412); - } else { - boolean replaced = target.exists(); - Files.move(file.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); - resp.setStatus(replaced ? 204 : 201); - } - break; - default: - resp.sendError(405); - } - } - - private void propfind(HttpServletRequest req, HttpServletResponse resp, String path, File file) throws IOException { - if (!file.exists()) { - resp.sendError(404); - return; - } - // Drain the request body - req.getInputStream().readAllBytes(); - if (propfindOverride != null) { - resp.setStatus(207); - resp.getOutputStream().write(propfindOverride.getBytes(StandardCharsets.UTF_8)); - return; - } - - // Mixed prefixes on purpose: clients must match on namespace, not prefix - StringBuilder xml = new StringBuilder("\n"); - xml.append(""); - appendResponse(xml, path, file); - if (file.isDirectory() && !"0".equals(req.getHeader("Depth"))) { - try (Stream children = Files.list(file.toPath())) { - for (Path child : (Iterable) children.sorted()::iterator) { - appendResponse(xml, (path.endsWith("/") ? path : path + "/") + decode(child.getFileName().toString()), child.toFile()); - } - } - } - xml.append(""); - - resp.setStatus(207); - resp.setContentType("application/xml; charset=utf-8"); - resp.getOutputStream().write(xml.toString().getBytes(StandardCharsets.UTF_8)); - } - - private void appendResponse(StringBuilder xml, String path, File file) { - String href = encode(path); - if (file.isDirectory() && !href.endsWith("/")) { - href += "/"; - } - xml.append("").append(href).append(""); - if (file.isDirectory()) { - xml.append(""); - } else { - xml.append(""); - xml.append("").append(file.length()).append(""); - } - xml.append("").append(RFC_1123.format(new Date(file.lastModified()).toInstant())).append(""); - String normalized = path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path; - if (lockedPaths.contains(normalized)) { - xml.append(""); - xml.append("0someoneSecond-3600"); - xml.append("opaquelocktoken:1234"); - } else { - xml.append(""); - } - xml.append("HTTP/1.1 200 OK"); - if (prettyPrint) { - String pretty = xml.toString().replace("><", ">\n <").replace("HTTP/1.1 200 OK", "\n HTTP/1.1 200 OK\n "); - xml.setLength(0); - xml.append(pretty); - } - } - - private File resolve(String path) { - return file(root, path); - } - } - - /** - * The file backing a decoded DAV path. Names are stored percent-encoded so they stay ASCII on - * disk, which works whatever charset the JVM uses for file names (e.g. a POSIX locale in CI). - */ - static File file(File root, String path) { - return new File(root, encode(path)); - } - - private static String decode(String rawPath) { - try { - return URLDecoder.decode(rawPath.replace("+", "%2B"), "UTF-8"); - } catch (IOException e) { - throw new IllegalArgumentException(e); - } - } - - private static String encode(String path) { - StringBuilder encoded = new StringBuilder(); - for (byte b : path.getBytes(StandardCharsets.UTF_8)) { - int c = b & 0xff; - if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || "/-._~".indexOf(c) >= 0) { - encoded.append((char) c); - } else { - encoded.append('%').append(String.format("%02X", c)); - } - } - return encoded.toString(); - } -} From 220f7d9a5ae75af7a2c988284ce08814fae3055f Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Sat, 31 Oct 2026 10:48:35 +0100 Subject: [PATCH 09/10] Resend WebDAV PUT bodies again and stop preemptive Basic Review follow-up: - Bring back the resettable PUT entity, passed through SardineImpl's public put(url, entity, contentType, expectContinue) overload instead of extending Sardine. Without it, a proxy that answers 100 Continue and reads the body before the 401 made authenticated writes fail. - With that, preemptive Basic isn't needed, so the password is no longer sent as Basic to servers that ask for Digest or NTLM. - Any HTTP answer, such as a 404 from exists(), keeps the connection valid. Only an I/O error marks it invalid, so the pool stops rebuilding the client after every missing file. Signed-off-by: Nico Piel --- .../file/filesystems/WebDavConnection.java | 73 ++++++++++++++----- .../filesystems/WebDavConnectionTest.java | 55 ++++++++++++-- 2 files changed, 100 insertions(+), 28 deletions(-) diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index da530e656b..c253580e5e 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -9,9 +9,11 @@ package com.mirth.connect.connectors.file.filesystems; +import java.io.ByteArrayInputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; @@ -32,12 +34,13 @@ import org.apache.http.client.config.RequestConfig; import org.apache.http.client.utils.URIBuilder; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.InputStreamEntity; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.impl.client.HttpClients; import org.apache.http.ssl.SSLContexts; import com.github.sardine.DavResource; -import com.github.sardine.Sardine; import com.github.sardine.impl.SardineImpl; import com.github.sardine.impl.SardineRedirectStrategy; @@ -104,14 +107,40 @@ public boolean isReadable() { public void populateSourceMap(Map sourceMap) {} } + /** + * Streams a ByteArrayInputStream, which is what FileDispatcher passes, and resends it by + * resetting the stream. The PUT can then be retried after an authentication challenge without + * copying the payload, even when a proxy has already read the body. + */ + private static class ResettableEntity extends InputStreamEntity { + private final ByteArrayInputStream in; + + ResettableEntity(ByteArrayInputStream in, long length) { + super(in, length, ContentType.APPLICATION_OCTET_STREAM); + this.in = in; + in.mark(0); + } + + @Override + public boolean isRepeatable() { + return true; + } + + @Override + public void writeTo(OutputStream out) throws IOException { + in.reset(); + super.writeTo(out); + } + } + private static transient Log logger = LogFactory.getLog(WebDavConnection.class); /** The WebDAV client instance */ - private Sardine client = null; + private SardineImpl client = null; private String baseUrl; private InputStream readStream = null; - /** Whether the last path looked up exists, as WebdavResource.exists() reported it */ - private boolean exists = false; + /** Whether the server answered the last PROPFIND */ + private boolean connected = false; /** Status of the last failed PROPFIND */ private String lastStatus = null; @@ -119,7 +148,7 @@ public WebDavConnection(String host, int port, boolean secure, FileSystemConnect this(createClient(host, port, secure, fileSystemOptions, timeout), new HttpHost(host, port, secure ? "https" : "http").toURI()); } - WebDavConnection(Sardine client, String baseUrl) { + WebDavConnection(SardineImpl client, String baseUrl) { this.client = client; this.baseUrl = baseUrl; @@ -130,13 +159,13 @@ public WebDavConnection(String host, int port, boolean secure, FileSystemConnect } } - private static Sardine createClient(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) { + private static SardineImpl createClient(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) { String username = fileSystemOptions.getUsername(); String password = fileSystemOptions.getPassword(); - int authPort = port > 0 ? port : (secure ? 443 : 80); - CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); if (!username.equals("null")) { + int authPort = port > 0 ? port : (secure ? 443 : 80); + // Scope credentials to this port so a redirect elsewhere doesn't get them credentialsProvider.setCredentials(new AuthScope(host, authPort), new UsernamePasswordCredentials(username, password)); @@ -149,13 +178,7 @@ private static Sardine createClient(String host, int port, boolean secure, FileS // The schemes httpclient 3 negotiated, in its order of preference RequestConfig requestConfig = RequestConfig.custom().setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM, AuthSchemes.DIGEST, AuthSchemes.BASIC)).setConnectTimeout(timeout).setSocketTimeout(timeout).setExpectContinueEnabled(false).build(); - SardineImpl sardine = new SardineImpl(HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setRedirectStrategy(new SardineRedirectStrategy()).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault()))); - if (secure && !username.equals("null")) { - // Send Basic up front so a streamed PUT doesn't have to be resent. Only over TLS, so a - // server that would have negotiated Digest never sees the password in cleartext. - sardine.enablePreemptiveAuthentication(host, authPort, authPort); - } - return sardine; + return new SardineImpl(HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setRedirectStrategy(new SardineRedirectStrategy()).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault()))); } @Override @@ -273,7 +296,13 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, } try { - client.put(url(fullPath), is, "application/octet-stream", true, contentLength); + if (is instanceof ByteArrayInputStream) { + client.put(url(fullPath), new ResettableEntity((ByteArrayInputStream) is, contentLength), null, false); + } else { + // Other streams can't be resent. Expect: 100-continue usually gets an authentication + // challenge before the body, but a buffering proxy may still consume it first. + client.put(url(fullPath), new InputStreamEntity(is, contentLength, ContentType.APPLICATION_OCTET_STREAM), null, true); + } } catch (HttpResponseException e) { logger.error("Unable to write file: '" + fullPath, e); } @@ -319,7 +348,7 @@ public void move(String fromName, String fromDir, String toName, String toDir) t @Override public boolean isConnected() { - return client != null && exists; + return client != null && connected; } @Override @@ -349,7 +378,7 @@ public void destroy() { @Override public boolean isValid() { - return client != null && exists; + return client != null && connected; } @Override @@ -383,12 +412,16 @@ private DavResource stat(String path) throws IOException { private List propfind(String path, int depth) throws IOException { try { List resources = client.list(url(path), depth, Collections.emptySet()); - exists = true; + connected = true; return resources; } catch (HttpResponseException e) { - exists = false; + // Any answer, e.g. 404 for a missing file, means the server is reachable + connected = true; lastStatus = e.getMessage(); return null; + } catch (IOException e) { + connected = false; + throw e; } } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index 4802af8bcb..4f4c79c181 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -5,16 +5,20 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; @@ -22,24 +26,27 @@ import java.util.HashMap; import java.util.List; +import org.apache.http.HttpEntity; + import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import com.github.sardine.DavResource; -import com.github.sardine.Sardine; import com.github.sardine.impl.SardineException; +import com.github.sardine.impl.SardineImpl; import com.mirth.connect.connectors.file.FileSystemConnectionOptions; public class WebDavConnectionTest { private static final String BASE = "http://dav.example:8080"; - private Sardine sardine; + private SardineImpl sardine; private WebDavConnection connection; @Before public void setUp() throws Exception { - sardine = mock(Sardine.class); + sardine = mock(SardineImpl.class); // Anything not set up below doesn't exist when(sardine.list(anyString(), anyInt(), any())).thenThrow(new SardineException("Unexpected response", 404, "Not Found")); propfind("/", 0, dir("/")); @@ -112,10 +119,25 @@ public void readFile() throws Exception { @Test public void writeFileCreatesMissingDirectory() throws Exception { - InputStream in = new ByteArrayInputStream(new byte[3]); - connection.writeFile("a.txt", "/out", false, in, 3, new HashMap<>()); + connection.writeFile("a.txt", "/out", false, new ByteArrayInputStream("abc".getBytes()), 3, new HashMap<>()); verify(sardine).createDirectory(BASE + "/out"); - verify(sardine).put(BASE + "/out/a.txt", in, "application/octet-stream", true, 3); + + // The body can be resent after an authentication challenge, without Expect: 100-continue + ArgumentCaptor entity = ArgumentCaptor.forClass(HttpEntity.class); + verify(sardine).put(eq(BASE + "/out/a.txt"), entity.capture(), isNull(), eq(false)); + assertTrue(entity.getValue().isRepeatable()); + assertEquals("abc", body(entity.getValue())); + assertEquals("abc", body(entity.getValue())); + } + + @Test + public void otherStreamsUseExpectContinue() throws Exception { + propfind("/out", 0, dir("/out/")); + InputStream in = new FilterInputStream(new ByteArrayInputStream("abc".getBytes())) {}; + connection.writeFile("a.txt", "/out", false, in, 3, new HashMap<>()); + ArgumentCaptor entity = ArgumentCaptor.forClass(HttpEntity.class); + verify(sardine).put(eq(BASE + "/out/a.txt"), entity.capture(), isNull(), eq(true)); + assertFalse(entity.getValue().isRepeatable()); } @Test @@ -127,7 +149,7 @@ public void writeFileIntoFileFails() throws Exception { } catch (Exception e) { assertEquals("The destination directory path is invalid: '/out'", e.getMessage()); } - verify(sardine, never()).put(anyString(), any(InputStream.class), anyString(), eq(true), anyLong()); + verify(sardine, never()).put(anyString(), any(HttpEntity.class), any(), anyBoolean()); } @Test @@ -144,6 +166,17 @@ public void pathsAreEncoded() throws Exception { verify(sardine).delete(BASE + "/in/a%20b%23%3F.txt"); } + @Test + public void missingFileKeepsConnectionValid() throws Exception { + assertTrue(connection.isValid()); + assertFalse(connection.exists("a.txt", "/in")); + assertTrue(connection.isValid()); + + when(sardine.list(eq(BASE + "/in/b.txt"), eq(0), any())).thenThrow(new IOException("Connection reset")); + assertFalse(connection.exists("b.txt", "/in")); + assertFalse(connection.isValid()); + } + @Test public void unreachableServerIsInvalid() throws Exception { WebDavConnection unreachable = new WebDavConnection("localhost", 1, false, new FileSystemConnectionOptions(false, "null", "null", null), 1000); @@ -178,6 +211,12 @@ private static DavResource resource(String path) { return resource; } + private static String body(HttpEntity entity) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + entity.writeTo(out); + return out.toString("UTF-8"); + } + private static List names(List files) { List names = new ArrayList<>(); for (FileInfo file : files) { From 31358a0729dd66c0ac9165f5de3ac2a0d3066c6b Mon Sep 17 00:00:00 2001 From: Nico Piel Date: Sat, 31 Oct 2026 12:07:22 +0100 Subject: [PATCH 10/10] Send Expect: 100-continue with resettable WebDAV PUTs Sardine runs each request in a child context, so a successful authentication isn't reused and every authenticated PUT is challenged. Without Expect: 100-continue the whole body went out before the 401. Resetting the stream still covers proxies that read the body first. Signed-off-by: Nico Piel --- .../file/filesystems/WebDavConnection.java | 12 +++++------- .../file/filesystems/WebDavConnectionTest.java | 6 +++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java index c253580e5e..83aafbf987 100644 --- a/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java +++ b/server/src/main/java/com/mirth/connect/connectors/file/filesystems/WebDavConnection.java @@ -24,6 +24,7 @@ import org.apache.commons.io.filefilter.WildcardFileFilter; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.apache.http.HttpEntity; import org.apache.http.HttpHost; import org.apache.http.auth.AuthScope; import org.apache.http.auth.NTCredentials; @@ -296,13 +297,10 @@ public void writeFile(String file, String toDir, boolean append, InputStream is, } try { - if (is instanceof ByteArrayInputStream) { - client.put(url(fullPath), new ResettableEntity((ByteArrayInputStream) is, contentLength), null, false); - } else { - // Other streams can't be resent. Expect: 100-continue usually gets an authentication - // challenge before the body, but a buffering proxy may still consume it first. - client.put(url(fullPath), new InputStreamEntity(is, contentLength, ContentType.APPLICATION_OCTET_STREAM), null, true); - } + // Expect: 100-continue usually gets an authentication challenge before the body. A buffering + // proxy may still consume the body first, so resend it when the stream allows. + HttpEntity entity = is instanceof ByteArrayInputStream ? new ResettableEntity((ByteArrayInputStream) is, contentLength) : new InputStreamEntity(is, contentLength, ContentType.APPLICATION_OCTET_STREAM); + client.put(url(fullPath), entity, null, true); } catch (HttpResponseException e) { logger.error("Unable to write file: '" + fullPath, e); } diff --git a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java index 4f4c79c181..3f028e3294 100644 --- a/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -122,16 +122,16 @@ public void writeFileCreatesMissingDirectory() throws Exception { connection.writeFile("a.txt", "/out", false, new ByteArrayInputStream("abc".getBytes()), 3, new HashMap<>()); verify(sardine).createDirectory(BASE + "/out"); - // The body can be resent after an authentication challenge, without Expect: 100-continue + // The body can be resent if a proxy consumed it before the authentication challenge ArgumentCaptor entity = ArgumentCaptor.forClass(HttpEntity.class); - verify(sardine).put(eq(BASE + "/out/a.txt"), entity.capture(), isNull(), eq(false)); + verify(sardine).put(eq(BASE + "/out/a.txt"), entity.capture(), isNull(), eq(true)); assertTrue(entity.getValue().isRepeatable()); assertEquals("abc", body(entity.getValue())); assertEquals("abc", body(entity.getValue())); } @Test - public void otherStreamsUseExpectContinue() throws Exception { + public void otherStreamsAreNotResent() throws Exception { propfind("/out", 0, dir("/out/")); InputStream in = new FilterInputStream(new ByteArrayInputStream("abc".getBytes())) {}; connection.writeFile("a.txt", "/out", false, in, 3, new HashMap<>());