Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Sources/Rainmaker/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ extension Server: Serving {
}
}

public func enumerate(at path: String, recursively: Bool) async throws -> [Item] {
var items = [Item]()
let stream: AsyncThrowingStream<Item, Error> = try await enumerate(at: path, recursively: recursively)

for try await item in stream {
items.append(item)
}

return items
}
Comment thread
i2h3 marked this conversation as resolved.

public func login() async throws -> LoginFlow {
logger.debug("Fetching login information...")

Expand Down
16 changes: 16 additions & 0 deletions Sources/Rainmaker/Serving.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ protocol Serving: Sendable {
///
func enumerate(at path: String, recursively: Bool) async throws -> AsyncThrowingStream<Item, Error>

///
/// A convenience wrapper that aggregates all items first before returning.
///
/// > Warning: It is recommended to use the equally named streaming alternative which returns an `AsyncThrowingStream` whenever possible.
/// Using this method may result in high memory peaks in case of large hierarchies in recursive enumeration.
///
/// - Parameters:
/// - path: The root directory to enter.
/// - recursively: Whether subdirectories should be traversed, too.
///
/// - Returns: All items found at the given path (and, optionally, in its subdirectories) collected in an array.
///
/// - Throws: Any error that might occur during the listing of a remote directory.
///
func enumerate(at path: String, recursively: Bool) async throws -> [Item]

///
/// Look up the login flow information.
///
Expand Down
7 changes: 1 addition & 6 deletions Sources/RainmakerCLI/Commands/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,7 @@ struct List: AsyncParsableCommand {
}

let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue)
let stream = try await server.enumerate(at: path, recursively: recursive)
var items = [Item]()

for try await item in stream {
items.append(item)
}
let items: [Item] = try await server.enumerate(at: path, recursively: recursive)

switch formatArguments.outputFormat {
case .json:
Expand Down
17 changes: 6 additions & 11 deletions Tests/RainmakerTests/Extensions/HTTPURLResponse+init.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,24 @@ extension HTTPURLResponse {
/// More comprehensive and robust implementations by others have been left out intentionally for now.
///
/// - Parameters:
/// - file: The fixture to parse for the values.
/// - data: The raw HTTP response header data to parse.
/// - request: The original URL request this information is supposed to be initialized for.
///
convenience init?(from file: URL, for request: URL) throws {
guard FileManager.default.fileExists(atPath: file.path()) else {
throw RainmakerTestsError.missingFixture(file)
}

let data = try Data(contentsOf: file)

convenience init?(from data: Data, for request: URL) throws {
guard let text = String(data: data, encoding: .utf8) else {
throw RainmakerTestsError.decodingError(file)
throw RainmakerTestsError.decodingError(request)
}

let lines = text.split(separator: "\n")

guard let status = lines.first?.split(separator: " ") else {
throw RainmakerTestsError.decodingError(file)
throw RainmakerTestsError.decodingError(request)
}

let httpVersion = String(status[0])

guard let statusCode = Int(status[1]) else {
throw RainmakerTestsError.decodingError(file)
throw RainmakerTestsError.decodingError(request)
}

var headerFields = [String: String]()
Expand Down
19 changes: 19 additions & 0 deletions Tests/RainmakerTests/Extensions/URL+percentEncodedPath.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation

extension URL {
///
/// A convenience method to encode the path of a URL with a custom set of allowed characters to ensure maximum file system compatibility for test fixture paths.
///
var percentEncodedPath: String {
var allowedCharacters = CharacterSet.alphanumerics
allowedCharacters.insert(charactersIn: ".")
allowedCharacters.insert(charactersIn: "-")
allowedCharacters.insert(charactersIn: "_")
allowedCharacters.insert(charactersIn: "/")
Comment thread
i2h3 marked this conversation as resolved.

return path(percentEncoded: false).addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? path(percentEncoded: false)
}
}
101 changes: 77 additions & 24 deletions Tests/RainmakerTests/ListingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,49 +14,102 @@ import Testing
let server = try makeServer(user: nil, password: nil, serverVersion: serverVersion)

await #expect(throws: RainmakerError.credentialsRequired) {
_ = try await server.enumerate(at: "/", recursively: false)
let _: [Item] = try await server.enumerate(at: "/", recursively: false)
}
}

@Test("List Root Folder Content", arguments: ServerVersion.allCases)
func listRootFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let stream = try await server.enumerate(at: "/", recursively: false)
var items = [Item]()

for try await item in stream {
items.append(item)
}

#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Readme.md" })
let items: [Item] = try await server.enumerate(at: "/", recursively: false)
let readme = try #require(items.first { $0.name == "Readme.md" })
#expect(readme.path == "/Readme.md")
#expect(readme.href.path() == "/remote.php/dav/files/admin/Readme.md")
}

@Test("List Documents Folder Content", arguments: ServerVersion.allCases)
func listDocumentsFolderContent(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
let stream = try await server.enumerate(at: "/Documents", recursively: false)
var items = [Item]()

for try await item in stream {
items.append(item)
}

#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Documents/Example.md" })
let items: [Item] = try await server.enumerate(at: "/Documents", recursively: false)
let example = try #require(items.first { $0.name == "Example.md" })
#expect(example.path == "/Documents/Example.md")
#expect(example.href.path() == "/remote.php/dav/files/admin/Documents/Example.md")
}

@Test("List All Content Recursively and Asynchronously", arguments: ServerVersion.allCases)
func listAllContentRecursivelyAndAsynchronously(_ serverVersion: ServerVersion) async throws {
let server = try makeServer(serverVersion: serverVersion)
var items = [Item]()
let stream: AsyncThrowingStream<Item, Error> = try await server.enumerate(at: "/", recursively: true)

for try await item in stream {
items.append(item)
}

let items: [Item] = try await server.enumerate(at: "/", recursively: true)
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Readme.md" })
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Documents/Example.md" })
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Photos/Frog.jpg" })
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Templates/Brainstorming.whiteboard" })
}

@Test("List Whitespace Folder Content", 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)
#expect(items.contains { $0.name == ":" })
#expect(items.contains { $0.name == "?" })
#expect(items.contains { $0.name == "&" })
#expect(items.contains { $0.name == "#" })
#expect(items.contains { $0.name == "%" })
}

@Test("List Percent-encoded Folder Content", 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)
#expect(items.contains { $0.name == ":" })
#expect(items.contains { $0.name == "?" })
#expect(items.contains { $0.name == "&" })
#expect(items.contains { $0.name == "#" })
#expect(items.contains { $0.name == "%" })
}

@Test("List : Folder Content", 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)
let readme = try #require(items.first { $0.name == "Readme.md" })
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/:/Readme.md")
#expect(readme.path == "/Special Characters/:/Readme.md")
}

@Test("List ? Folder Content", 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)
let readme = try #require(items.first { $0.name == "Readme.md" })
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%3f/Readme.md")
#expect(readme.path == "/Special Characters/?/Readme.md")
}

@Test("List & Folder Content", 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)
let readme = try #require(items.first { $0.name == "Readme.md" })
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%26/Readme.md")
#expect(readme.path == "/Special Characters/&/Readme.md")
}

@Test("List # Folder Content", 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)
let readme = try #require(items.first { $0.name == "Readme.md" })
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%23/Readme.md")
#expect(readme.path == "/Special Characters/#/Readme.md")
}

@Test("List % Folder Content", 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)
let readme = try #require(items.first { $0.name == "Readme.md" })
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%25/Readme.md")
#expect(readme.path == "/Special Characters/%/Readme.md")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/admin/Special%20Characters/%26/</d:href>
<d:propstat>
<d:prop>
<d:creationdate>1970-01-01T00:00:00+00:00</d:creationdate>
<d:displayname>&amp;</d:displayname>
<d:getetag>"6966648ee0be6"</d:getetag>
<d:getlastmodified>Tue, 13 Jan 2026 15:28:14 GMT</d:getlastmodified>
<d:quota-available-bytes>-3</d:quota-available-bytes>
<d:quota-used-bytes>554</d:quota-used-bytes>
<d:resourcetype>
<d:collection/>
</d:resourcetype>
<nc:has-preview>false</nc:has-preview>
<nc:hidden>false</nc:hidden>
<nc:is-mount-root>false</nc:is-mount-root>
<oc:comments-count>0</oc:comments-count>
<oc:comments-href>/remote.php/dav/comments/files/442</oc:comments-href>
<oc:comments-unread>0</oc:comments-unread>
<oc:favorite>0</oc:favorite>
<oc:fileid>442</oc:fileid>
<oc:id>00000442ocvseob65bkf</oc:id>
<oc:owner-display-name>admin</oc:owner-display-name>
<oc:owner-id>admin</oc:owner-id>
<oc:permissions>RGDNVCK</oc:permissions>
<oc:size>554</oc:size>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:getcontenttype/>
<nc:is-encrypted/>
<nc:lock/>
<nc:lock-owner/>
<nc:lock-owner-displayname/>
<nc:lock-owner-editor/>
<nc:lock-owner-type/>
<nc:lock-time/>
<nc:lock-timeout/>
<nc:lock-token/>
<nc:upload_time/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/admin/Special%20Characters/%26/Readme.md</d:href>
<d:propstat>
<d:prop>
<d:creationdate>1970-01-01T00:00:00+00:00</d:creationdate>
<d:displayname>Readme.md</d:displayname>
<d:getcontenttype>text/markdown</d:getcontenttype>
<d:getetag>"47645c72bff9c92ebf83edf2807916e6"</d:getetag>
<d:getlastmodified>Tue, 13 Jan 2026 15:28:14 GMT</d:getlastmodified>
<d:resourcetype/>
<nc:has-preview>true</nc:has-preview>
<nc:hidden>false</nc:hidden>
<nc:is-mount-root>false</nc:is-mount-root>
<nc:upload_time>0</nc:upload_time>
<oc:comments-count>0</oc:comments-count>
<oc:comments-href>/remote.php/dav/comments/files/467</oc:comments-href>
<oc:comments-unread>0</oc:comments-unread>
<oc:favorite>0</oc:favorite>
<oc:fileid>467</oc:fileid>
<oc:id>00000467ocvseob65bkf</oc:id>
<oc:owner-display-name>admin</oc:owner-display-name>
<oc:owner-id>admin</oc:owner-id>
<oc:permissions>RGDNVW</oc:permissions>
<oc:size>554</oc:size>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:quota-available-bytes/>
<d:quota-used-bytes/>
<nc:is-encrypted/>
<nc:lock/>
<nc:lock-owner/>
<nc:lock-owner-displayname/>
<nc:lock-owner-editor/>
<nc:lock-owner-type/>
<nc:lock-time/>
<nc:lock-timeout/>
<nc:lock-token/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
HTTP/1.1 207 Multi-Status
Content-Type: application/xml; charset=utf-8
Loading