diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 29ad797961..5a8de4b610 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 446d076a41..ae46376653 100644 --- a/gradle/vendored-layout.json +++ b/gradle/vendored-layout.json @@ -378,6 +378,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 509312e318..ffb13271f0 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -256,6 +256,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 d0db9c2127..6b7a6b99c8 100644 --- a/server/docs/thirdparty/THIRD-PARTY-README.txt +++ b/server/docs/thirdparty/THIRD-PARTY-README.txt @@ -38,11 +38,11 @@ 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) 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 8bd3b5f73e..0000000000 Binary files a/server/lib/extensions/file/webdavclient4j-core-0.92.jar and /dev/null differ 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..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 @@ -9,20 +9,41 @@ 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; +import java.util.Collections; import java.util.List; import java.util.Map; -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.HttpEntity; +import org.apache.http.HttpHost; +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.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.impl.SardineImpl; +import com.github.sardine.impl.SardineRedirectStrategy; import com.mirth.connect.connectors.file.FileSystemConnectionOptions; import com.mirth.connect.connectors.file.filters.RegexFilenameFilter; @@ -30,28 +51,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 +89,97 @@ 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) {} } + /** + * 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 WebdavResource client = null; - private boolean secure = false; - private String username = null; - private String password = null; - - public WebDavConnection(String host, boolean secure, FileSystemConnectionOptions fileSystemOptions) throws Exception { - this.secure = secure; - username = fileSystemOptions.getUsername(); - password = fileSystemOptions.getPassword(); + private SardineImpl client = null; + private String baseUrl; + private InputStream readStream = null; + /** Whether the server answered the last PROPFIND */ + private boolean connected = false; + /** 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()); + } - HttpURL url = null; + WebDavConnection(SardineImpl client, String baseUrl) { + this.client = client; + this.baseUrl = baseUrl; - if (secure) { - url = new HttpsURL("https://" + host); - } else { - url = new HttpURL("http://" + host); + try { + stat("/"); + } catch (IOException e) { + logger.debug(e); } + } + private static SardineImpl createClient(String host, int port, boolean secure, FileSystemConnectionOptions fileSystemOptions, int timeout) { + String username = fileSystemOptions.getUsername(); + String password = fileSystemOptions.getPassword(); + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); if (!username.equals("null")) { - url.setUserinfo(username, password); + 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)); + + // 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).setExpectContinueEnabled(false).build(); + + return new SardineImpl(HttpClients.custom().setDefaultCredentialsProvider(credentialsProvider).setDefaultRequestConfig(requestConfig).setRedirectStrategy(new SardineRedirectStrategy()).setSSLSocketFactory(new SSLConnectionSocketFactory(SSLContexts.createSystemDefault()))); } @Override @@ -124,49 +199,40 @@ 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(normalize(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) { - - HttpsURL hrl = new HttpsURL("https://" + client.getHost() + filePath); - if (!username.equals("null")) { - hrl.setUserinfo(username, password); - } - file = new WebdavFile(hrl); - - } else { - - HttpURL hrl = new HttpURL("http://" + client.getHost() + filePath); - if (!username.equals("null")) { - hrl.setUserinfo(username, password); - } - file = new WebdavFile(hrl); + // The directory itself is part of a Depth 1 response + int self = selfIndex(resources, dirPath); + List fileInfoList = new ArrayList(resources.size()); + for (int i = 0; i < resources.size(); i++) { + 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; } + 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("."))) { - fileInfoList.add(new WebDavFileInfo(fromDir, file)); + fileInfoList.add(file); } } else if (file.isDirectory()) { - fileInfoList.add(new WebDavFileInfo(fromDir, file)); + fileInfoList.add(file); } } @@ -176,8 +242,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 +252,27 @@ 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()) { + DavResource resource = stat(fullPath); + if (resource != null && resource.isDirectory()) { logger.error("Invalid filepath: " + fullPath); throw new Exception("Invalid Path"); } - return client.getMethodData(); + closeReadFile(); + try { + readStream = client.get(url(fullPath)); + } catch (HttpResponseException e) { + throw new IOException("Couldn't get file", e); + } + return readStream; } @Override public void closeReadFile() throws Exception { - // irrelevant + if (readStream != null) { + readStream.close(); + readStream = null; + } } @Override @@ -211,29 +285,24 @@ 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()) { + DavResource dir = stat(toDir); + if (dir == null) { // create the directory. - client.mkcolMethod(toDir); + createDirectory(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.isDirectory()) { // 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 + "'"); + } + + try { + // 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); } } @@ -241,9 +310,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 (!client.deleteMethod(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); } } } @@ -254,35 +325,28 @@ 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()) { + DavResource dir = stat(toDir); + if (dir == null) { // create the directory. and then move. - client.mkcolMethod(toDir); + createDirectory(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.isDirectory()) { // 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 + "'"); + } + + 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); } } @Override public boolean isConnected() { - return client != null && client.exists(); + return client != null && connected; } @Override @@ -301,41 +365,111 @@ public void passivate() { @Override public void destroy() { try { + closeReadFile(); if (client != null) { - client.close(); + client.shutdown(); } - } catch (IOException e) { + } catch (Exception e) { logger.debug(e); } } @Override public boolean isValid() { - return client != null && client.exists(); + return client != null && connected; } @Override public boolean canRead(String readDir) { + 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 { - client.setPath(readDir); - return client.exists() && client.isCollection(); + DavResource dir = stat(path); + return dir != null && dir.isDirectory(); } catch (IOException e) { logger.debug(e); return false; } } - @Override - public boolean canWrite(String writeDir) { + /** Returns the resource at path, or null if it can't be found */ + 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, or null if the request failed */ + private List propfind(String path, int depth) throws IOException { try { - client.setPath(writeDir); - return client.exists() && client.isCollection() && !client.isLocked(); + List resources = client.list(url(path), depth, Collections.emptySet()); + connected = true; + return resources; + } catch (HttpResponseException e) { + // 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; + } + } + + /** + * 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).getPath()).equalsIgnoreCase(dirPath)) { + return i; + } + } + + for (int i = 0; i < resources.size(); i++) { + 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).getPath()).startsWith(prefix); + } + if (parent) { + return i; + } + } + + return -1; + } + + private void createDirectory(String path) throws IOException { + try { + client.createDirectory(url(path)); + } catch (HttpResponseException e) { logger.debug(e); - return false; } } + private String url(String path) throws IOException { + try { + return new URIBuilder(baseUrl).setPath(path).build().toASCIIString(); + } 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/main/resources/com/mirth/connect/connectors/file/destination.xml b/server/src/main/resources/com/mirth/connect/connectors/file/destination.xml index f3c9326bf4..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,7 +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 8a36863918..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,7 +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 new file mode 100644 index 0000000000..3f028e3294 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/connectors/file/filesystems/WebDavConnectionTest.java @@ -0,0 +1,227 @@ +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +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; +import java.util.Date; +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.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 SardineImpl sardine; + private WebDavConnection connection; + + @Before + public void setUp() throws Exception { + 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("/")); + connection = new WebDavConnection(sardine, BASE); + } + + @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); + + 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()); + + 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 listRoot() throws Exception { + propfind("/", 1, dir("/"), dir("/in/")); + assertEquals(Arrays.asList("/in"), connection.listDirectories("/")); + } + + @Test + 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 listingErrorIncludesStatus() throws Exception { + try { + connection.listFiles("/missing", "*", false, false); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage(), e.getMessage().contains("404")); + } + } + + @Test + 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")); + 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 { + propfind("/in/sub", 0, dir("/in/sub/")); + try { + connection.readFile("sub", "/in", new HashMap<>()); + fail(); + } catch (Exception e) { + assertEquals("Invalid Path", e.getMessage()); + } + } + + @Test + 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 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(true)); + assertTrue(entity.getValue().isRepeatable()); + assertEquals("abc", body(entity.getValue())); + assertEquals("abc", body(entity.getValue())); + } + + @Test + 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<>()); + ArgumentCaptor entity = ArgumentCaptor.forClass(HttpEntity.class); + verify(sardine).put(eq(BASE + "/out/a.txt"), entity.capture(), isNull(), eq(true)); + assertFalse(entity.getValue().isRepeatable()); + } + + @Test + public void writeFileIntoFileFails() throws Exception { + propfind("/out", 0, file("/out", 1)); + try { + connection.writeFile("a.txt", "/out", false, new ByteArrayInputStream(new byte[0]), 0, new HashMap<>()); + fail(); + } catch (Exception e) { + assertEquals("The destination directory path is invalid: '/out'", e.getMessage()); + } + verify(sardine, never()).put(anyString(), any(HttpEntity.class), any(), anyBoolean()); + } + + @Test + public void moveDoesNotOverwrite() throws Exception { + 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 pathsAreEncoded() throws Exception { + connection.delete("a b#?.txt", "/in", false); + 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); + assertFalse(unreachable.isValid()); + assertFalse(unreachable.exists("a.txt", "/in")); + } + + private void propfind(String path, int depth, DavResource... resources) throws Exception { + when(sardine.list(eq(BASE + path), eq(depth), any())).thenReturn(Arrays.asList(resources)); + } + + private static DavResource dir(String path) { + DavResource resource = resource(path); + when(resource.isDirectory()).thenReturn(true); + when(resource.getContentLength()).thenReturn(-1L); + return resource; + } + + 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 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 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) { + names.add(file.getName()); + } + return names; + } +}