diff --git a/.swiftformat b/.swiftformat
index a4c9857..bc4b823 100644
--- a/.swiftformat
+++ b/.swiftformat
@@ -1,2 +1,3 @@
--indent-case true
+--disable swiftTestingTestCaseNames
--trailing-commas collections-only
\ No newline at end of file
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Download).xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Download).xcscheme
new file mode 100644
index 0000000..9341ed0
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Download).xcscheme
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Help).xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Help).xcscheme
new file mode 100644
index 0000000..4cd46dd
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Help).xcscheme
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Info).xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Info).xcscheme
new file mode 100644
index 0000000..5b35c09
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/CLI (Info).xcscheme
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/REUSE.toml b/REUSE.toml
index 66ab34f..8e27da6 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -13,6 +13,7 @@ path = [
"Package.resolved",
".gitignore",
".swiftformat",
+ ".swiftpm/xcode/xcshareddata/xcschemes/**/*",
".swift-version",
"README.md",
"Sources/Rainmaker/Documentation.docc/**/*",
diff --git a/Sources/Rainmaker/Extensions/FileManager+assertFileDoesNotExist.swift b/Sources/Rainmaker/Extensions/FileManager+assertFileDoesNotExist.swift
new file mode 100644
index 0000000..5bd0c3c
--- /dev/null
+++ b/Sources/Rainmaker/Extensions/FileManager+assertFileDoesNotExist.swift
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: 2026 Iva Horn
+// SPDX-License-Identifier: MIT
+
+import Foundation
+
+extension FileManager {
+ func assertFileDoesNotExist(at location: URL) throws {
+ if fileExists(atPath: location.path()) {
+ throw RainmakerError.fileAlreadyExists(location)
+ }
+ }
+}
diff --git a/Sources/Rainmaker/Models/Method.swift b/Sources/Rainmaker/Models/Method.swift
index 2288455..bdae2a0 100644
--- a/Sources/Rainmaker/Models/Method.swift
+++ b/Sources/Rainmaker/Models/Method.swift
@@ -5,6 +5,11 @@
/// HTTP request methods.
///
enum Method: String, RawRepresentable {
+ ///
+ /// Fetching a resource.
+ ///
+ case get = "GET"
+
///
/// Sending data to a resource.
///
diff --git a/Sources/Rainmaker/RainmakerError.swift b/Sources/Rainmaker/RainmakerError.swift
index 2e6009b..53fa336 100644
--- a/Sources/Rainmaker/RainmakerError.swift
+++ b/Sources/Rainmaker/RainmakerError.swift
@@ -12,6 +12,26 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible {
///
case credentialsRequired
+ ///
+ /// The destination location is not an empty directory.
+ ///
+ case directoryNotEmpty
+
+ ///
+ /// During a local directory enumeration, the given error occurred for the item at the provided location.
+ ///
+ case enumeration(URL, String)
+
+ ///
+ /// A file transfer was attempted but the destination already exists and was not requested to be overwritten.
+ ///
+ case fileAlreadyExists(URL)
+
+ ///
+ /// Whatever you were looking for is not there.
+ ///
+ case notFound
+
///
/// The response most likely was not in the expected format or structure.
///
@@ -32,6 +52,14 @@ public enum RainmakerError: Error, Equatable, CustomStringConvertible {
switch self {
case .credentialsRequired:
"Credentials required"
+ case .directoryNotEmpty:
+ "The destination location is not an empty directory."
+ case let .enumeration(url, error):
+ "The item at \(url.path()) could not be enumerated: \(error)"
+ case let .fileAlreadyExists(url):
+ "A file already exists at: \(url.path())"
+ case .notFound:
+ "Not found."
case let .responseDecodingFailed(reason: reason):
reason
case let .unexpectedStatus(code: code):
diff --git a/Sources/Rainmaker/Requests/Requesting.swift b/Sources/Rainmaker/Requests/Requesting.swift
index 5840225..4fb3ead 100644
--- a/Sources/Rainmaker/Requests/Requesting.swift
+++ b/Sources/Rainmaker/Requests/Requesting.swift
@@ -8,4 +8,5 @@ import Foundation
///
protocol Requesting: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
+ func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse)
}
diff --git a/Sources/Rainmaker/Server.swift b/Sources/Rainmaker/Server.swift
index c876ada..b8bfb6e 100644
--- a/Sources/Rainmaker/Server.swift
+++ b/Sources/Rainmaker/Server.swift
@@ -10,6 +10,7 @@ import os
public final class Server {
static let resourceURL = Bundle.module.resourceURL!
+ nonisolated(unsafe) let fileManager = FileManager.default
let logger = Logger(category: "Server")
let jsonDecoder: JSONDecoder
let session: any Requesting
@@ -62,10 +63,11 @@ public final class Server {
///
/// List the content of the remote directory.
///
- private func content(at path: String) async throws -> [Item] {
- let url = webDAVAddress.appending(path: path, directoryHint: .isDirectory)
+ private func content(at path: String, depth: UInt = 1) async throws -> [Item] {
+ let url = webDAVAddress.appending(path: path, directoryHint: .inferFromPath)
var request = try makeWebDAVRequest(for: url, method: .propfind)
request.httpBody = try? Data(contentsOf: Self.resourceURL.appending(component: "Bodies").appending(component: "Listing.xml"))
+ request.setValue("\(depth)", forHTTPHeaderField: "Depth")
let (data, urlResponse) = try await session.data(for: request)
@@ -73,18 +75,218 @@ public final class Server {
throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.")
}
+ if response.status == .notFound {
+ throw RainmakerError.notFound
+ }
+
guard response.status == .multiStatus else {
throw RainmakerError.unexpectedStatus(code: response.statusCode)
}
- // Filter out metadata about the listed directory itself.
- return try ResponseParser.items(from: data, webDAVPathPrefix: webDAVPathPrefix).filter { item in
- if path == item.path {
- return false
+ let allItems = try ResponseParser.items(from: data, webDAVPathPrefix: webDAVPathPrefix)
+
+ // When fetching metadata about a specific item (depth 0), return the item itself.
+ // When listing directory contents (depth > 0), filter out the listed directory itself.
+ if depth == 0 {
+ return allItems
+ }
+
+ let normalizedPath = path.hasSuffix("/") ? String(path.dropLast()) : path
+
+ return allItems.filter { item in
+ let normalizedItemPath = item.path.hasSuffix("/") ? String(item.path.dropLast()) : item.path
+ return normalizedPath != normalizedItemPath
+ }
+ }
+
+ ///
+ /// Normalize a path so that it can be used as a key for matching local and remote items.
+ ///
+ /// Strips surrounding slashes so that `"/Foo/"`, `"Foo/"`, `"/Foo"`, and `"Foo"` all collapse to `"Foo"`.
+ ///
+ private func normalizeKey(_ path: String) -> String {
+ var slice = Substring(path)
+
+ while slice.hasPrefix("/") {
+ slice = slice.dropFirst()
+ }
+
+ while slice.hasSuffix("/") {
+ slice = slice.dropLast()
+ }
+
+ return String(slice)
+ }
+
+ ///
+ /// Enumerate local files recursively and return a dictionary mapping normalized relative paths to their URLs.
+ ///
+ private func enumerateLocalFiles(at destination: URL) throws -> [String: URL] {
+ logger.debug("Enumerating local files at \"\(destination.path(percentEncoded: false))\"...")
+
+ var enumerationErrorItem: URL?
+ var enumerationError: (any Error)?
+
+ let localEnumerator = fileManager.enumerator(at: destination, includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey]) { url, error in
+ enumerationErrorItem = url
+ enumerationError = error
+ return false
+ }
+
+ var result = [String: URL]()
+ // Use path components rather than string-prefix arithmetic so that resolving symlinks (e.g. macOS' `/var` → `/private/var`)
+ // or other inconsistencies between the supplied destination URL and the URLs yielded by the enumerator do not skew the relative path.
+ let destinationComponents = destination.resolvingSymlinksInPath().pathComponents
+
+ if let localEnumerator {
+ for case let fileURL as URL in localEnumerator {
+ let fileComponents = fileURL.resolvingSymlinksInPath().pathComponents
+
+ guard fileComponents.count > destinationComponents.count else {
+ continue
+ }
+
+ let relativeComponents = fileComponents.dropFirst(destinationComponents.count)
+ let relativePath = relativeComponents.joined(separator: "/")
+ let normalizedKey = normalizeKey(relativePath)
+ result[normalizedKey] = fileURL
+ logger.debug("Found \"\(normalizedKey)\"")
}
+ }
- return true
+ // The error handler closure is invoked during iteration, so the check must come after the loop.
+ if let enumerationErrorItem, let enumerationError {
+ throw RainmakerError.enumeration(enumerationErrorItem, enumerationError.localizedDescription)
}
+
+ return result
+ }
+
+ private func downloadDirectory(_ source: String, to destination: URL, force: Bool) async throws {
+ logger.debug("Downloading directory from \"\(source)\" to \"\(destination.path(percentEncoded: false))\" \(force ? "with" : "without") force...")
+
+ if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) == false {
+ try fileManager.createDirectory(at: destination, withIntermediateDirectories: true)
+ }
+
+ let destinationDirectoryContents = try fileManager.contentsOfDirectory(at: destination, includingPropertiesForKeys: nil)
+
+ if force == false, destinationDirectoryContents.isEmpty == false {
+ throw RainmakerError.directoryNotEmpty
+ }
+
+ // Enumerate local state recursively.
+
+ let localItemsByRelativePath = try enumerateLocalFiles(at: destination)
+
+ // Enumerate remote state recursively.
+
+ let normalizedSource = normalizeKey(source)
+ let remoteItems: [Item] = try await enumerate(at: source, recursively: true)
+ var remoteItemsByRelativePath = [String: Item]()
+
+ for item in remoteItems {
+ let normalizedItemPath = normalizeKey(item.path)
+ let relativePath = normalizeKey(String(normalizedItemPath.dropFirst(normalizedSource.count)))
+ remoteItemsByRelativePath[relativePath] = item
+ }
+
+ // Compare and derive actions.
+ // Create remote directories locally, shallowest first.
+
+ let remoteDirectories = remoteItems
+ .filter(\.isDirectory)
+ .sorted { $0.path.components(separatedBy: "/").count < $1.path.components(separatedBy: "/").count }
+
+ for directory in remoteDirectories {
+ let normalizedDirectoryPath = normalizeKey(directory.path)
+ let relativePath = normalizeKey(String(normalizedDirectoryPath.dropFirst(normalizedSource.count)))
+ let localURL = destination.appending(path: relativePath)
+
+ if fileManager.fileExists(atPath: localURL.path(percentEncoded: false)) == false {
+ try fileManager.createDirectory(at: localURL, withIntermediateDirectories: true)
+ }
+ }
+
+ // Download new and changed files.
+
+ let remoteFiles = remoteItems.filter { $0.isDirectory == false }
+
+ for file in remoteFiles {
+ let normalizedFilePath = normalizeKey(file.path)
+ let relativePath = normalizeKey(String(normalizedFilePath.dropFirst(normalizedSource.count)))
+ let localURL = destination.appending(path: relativePath)
+
+ try await downloadFile(file.path, to: localURL, force: force, remoteItem: file)
+ }
+
+ // Delete local items not present in the remote state, deepest first so a directory is never removed before its still-pending child entries (`removeItem` on a directory removes recursively, which would invalidate the URLs captured for those children and surface as a confusing failure).
+
+ if force {
+ let remoteRelativePaths = Set(remoteItemsByRelativePath.keys)
+ let orphanedPaths = localItemsByRelativePath.keys
+ .filter { remoteRelativePaths.contains($0) == false }
+ .sorted { $0.components(separatedBy: "/").count > $1.components(separatedBy: "/").count }
+
+ for path in orphanedPaths {
+ guard let url = localItemsByRelativePath[path] else {
+ continue
+ }
+
+ if fileManager.fileExists(atPath: url.path(percentEncoded: false)) {
+ try fileManager.removeItem(at: url)
+ }
+ }
+ }
+ }
+
+ ///
+ /// Download implementation specifically for files.
+ ///
+ private func downloadFile(_ source: String, to destination: URL, force: Bool, remoteItem: Item) async throws {
+ logger.debug("Downloading file from \"\(source)\" to \"\(destination.path(percentEncoded: false))\" \(force ? "with" : "without") force...")
+
+ if force == false {
+ // Check for the destination file to not exist before starting a potentially long running download.
+ try fileManager.assertFileDoesNotExist(at: destination)
+ } else if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
+ // Skip download if the local file is not older than the remote file.
+ let attributes = try fileManager.attributesOfItem(atPath: destination.path(percentEncoded: false))
+
+ if let localModification = attributes[.modificationDate] as? Date, localModification >= remoteItem.modification {
+ return
+ }
+ }
+
+ let url = webDAVAddress.appending(path: source)
+ let request = try makeWebDAVRequest(for: url, method: .get)
+ let (data, urlResponse) = try await session.download(for: request, delegate: nil)
+
+ guard let response = urlResponse as? HTTPURLResponse else {
+ throw RainmakerError.responseDecodingFailed(reason: "Failed to cast URLResponse to HTTPURLResponse.")
+ }
+
+ if response.status == .notFound {
+ throw RainmakerError.notFound
+ }
+
+ guard response.status == .ok else {
+ throw RainmakerError.unexpectedStatus(code: response.statusCode)
+ }
+
+ // Check for the destination file to still not exist after a potentially long running download.
+ if force == false {
+ try fileManager.assertFileDoesNotExist(at: destination)
+ }
+
+ if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
+ try fileManager.removeItem(at: destination)
+ }
+
+ try fileManager.moveItem(at: data, to: destination)
+
+ // Align the local modification date with the remote state for future change detection.
+ try fileManager.setAttributes([.modificationDate: remoteItem.modification], ofItemAtPath: destination.path(percentEncoded: false))
}
// MARK: - Factory Methods
@@ -150,6 +352,19 @@ public final class Server {
// MARK: - Serving
extension Server: Serving {
+ public func download(_ source: String, to destination: URL, force: Bool) async throws {
+ try requireCredentials()
+ logger.debug("Downloading \"\(source)\" to \"\(destination.path)\"...")
+ let item = try await info(source)
+
+ if item.isDirectory {
+ try await downloadDirectory(source, to: destination, force: force)
+ } else {
+ let destinationFile = destination.appending(component: item.name)
+ try await downloadFile(source, to: destinationFile, force: force, remoteItem: item)
+ }
+ }
+
public func enumerate(at path: String, recursively: Bool) async throws -> AsyncThrowingStream- {
try requireCredentials()
@@ -194,6 +409,18 @@ extension Server: Serving {
return items
}
+ public func info(_ path: String) async throws -> Item {
+ try requireCredentials()
+ logger.debug("Fetching information about \(path)")
+ let items = try await content(at: path, depth: 0)
+
+ guard let item = items.first else {
+ throw RainmakerError.responseDecodingFailed(reason: "Expected at least one item to be found but there was none.")
+ }
+
+ return item
+ }
+
public func login() async throws -> LoginFlow {
logger.debug("Fetching login information...")
diff --git a/Sources/Rainmaker/Serving.swift b/Sources/Rainmaker/Serving.swift
index 32e6fc7..a14081a 100644
--- a/Sources/Rainmaker/Serving.swift
+++ b/Sources/Rainmaker/Serving.swift
@@ -8,6 +8,38 @@ import Foundation
/// See ``Server`` for a implementation which is ready for use.
///
protocol Serving: Sendable {
+ ///
+ /// Download a file or a directory including its contents from the server to the local file system.
+ ///
+ /// This can be used as a one-way synchronization mechanism to replicate remote content locally.
+ /// In combination with the enumeration methods, a metadata-only synchronization is also possible.
+ ///
+ /// This method behaves differently given its arguments:
+ ///
+ /// | Type | Source | Destination | Force | Behavior |
+ /// | - | - | - | - | - |
+ /// | File | Exists | Empty | `false` | Download to destination directory |
+ /// | File | Exists | Contains file with same name | `false` | Cancel with conflict error |
+ /// | File | Exists | Contains file with same name | `true` | Skip if the local file is not older than the remote file, overwrite otherwise |
+ /// | File | Changed | Contains file with same name | `true` | Overwrite local file |
+ /// | Directory | Exists | Empty | `false` | Download content of source directory to destination directory |
+ /// | Directory | Exists | Not empty | `false` | Cancel with conflict error |
+ /// | Directory | Exists | Not empty | `true` | Delete local files which are not present in the remote state, replace local files with the state of their remote counterparts, download locally missing files which exist in the remote state |
+ ///
+ /// - Parameters:
+ /// - source: The file or root directory to download.
+ /// This can be either a file or a directory.
+ /// - destination: The directory in the local file system to download to.
+ /// For directory downloads, this directory is created automatically when it does not yet exist.
+ /// The content of the source is placed directly into that directory.
+ /// - force: Whether the local state should be overwritten with the remote state or not. This is `false` by default.
+ ///
+ /// - Throws:
+ /// - If a file is downloaded and an equally named file already exists in the destination directory.
+ /// - If a directory is downloaded and the destination directory is not empty.
+ ///
+ func download(_ source: String, to destination: URL, force: Bool) async throws
+
///
/// Returns items in the given path.
///
@@ -61,9 +93,25 @@ protocol Serving: Sendable {
///
func enumerate(at path: String, recursively: Bool) async throws -> [Item]
+ ///
+ /// Retrieve the information about a single specific item itself.
+ ///
+ /// This does not retrieve actual content but only metadata.
+ ///
+ /// - Parameters:
+ /// - path: The item to retrieve the metadata of.
+ ///
+ /// - Returns: The metadata for the specific item identified by the given path.
+ ///
+ /// - Throws: Any error that might occur during retrieval of the item properties.
+ ///
+ func info(_ path: String) async throws -> Item
+
///
/// Look up the login flow information.
///
+ /// - Returns: A set of properties to kick off the authentication which yields an app password.
+ ///
func login() async throws -> LoginFlow
///
diff --git a/Sources/RainmakerCLI/Commands/Download.swift b/Sources/RainmakerCLI/Commands/Download.swift
new file mode 100644
index 0000000..8ec2439
--- /dev/null
+++ b/Sources/RainmakerCLI/Commands/Download.swift
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: 2026 Iva Horn
+// SPDX-License-Identifier: MIT
+
+import ArgumentParser
+import Foundation
+import Rainmaker
+
+///
+/// Remote content download command.
+///
+struct Download: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(abstract: "Download a file or directory from the server.")
+
+ @OptionGroup
+ var authenticatedArguments: AuthenticatedArguments
+
+ @OptionGroup
+ var unauthenticatedArguments: UnauthenticatedArguments
+
+ @Option(help: "Remote path to download.")
+ var source: String
+
+ @Option(help: "Local directory to download to.")
+ var destination: String = "."
+
+ @Flag(help: "Whether to overwrite existing local files with the remote state.")
+ var force: Bool = false
+
+ func run() async throws {
+ guard let address = URL(string: unauthenticatedArguments.hostValue) else {
+ throw RainmakerCommandError.invalidAddress
+ }
+
+ let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue)
+ // `URL(filePath:)` does not expand `~`; do it ourselves so paths like `~/Downloads/Rainmaker`
+ // resolve to the user's home directory instead of a literal `~` folder.
+ let expandedDestination = (destination as NSString).expandingTildeInPath
+ let destinationURL = URL(filePath: expandedDestination, directoryHint: .isDirectory)
+
+ try await server.download(source, to: destinationURL, force: force)
+ }
+}
diff --git a/Sources/RainmakerCLI/Commands/Info.swift b/Sources/RainmakerCLI/Commands/Info.swift
new file mode 100644
index 0000000..7770e80
--- /dev/null
+++ b/Sources/RainmakerCLI/Commands/Info.swift
@@ -0,0 +1,50 @@
+// SPDX-FileCopyrightText: 2026 Iva Horn
+// SPDX-License-Identifier: MIT
+
+import ArgumentParser
+import Foundation
+import Rainmaker
+
+///
+/// Remote item information command.
+///
+struct Info: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(abstract: "Show information about a remote file or directory.")
+
+ @OptionGroup
+ var authenticatedArguments: AuthenticatedArguments
+
+ @OptionGroup
+ var formatArguments: FormatArguments
+
+ @OptionGroup
+ var unauthenticatedArguments: UnauthenticatedArguments
+
+ @Option(help: "Remote path to retrieve the information of.")
+ var path: String
+
+ func run() async throws {
+ guard let address = URL(string: unauthenticatedArguments.hostValue) else {
+ throw RainmakerCommandError.invalidAddress
+ }
+
+ let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue)
+ let item = try await server.info(path)
+
+ switch formatArguments.outputFormat {
+ case .json:
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+
+ let data = try encoder.encode(item)
+
+ guard let json = String(data: data, encoding: .utf8) else {
+ throw RainmakerCommandError.encodingError
+ }
+
+ print(json)
+ case .plain:
+ print(item.path)
+ }
+ }
+}
diff --git a/Sources/RainmakerCLI/Commands/Rainmaker.swift b/Sources/RainmakerCLI/Commands/Rainmaker.swift
index 16afafe..cedb19a 100644
--- a/Sources/RainmakerCLI/Commands/Rainmaker.swift
+++ b/Sources/RainmakerCLI/Commands/Rainmaker.swift
@@ -9,5 +9,5 @@ import Foundation
///
@main
struct Rainmaker: AsyncParsableCommand {
- static let configuration = CommandConfiguration(subcommands: [List.self, Login.self, Poll.self])
+ static let configuration = CommandConfiguration(subcommands: [Download.self, Info.self, List.self, Login.self, Poll.self])
}
diff --git a/Tests/RainmakerTests/DownloadTests.swift b/Tests/RainmakerTests/DownloadTests.swift
new file mode 100644
index 0000000..7f341a3
--- /dev/null
+++ b/Tests/RainmakerTests/DownloadTests.swift
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: 2026 Iva Horn
+// SPDX-License-Identifier: MIT
+
+import Foundation
+@testable import Rainmaker
+import Testing
+
+///
+/// About downloading content from the server.
+///
+@Suite("Downloads") struct DownloadTests: ServerTesting {
+ @Test("Require Credentials", arguments: ServerVersion.allCases)
+ func requireCredentials(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(user: nil, password: nil, serverVersion: serverVersion)
+
+ await #expect(throws: RainmakerError.credentialsRequired) {
+ let destination = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString)
+ try await server.download("/Readme.md", to: destination, force: false)
+ }
+ }
+
+ @Test("File", arguments: ServerVersion.allCases)
+ func file(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ let destination = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString)
+ try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
+
+ defer {
+ try? FileManager.default.removeItem(at: destination)
+ }
+
+ try await server.download("/Readme.md", to: destination, force: false)
+
+ let downloadedFile = destination.appending(component: "Readme.md")
+ #expect(FileManager.default.fileExists(atPath: downloadedFile.path()))
+
+ let content = try Data(contentsOf: downloadedFile)
+ #expect(content.isEmpty == false)
+ }
+
+ @Test("Directory", arguments: ServerVersion.allCases)
+ func directory(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ let destination = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString)
+ try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
+
+ defer {
+ try? FileManager.default.removeItem(at: destination)
+ }
+
+ try await server.download("/Documents", to: destination, force: false)
+
+ let downloadedFile = destination.appending(component: "Example.md")
+ #expect(FileManager.default.fileExists(atPath: downloadedFile.path()))
+
+ let content = try Data(contentsOf: downloadedFile)
+ #expect(content.isEmpty == false)
+ }
+
+ @Test("Overwrite File", arguments: ServerVersion.allCases)
+ func overwriteFile(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ let destination = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString)
+ try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
+
+ defer {
+ try? FileManager.default.removeItem(at: destination)
+ }
+
+ // Pre-create an old version of the file with a very old modification date.
+ let existingFile = destination.appending(component: "Readme.md")
+ let oldContent = Data("old content".utf8)
+ try oldContent.write(to: existingFile)
+ try FileManager.default.setAttributes([.modificationDate: Date.distantPast], ofItemAtPath: existingFile.path())
+
+ try await server.download("/Readme.md", to: destination, force: true)
+
+ let newContent = try Data(contentsOf: existingFile)
+ #expect(newContent != oldContent)
+ }
+
+ @Test("Overwrite Unchanged File", arguments: ServerVersion.allCases)
+ func overwriteUnchangedFile(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ let destination = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString)
+ try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
+
+ defer {
+ try? FileManager.default.removeItem(at: destination)
+ }
+
+ // Pre-create a file with a modification date far in the future so it is always considered up to date.
+ let existingFile = destination.appending(component: "Readme.md")
+ let originalContent = Data("original content".utf8)
+ try originalContent.write(to: existingFile)
+ try FileManager.default.setAttributes([.modificationDate: Date.distantFuture], ofItemAtPath: existingFile.path())
+
+ try await server.download("/Readme.md", to: destination, force: true)
+
+ // The file should not have been re-downloaded because the local copy is not older.
+ let currentContent = try Data(contentsOf: existingFile)
+ #expect(currentContent == originalContent)
+ }
+
+ @Test("Overwrite Directory", arguments: ServerVersion.allCases)
+ func overwriteDirectory(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ let destination = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString)
+ try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
+
+ defer {
+ try? FileManager.default.removeItem(at: destination)
+ }
+
+ // Pre-create an orphan file that should be deleted during synchronization.
+ let orphanFile = destination.appending(component: "Orphan.txt")
+ try Data("orphan".utf8).write(to: orphanFile)
+
+ try await server.download("/Documents", to: destination, force: true)
+
+ // The orphan file should have been deleted.
+ #expect(FileManager.default.fileExists(atPath: orphanFile.path()) == false)
+
+ // The remote file should have been downloaded.
+ let downloadedFile = destination.appending(component: "Example.md")
+ #expect(FileManager.default.fileExists(atPath: downloadedFile.path()))
+ }
+
+ @Test("Overwrite Directory Preserves Existing Remote Items", arguments: ServerVersion.allCases)
+ func overwriteDirectoryPreservesExistingRemoteItems(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ // Construct the destination URL the same way `RainmakerCLI` does, so the test exercises the
+ // production path including the trailing slash that `.isDirectory` adds to `path(...)`.
+ let destination = URL(filePath: NSTemporaryDirectory(), directoryHint: .isDirectory)
+ .appending(component: UUID().uuidString, directoryHint: .isDirectory)
+
+ try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
+
+ defer {
+ try? FileManager.default.removeItem(at: destination)
+ }
+
+ // Pre-create the file that also exists on the remote, simulating a prior successful download.
+ let existingFile = destination.appending(component: "Example.md")
+ try Data("pre-existing".utf8).write(to: existingFile)
+
+ try await server.download("/Documents", to: destination, force: true)
+
+ // The file should still exist after synchronization because it is present in the remote state.
+ #expect(FileManager.default.fileExists(atPath: existingFile.path(percentEncoded: false)))
+ }
+}
diff --git a/Tests/RainmakerTests/InfoTests.swift b/Tests/RainmakerTests/InfoTests.swift
new file mode 100644
index 0000000..c4eaef4
--- /dev/null
+++ b/Tests/RainmakerTests/InfoTests.swift
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2026 Iva Horn
+// SPDX-License-Identifier: MIT
+
+import Foundation
+@testable import Rainmaker
+import Testing
+
+///
+/// Retrieving metadata of specific items from the server.
+///
+@Suite("Infos") struct InfoTests: ServerTesting {
+ @Test("Require Credentials", arguments: ServerVersion.allCases)
+ func requireCredentials(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(user: nil, password: nil, serverVersion: serverVersion)
+
+ await #expect(throws: RainmakerError.credentialsRequired) {
+ _ = try await server.info("/")
+ }
+ }
+
+ @Test("File", arguments: ServerVersion.allCases)
+ func file(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ await #expect(throws: Never.self) {
+ _ = try await server.info("/Readme.md")
+ }
+ }
+
+ @Test("Directory", arguments: ServerVersion.allCases)
+ func directory(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ await #expect(throws: Never.self) {
+ _ = try await server.info("/")
+ }
+ }
+
+ @Test("Inexistent", arguments: ServerVersion.allCases)
+ func inexistent(_ serverVersion: ServerVersion) async throws {
+ let server = try makeServer(serverVersion: serverVersion)
+
+ await #expect(throws: RainmakerError.notFound) {
+ _ = try await server.info("/This does not exist")
+ }
+ }
+}
diff --git a/Tests/RainmakerTests/ListingTests.swift b/Tests/RainmakerTests/ListingTests.swift
index c2efe41..68fe853 100644
--- a/Tests/RainmakerTests/ListingTests.swift
+++ b/Tests/RainmakerTests/ListingTests.swift
@@ -8,7 +8,7 @@ import Testing
///
/// About folder content listing.
///
-@Suite("Listing Tests") struct ListingTests: ServerTesting {
+@Suite("Listings") struct ListingTests: ServerTesting {
@Test("Require Credentials", arguments: ServerVersion.allCases)
func requireCredentials(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(user: nil, password: nil, serverVersion: serverVersion)
@@ -18,7 +18,7 @@ import Testing
}
}
- @Test("List Root Folder Content", arguments: ServerVersion.allCases)
+ @Test("Root Folder", arguments: ServerVersion.allCases)
func listRootFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/", recursively: false)
@@ -27,7 +27,7 @@ import Testing
#expect(readme.href.path() == "/remote.php/dav/files/admin/Readme.md")
}
- @Test("List Documents Folder Content", arguments: ServerVersion.allCases)
+ @Test("Documents Folder", arguments: ServerVersion.allCases)
func listDocumentsFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Documents", recursively: false)
@@ -36,7 +36,7 @@ import Testing
#expect(example.href.path() == "/remote.php/dav/files/admin/Documents/Example.md")
}
- @Test("List All Content Recursively and Asynchronously", arguments: ServerVersion.allCases)
+ @Test("All Content Recursively and Asynchronously", arguments: ServerVersion.allCases)
func listAllContentRecursivelyAndAsynchronously(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/", recursively: true)
@@ -46,7 +46,7 @@ import Testing
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Templates/Brainstorming.whiteboard" })
}
- @Test("List Whitespace Folder Content", arguments: ServerVersion.allCases)
+ @Test("Whitespace Folder", arguments: ServerVersion.allCases)
func listWhitespaceFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Special Characters", recursively: false)
@@ -57,7 +57,7 @@ import Testing
#expect(items.contains { $0.name == "%" })
}
- @Test("List Percent-encoded Folder Content", arguments: ServerVersion.allCases)
+ @Test("Percent-encoded Folder", arguments: ServerVersion.allCases)
func listPercentEncodedFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Special%20Characters", recursively: false)
@@ -68,7 +68,7 @@ import Testing
#expect(items.contains { $0.name == "%" })
}
- @Test("List : Folder Content", arguments: ServerVersion.allCases)
+ @Test(": Folder", arguments: ServerVersion.allCases)
func listColonFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Special Characters/:", recursively: false)
@@ -77,7 +77,7 @@ import Testing
#expect(readme.path == "/Special Characters/:/Readme.md")
}
- @Test("List ? Folder Content", arguments: ServerVersion.allCases)
+ @Test("? Folder", arguments: ServerVersion.allCases)
func listQuestionMarkFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Special Characters/?", recursively: false)
@@ -86,7 +86,7 @@ import Testing
#expect(readme.path == "/Special Characters/?/Readme.md")
}
- @Test("List & Folder Content", arguments: ServerVersion.allCases)
+ @Test("& Folder", arguments: ServerVersion.allCases)
func listAmpersandFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Special Characters/&", recursively: false)
@@ -95,7 +95,7 @@ import Testing
#expect(readme.path == "/Special Characters/&/Readme.md")
}
- @Test("List # Folder Content", arguments: ServerVersion.allCases)
+ @Test("# Folder", arguments: ServerVersion.allCases)
func listHashFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Special Characters/#", recursively: false)
@@ -104,7 +104,7 @@ import Testing
#expect(readme.path == "/Special Characters/#/Readme.md")
}
- @Test("List % Folder Content", arguments: ServerVersion.allCases)
+ @Test("% Folder", arguments: ServerVersion.allCases)
func listPercentFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let items: [Item] = try await server.enumerate(at: "/Special Characters/%", recursively: false)
diff --git a/Tests/RainmakerTests/LoginTests.swift b/Tests/RainmakerTests/LoginTests.swift
index 83a2ae6..2d01588 100644
--- a/Tests/RainmakerTests/LoginTests.swift
+++ b/Tests/RainmakerTests/LoginTests.swift
@@ -8,7 +8,7 @@ import Testing
///
/// Login flow related tests.
///
-@Suite("Login Tests") struct LoginTests: ServerTesting {
+@Suite("Login") struct LoginTests: ServerTesting {
@Test("Fetch Login Information", arguments: ServerVersion.allCases)
func fetchLoginInformation(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
diff --git a/Tests/RainmakerTests/PollTests.swift b/Tests/RainmakerTests/PollTests.swift
index a9fcf01..bbf777a 100644
--- a/Tests/RainmakerTests/PollTests.swift
+++ b/Tests/RainmakerTests/PollTests.swift
@@ -8,7 +8,7 @@ import Testing
///
/// Login flow related tests.
///
-@Suite("Poll Tests") struct PollTests: ServerTesting {
+@Suite("Polling") struct PollTests: ServerTesting {
let token = "some-token"
@Test("Polling Failure", arguments: ServerVersion.allCases)
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
new file mode 100644
index 0000000..28d8603
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
@@ -0,0 +1,3 @@
+# Example
+
+This is the test content of Documents/Example.md.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
new file mode 100644
index 0000000..b300f9a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
@@ -0,0 +1,99 @@
+
+
+
+ /remote.php/dav/files/admin/Documents/
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "69455eb947440"
+ 1095
+ Documents
+
+
+
+ 00000049ocb3twjyt9mh
+ 49
+ RGDNVCK
+ false
+
+ 0
+ /remote.php/dav/comments/files/49
+ 0
+ 0
+ admin
+ admin
+ false
+ false
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
+ /remote.php/dav/files/admin/Documents/Example.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "dde75aa4303f49d782121cb875b012a1"
+ text/markdown
+ 1095
+ Example.md
+
+ 00000053ocb3twjyt9mh
+ 53
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/53
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Body.bin b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Body.bin
new file mode 100644
index 0000000..db3d9e5
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Body.bin
@@ -0,0 +1,3 @@
+# Welcome to Nextcloud
+
+This is the test content of the Readme.md file.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..3083842
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,50 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:32 GMT
+ "d0650fb5ff2a2e4e2344e02361799a5e"
+ text/markdown
+ 197
+ Readme.md
+
+ 00000013ocb3twjyt9mh
+ 13
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/13
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
new file mode 100644
index 0000000..28d8603
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
@@ -0,0 +1,3 @@
+# Example
+
+This is the test content of Documents/Example.md.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
new file mode 100644
index 0000000..b300f9a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
@@ -0,0 +1,99 @@
+
+
+
+ /remote.php/dav/files/admin/Documents/
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "69455eb947440"
+ 1095
+ Documents
+
+
+
+ 00000049ocb3twjyt9mh
+ 49
+ RGDNVCK
+ false
+
+ 0
+ /remote.php/dav/comments/files/49
+ 0
+ 0
+ admin
+ admin
+ false
+ false
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
+ /remote.php/dav/files/admin/Documents/Example.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "dde75aa4303f49d782121cb875b012a1"
+ text/markdown
+ 1095
+ Example.md
+
+ 00000053ocb3twjyt9mh
+ 53
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/53
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
new file mode 100644
index 0000000..28d8603
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
@@ -0,0 +1,3 @@
+# Example
+
+This is the test content of Documents/Example.md.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
new file mode 100644
index 0000000..b300f9a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
@@ -0,0 +1,99 @@
+
+
+
+ /remote.php/dav/files/admin/Documents/
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "69455eb947440"
+ 1095
+ Documents
+
+
+
+ 00000049ocb3twjyt9mh
+ 49
+ RGDNVCK
+ false
+
+ 0
+ /remote.php/dav/comments/files/49
+ 0
+ 0
+ admin
+ admin
+ false
+ false
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
+ /remote.php/dav/files/admin/Documents/Example.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "dde75aa4303f49d782121cb875b012a1"
+ text/markdown
+ 1095
+ Example.md
+
+ 00000053ocb3twjyt9mh
+ 53
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/53
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Body.bin b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Body.bin
new file mode 100644
index 0000000..db3d9e5
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Body.bin
@@ -0,0 +1,3 @@
+# Welcome to Nextcloud
+
+This is the test content of the Readme.md file.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..3083842
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,50 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:32 GMT
+ "d0650fb5ff2a2e4e2344e02361799a5e"
+ text/markdown
+ 197
+ Readme.md
+
+ 00000013ocb3twjyt9mh
+ 13
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/13
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..3083842
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,50 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:32 GMT
+ "d0650fb5ff2a2e4e2344e02361799a5e"
+ text/markdown
+ 197
+ Readme.md
+
+ 00000013ocb3twjyt9mh
+ 13
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/13
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Body.xml
new file mode 100644
index 0000000..e1e982f
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Body.xml
@@ -0,0 +1,49 @@
+
+
+
+ /remote.php/dav/files/admin/
+
+
+ 0
+ admin
+ "6a04252f687b4"
+ Wed, 13 May 2026 07:15:59 GMT
+ -3
+ 60882972
+
+
+
+ false
+ false
+ false
+ 0
+ /remote.php/dav/comments/files/2
+ 0
+ 0
+ 2
+ 00000002oc2mcql7sn5e
+ admin
+ admin
+ RGDNVCK
+ 60882972
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
diff --git a/Tests/RainmakerTests/Responses/31.0.12/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..16215d6
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,47 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Readme.md
+ text/markdown
+ "a4abe734fb651bf594962233b6aef4a4"
+ Mon, 11 May 2026 08:39:44 GMT
+
+ true
+ false
+ false
+ 0
+ 0
+ /remote.php/dav/comments/files/21
+ 0
+ 0
+ 21
+ 00000021oc2mcql7sn5e
+ admin
+ admin
+ RGDNVW
+ 197
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
diff --git a/Tests/RainmakerTests/Responses/31.0.12/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/31.0.12/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Body.xml b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Body.xml
new file mode 100644
index 0000000..4b2468a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Body.xml
@@ -0,0 +1,5 @@
+
+
+ Sabre\DAV\Exception\NotFound
+ File with name /This does not exist could not be located
+
diff --git a/Tests/RainmakerTests/Responses/31.0.12/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Headers.txt b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Headers.txt
new file mode 100644
index 0000000..ecf3f6a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/31.0.12/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 404 Not Found
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
new file mode 100644
index 0000000..28d8603
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
@@ -0,0 +1,3 @@
+# Example
+
+This is the test content of Documents/Example.md.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
new file mode 100644
index 0000000..b300f9a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
@@ -0,0 +1,99 @@
+
+
+
+ /remote.php/dav/files/admin/Documents/
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "69455eb947440"
+ 1095
+ Documents
+
+
+
+ 00000049ocb3twjyt9mh
+ 49
+ RGDNVCK
+ false
+
+ 0
+ /remote.php/dav/comments/files/49
+ 0
+ 0
+ admin
+ admin
+ false
+ false
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
+ /remote.php/dav/files/admin/Documents/Example.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "dde75aa4303f49d782121cb875b012a1"
+ text/markdown
+ 1095
+ Example.md
+
+ 00000053ocb3twjyt9mh
+ 53
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/53
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/directory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Body.bin b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Body.bin
new file mode 100644
index 0000000..db3d9e5
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Body.bin
@@ -0,0 +1,3 @@
+# Welcome to Nextcloud
+
+This is the test content of the Readme.md file.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..3083842
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,50 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:32 GMT
+ "d0650fb5ff2a2e4e2344e02361799a5e"
+ text/markdown
+ 197
+ Readme.md
+
+ 00000013ocb3twjyt9mh
+ 13
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/13
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
new file mode 100644
index 0000000..28d8603
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
@@ -0,0 +1,3 @@
+# Example
+
+This is the test content of Documents/Example.md.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
new file mode 100644
index 0000000..b300f9a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
@@ -0,0 +1,99 @@
+
+
+
+ /remote.php/dav/files/admin/Documents/
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "69455eb947440"
+ 1095
+ Documents
+
+
+
+ 00000049ocb3twjyt9mh
+ 49
+ RGDNVCK
+ false
+
+ 0
+ /remote.php/dav/comments/files/49
+ 0
+ 0
+ admin
+ admin
+ false
+ false
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
+ /remote.php/dav/files/admin/Documents/Example.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "dde75aa4303f49d782121cb875b012a1"
+ text/markdown
+ 1095
+ Example.md
+
+ 00000053ocb3twjyt9mh
+ 53
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/53
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectory/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
new file mode 100644
index 0000000..28d8603
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Body.bin
@@ -0,0 +1,3 @@
+# Example
+
+This is the test content of Documents/Example.md.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/GET/remote.php/dav/files/admin/Documents/Example.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
new file mode 100644
index 0000000..b300f9a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Body.xml
@@ -0,0 +1,99 @@
+
+
+
+ /remote.php/dav/files/admin/Documents/
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "69455eb947440"
+ 1095
+ Documents
+
+
+
+ 00000049ocb3twjyt9mh
+ 49
+ RGDNVCK
+ false
+
+ 0
+ /remote.php/dav/comments/files/49
+ 0
+ 0
+ admin
+ admin
+ false
+ false
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
+ /remote.php/dav/files/admin/Documents/Example.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:33 GMT
+ "dde75aa4303f49d782121cb875b012a1"
+ text/markdown
+ 1095
+ Example.md
+
+ 00000053ocb3twjyt9mh
+ 53
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/53
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteDirectoryPreservesExistingRemoteItems/PROPFIND/remote.php/dav/files/admin/Documents/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Body.bin b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Body.bin
new file mode 100644
index 0000000..db3d9e5
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Body.bin
@@ -0,0 +1,3 @@
+# Welcome to Nextcloud
+
+This is the test content of the Readme.md file.
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..c4df03e
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/GET/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Type: text/markdown
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..3083842
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,50 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:32 GMT
+ "d0650fb5ff2a2e4e2344e02361799a5e"
+ text/markdown
+ 197
+ Readme.md
+
+ 00000013ocb3twjyt9mh
+ 13
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/13
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..3083842
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,50 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Fri, 19 Dec 2025 14:18:32 GMT
+ "d0650fb5ff2a2e4e2344e02361799a5e"
+ text/markdown
+ 197
+ Readme.md
+
+ 00000013ocb3twjyt9mh
+ 13
+ RGDNVW
+ false
+
+ 0
+ /remote.php/dav/comments/files/13
+ 0
+ 0
+ admin
+ admin
+ true
+ false
+ 0
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/DownloadTests/overwriteUnchangedFile/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Body.xml
new file mode 100644
index 0000000..e1e982f
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Body.xml
@@ -0,0 +1,49 @@
+
+
+
+ /remote.php/dav/files/admin/
+
+
+ 0
+ admin
+ "6a04252f687b4"
+ Wed, 13 May 2026 07:15:59 GMT
+ -3
+ 60882972
+
+
+
+ false
+ false
+ false
+ 0
+ /remote.php/dav/comments/files/2
+ 0
+ 0
+ 2
+ 00000002oc2mcql7sn5e
+ admin
+ admin
+ RGDNVCK
+ 60882972
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
diff --git a/Tests/RainmakerTests/Responses/32.0.3/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/directory/PROPFIND/remote.php/dav/files/admin/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
new file mode 100644
index 0000000..16215d6
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Body.xml
@@ -0,0 +1,47 @@
+
+
+
+ /remote.php/dav/files/admin/Readme.md
+
+
+ 0
+ Readme.md
+ text/markdown
+ "a4abe734fb651bf594962233b6aef4a4"
+ Mon, 11 May 2026 08:39:44 GMT
+
+ true
+ false
+ false
+ 0
+ 0
+ /remote.php/dav/comments/files/21
+ 0
+ 0
+ 21
+ 00000021oc2mcql7sn5e
+ admin
+ admin
+ RGDNVW
+ 197
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP/1.1 404 Not Found
+
+
+
diff --git a/Tests/RainmakerTests/Responses/32.0.3/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
new file mode 100644
index 0000000..d5ed16a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/file/PROPFIND/remote.php/dav/files/admin/Readme.md/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 207 Multi-Status
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/Responses/32.0.3/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Body.xml b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Body.xml
new file mode 100644
index 0000000..4b2468a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Body.xml
@@ -0,0 +1,5 @@
+
+
+ Sabre\DAV\Exception\NotFound
+ File with name /This does not exist could not be located
+
diff --git a/Tests/RainmakerTests/Responses/32.0.3/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Headers.txt b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Headers.txt
new file mode 100644
index 0000000..ecf3f6a
--- /dev/null
+++ b/Tests/RainmakerTests/Responses/32.0.3/InfoTests/inexistent/PROPFIND/remote.php/dav/files/admin/This%20does%20not%20exist/Headers.txt
@@ -0,0 +1,2 @@
+HTTP/1.1 404 Not Found
+Content-Type: application/xml; charset=utf-8
\ No newline at end of file
diff --git a/Tests/RainmakerTests/URLTestSession.swift b/Tests/RainmakerTests/URLTestSession.swift
index 2b494d5..0709fa8 100644
--- a/Tests/RainmakerTests/URLTestSession.swift
+++ b/Tests/RainmakerTests/URLTestSession.swift
@@ -112,6 +112,50 @@ public actor URLTestSession: Requesting {
return (bodyData, httpResponse)
}
+ public func download(for request: URLRequest, delegate _: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) {
+ guard let testResources else {
+ throw URLTestSessionError.testNotFound
+ }
+
+ guard let method = request.httpMethod else {
+ throw URLTestSessionError.missingValue
+ }
+
+ guard let url = request.url else {
+ throw URLTestSessionError.missingValue
+ }
+
+ let requestPath = url.path(percentEncoded: false)
+
+ let requestResources = testResources
+ .appending(component: method)
+ .appending(path: requestPath)
+
+ let headersFile = requestResources
+ .appending(component: "Headers")
+ .appendingPathExtension("txt")
+
+ logger.debug("Assuming headers file: \(headersFile.percentEncodedPath)")
+ let headersData = try readDataFromPercentEncodedPath(at: headersFile)
+
+ let bodyFile = requestResources
+ .appending(component: "Body")
+ .appendingPathExtension("bin")
+
+ logger.debug("Assuming body file: \(bodyFile.percentEncodedPath)")
+ let bodyData = try readDataFromPercentEncodedPath(at: bodyFile)
+
+ guard let httpResponse = try HTTPURLResponse(from: headersData, for: url) else {
+ throw URLTestSessionError.missingValue
+ }
+
+ let temporaryFile = FileManager.default.temporaryDirectory
+ .appending(component: UUID().uuidString)
+ try bodyData.write(to: temporaryFile)
+
+ return (temporaryFile, httpResponse)
+ }
+
///
/// To have a safe and consistent path conversion for fixture files based on request paths, this is required to avoid double encoding as it may happen when loading `Data` directly from a `URL` with the designated initializer.
///