Files
Termind/FrameworkTests/SFTPRemoteConflictResolverTests.swift
T

248 lines
11 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
@Suite @MainActor
struct SFTPRemoteConflictResolverTests {
@Test(arguments: [false, true])
func preflightPreservesTheCallersRootCountingRule(includeRoot: Bool) async throws {
let root = "/dest/folder"
let client = RemoteConflictFileSystem(files: [
conflictFile(root), conflictFile(root + "-other", directory: true),
])
let result = try await SFTPRemoteConflictResolver.preflight(
directories: [root + "/nested", root, root],
targets: [root, root + "/nested/file", root + "-other"],
includeConflictingRoot: includeRoot, client: client)
let expectedKinds: [Bool?] = includeRoot ? [nil, nil, true] : [false, nil, true]
#expect(result.directories == [root, root, root + "/nested"])
#expect(result.directoryConflicts == [root])
#expect(result.targetKinds == expectedKinds)
#expect(result.conflictCount == (includeRoot ? 2 : 4))
let expectedCalls: [RemoteConflictCall] =
includeRoot
? [.attributes(root), .attributes(root + "-other")]
: [.attributes(root), .attributes(root), .attributes(root), .attributes(root + "-other")]
#expect(await client.operations == expectedCalls)
}
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth])
func directoryPoliciesPreserveSubtreeMappingAndMutationAccounting(policy: TransferConflictPolicy) async throws {
let root = "/dest/folder"
let client = RemoteConflictFileSystem(files: [conflictFile(root), conflictFile(root + ".2", directory: true)])
let result = try await SFTPRemoteConflictResolver.resolveDirectories(
[root, root + "/nested"], conflicts: [root], policy: policy, client: client)
#expect(result.destination(for: root + "-other/file") == root + "-other/file")
switch policy {
case .skip:
#expect(result.skipped == [root])
#expect(result.destination(for: root + "/nested/file") == nil)
#expect(result.created.isEmpty)
#expect(!result.changed)
#expect(await client.operations.isEmpty)
case .replace:
#expect(result.mappings.isEmpty)
#expect(result.created == [root, root + "/nested"])
#expect(result.destination(for: root + "/nested/file") == root + "/nested/file")
#expect(result.changed)
#expect(await client.operations.first == .removeFile(root))
case .keepBoth:
#expect(result.mappings.map(\.0) == [root])
#expect(result.mappings.map(\.1) == [root + ".3"])
#expect(result.created == [root + ".3", root + ".3/nested"])
#expect(result.destination(for: root + "/nested/file") == root + ".3/nested/file")
#expect(result.changed)
#expect(try await client.attributes(at: root)?.isDirectory == false)
}
}
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth])
func filePoliciesPreserveOverwriteAndExtensionAwareKeepBoth(policy: TransferConflictPolicy) async throws {
let path = "/dest/report.tar.gz"
let client = RemoteConflictFileSystem(files: [conflictFile(path), conflictFile("/dest/report.tar.2.gz")])
let result = try await SFTPRemoteConflictResolver.resolveConflict(
path: path, conflictingTargetIsDirectory: false, policy: policy, client: client)
switch policy {
case .skip:
#expect(result == nil)
#expect(await client.operations.isEmpty)
case .replace:
#expect(result?.path == path)
#expect(result?.overwrite == true)
#expect(await client.operations.isEmpty)
case .keepBoth:
#expect(result?.path == "/dest/report.tar.3.gz")
#expect(result?.overwrite == false)
#expect(
await client.operations == [.attributes("/dest/report.tar.2.gz"), .attributes("/dest/report.tar.3.gz")])
}
}
@Test(arguments: ["/dest/folder", "/"])
func replacingADirectoryDeletesItsChildrenBeforeTheirParents(root: String) async throws {
let first = RemotePath.join(root, "a")
let nested = RemotePath.join(root, "nested")
let second = RemotePath.join(nested, "b")
let client = RemoteConflictFileSystem(files: [
conflictFile(root, directory: true), conflictFile(first),
conflictFile(nested, directory: true), conflictFile(second),
])
let result = try await SFTPRemoteConflictResolver.resolveConflict(
path: root, conflictingTargetIsDirectory: true, policy: .replace, client: client)
#expect(result?.path == root)
#expect(result?.overwrite == true)
#expect(
await client.operations == [
.attributes(root), .list(root), .removeFile(first), .list(nested),
.removeFile(second), .removeDirectory(nested), .removeDirectory(root),
])
}
@Test(arguments: [
conflictFile("/outside/file"),
conflictFile("/dest/folder/..", name: ".."),
conflictFile("/dest/folder/bad\nname", name: "bad\nname"),
])
func replacingADirectoryRejectsUnsafeChildrenWithoutDeletingThem(child: RemoteFile) async throws {
let root = "/dest/folder"
let client = RemoteConflictFileSystem(files: [conflictFile(root, directory: true)], children: [root: [child]])
do {
_ = try await SFTPRemoteConflictResolver.resolveConflict(
path: root, conflictingTargetIsDirectory: true, policy: .replace, client: client)
Issue.record("Expected unsafe remote entry rejection")
} catch let error as SFTPRemoteCopySourceError {
#expect(error.path == child.path)
}
#expect(await client.operations == [.attributes(root), .list(root)])
}
@Test(arguments: ["directories", "name-search", "recursive-delete"])
func cancellationStopsAtTheExistingCopyCheckpoints(point: String) async {
let root = "/dest/report"
let cancelOn: RemoteConflictCall? =
switch point {
case "name-search": .attributes(root + ".2")
case "recursive-delete": .list(root)
default: nil
}
let client = RemoteConflictFileSystem(
files: [conflictFile(root, directory: true), conflictFile(root + "/child"), conflictFile(root + ".2")],
cancelOn: cancelOn)
let task = Task {
switch point {
case "directories":
withUnsafeCurrentTask { $0?.cancel() }
_ = try await SFTPRemoteConflictResolver.resolveDirectories(
[root], conflicts: [root], policy: .replace, client: client)
case "name-search":
_ = try await SFTPRemoteConflictResolver.resolveConflict(
path: root, conflictingTargetIsDirectory: false, policy: .keepBoth, client: client)
default:
_ = try await SFTPRemoteConflictResolver.resolveConflict(
path: root, conflictingTargetIsDirectory: true, policy: .replace, client: client)
}
}
await #expect(throws: CancellationError.self) { try await task.value }
let expectedCalls: [RemoteConflictCall] =
switch point {
case "name-search": [.attributes(root + ".2")]
case "recursive-delete": [.attributes(root), .list(root)]
default: []
}
#expect(await client.operations == expectedCalls)
}
@Test
func anExistingDirectoryDoesNotReportAMutation() async throws {
let root = "/dest/folder"
let client = RemoteConflictFileSystem(files: [conflictFile(root, directory: true)])
let result = try await SFTPRemoteConflictResolver.resolveDirectories(
[root], conflicts: [], policy: .replace, client: client)
#expect(!result.changed)
#expect(result.created.isEmpty)
#expect(result.destination(for: root + "/file") == root + "/file")
}
}
private nonisolated enum RemoteConflictCall: Equatable, Sendable {
case attributes(String)
case list(String)
case createDirectory(String)
case removeFile(String)
case removeDirectory(String)
}
private nonisolated func conflictFile(_ path: String, directory: Bool = false, name: String? = nil) -> RemoteFile {
RemoteFile(
path: path, name: name ?? URL(fileURLWithPath: path).lastPathComponent,
kind: directory ? .directory : .file, size: directory ? 0 : 1,
permissions: directory ? 0o755 : 0o644, ownerUID: 501, ownerGID: 20)
}
private actor RemoteConflictFileSystem: RemoteFileSystem {
private var files: [String: RemoteFile]
private let children: [String: [RemoteFile]]
private let cancelOn: RemoteConflictCall?
private(set) var operations: [RemoteConflictCall] = []
init(files: [RemoteFile], children: [String: [RemoteFile]] = [:], cancelOn: RemoteConflictCall? = nil) {
self.files = Dictionary(uniqueKeysWithValues: files.map { ($0.path, $0) })
self.children = children
self.cancelOn = cancelOn
}
func attributes(at path: String) async throws -> RemoteFile? {
record(.attributes(path))
return files[path]
}
func listDirectory(at path: String) async throws -> [RemoteFile] {
record(.list(path))
return children[path]
?? files.values.filter { $0.path != path && RemotePath.parent(of: $0.path) == path }
.sorted { $0.path < $1.path }
}
func createDirectory(at path: String) async throws {
record(.createDirectory(path))
guard files[path] == nil else { throw FixtureError.unexpectedCall }
files[path] = conflictFile(path, directory: true)
}
func removeFile(at path: String) async throws {
record(.removeFile(path))
guard files[path]?.isDirectory == false else { throw FixtureError.unexpectedCall }
files[path] = nil
}
func removeDirectory(at path: String) async throws {
record(.removeDirectory(path))
let prefix = path == "/" ? "/" : path + "/"
guard files[path]?.isDirectory == true, !files.keys.contains(where: { $0 != path && $0.hasPrefix(prefix) })
else {
throw FixtureError.unexpectedCall
}
files[path] = nil
}
func readFile(at path: String, maximumBytes: Int) async throws -> Data { throw FixtureError.unexpectedCall }
func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { throw FixtureError.unexpectedCall }
func setPermissions(at path: String, to permissions: UInt32) async throws { throw FixtureError.unexpectedCall }
func rename(from source: String, to destination: String, overwrite: Bool) async throws {
throw FixtureError.unexpectedCall
}
private func record(_ operation: RemoteConflictCall) {
operations.append(operation)
if operation == cancelOn { withUnsafeCurrentTask { $0?.cancel() } }
}
private enum FixtureError: Error { case unexpectedCall }
}