PROPFIND walk / diff / GET / PUT mirror with conflicts, per-account state, BGTaskScheduler background runs, and conflict notifications with Keep local / Use server. Reviewed by Codex against the Kotlin original; its data-loss findings are fixed and each has a regression test (fake-server end-to-end tests, 68 Swift tests total): truncated/unusable PROPFIND answers are never a manifest, unreadable state aborts the run, server paths can't leave the mirror, case/Unicode collisions are skipped, atomic downloads, a locally edited file deleted on the server is kept, '/' scope matching, serialised removal, tracked/cancellable background runs, per-account conflicts, and sign-out vs background-off credentials (cancel gains a 'forget' flag). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
295 lines
11 KiB
Swift
295 lines
11 KiB
Swift
import Foundation
|
|
|
|
/// Credentials for one account's WebDAV root.
|
|
struct SyncCredentials {
|
|
let serverUrl: String
|
|
let username: String
|
|
let authHeader: String
|
|
}
|
|
|
|
/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item),
|
|
/// a recursive walk, and GET/PUT of a single file. Foreground
|
|
/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a
|
|
/// background session, so a run can walk, diff and decide in one go.
|
|
///
|
|
/// Authenticates by each request's own `Authorization` header only (no cookies,
|
|
/// no credential cache): two accounts can share a server, and one account's
|
|
/// session must never answer for another.
|
|
final class DavSyncClient {
|
|
private let session: URLSession
|
|
|
|
init(session: URLSession = DavSyncClient.makeSession()) {
|
|
self.session = session
|
|
}
|
|
|
|
static func makeSession() -> URLSession {
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.httpShouldSetCookies = false
|
|
config.httpCookieAcceptPolicy = .never
|
|
config.urlCredentialStorage = nil
|
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
config.timeoutIntervalForRequest = 30
|
|
config.timeoutIntervalForResource = 15 * 60
|
|
return URLSession(configuration: config)
|
|
}
|
|
|
|
// MARK: - PROPFIND
|
|
|
|
private static let propfindBody = """
|
|
<?xml version="1.0" encoding="utf-8" ?>
|
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
|
<d:prop>
|
|
<d:getlastmodified/>
|
|
<d:getcontentlength/>
|
|
<d:resourcetype/>
|
|
<d:getetag/>
|
|
<oc:fileid/>
|
|
</d:prop>
|
|
</d:propfind>
|
|
"""
|
|
|
|
/// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the
|
|
/// folder is genuinely gone) is an empty list; anything else that goes wrong
|
|
/// throws `SyncRemoteUnavailable`.
|
|
func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] {
|
|
try await propfind(creds, path: path, depth: "1", skipSelf: true)
|
|
}
|
|
|
|
/// Depth-0 PROPFIND of [path] itself - nil on a clean 404.
|
|
func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? {
|
|
try await propfind(creds, path: path, depth: "0", skipSelf: false).first
|
|
}
|
|
|
|
private func propfind(
|
|
_ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool
|
|
) async throws -> [SyncRemoteEntry] {
|
|
var clean = path.trimmingCharacters(in: .whitespaces)
|
|
if !clean.hasPrefix("/") { clean = "/" + clean }
|
|
guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean)
|
|
else { throw SyncRemoteUnavailable(message: "Invalid server address.") }
|
|
// Collections are addressed with a trailing slash.
|
|
if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "PROPFIND"
|
|
request.setValue(depth, forHTTPHeaderField: "Depth")
|
|
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
|
request.httpBody = Self.propfindBody.data(using: .utf8)
|
|
|
|
let data: Data
|
|
let status: Int
|
|
do {
|
|
let (body, response) = try await session.data(for: request)
|
|
data = body
|
|
status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
} catch {
|
|
try Self.rethrowIfCancelled(error)
|
|
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)")
|
|
}
|
|
if status == 404 { return [] } // genuinely gone
|
|
guard status == 207 else {
|
|
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)")
|
|
}
|
|
return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf)
|
|
}
|
|
|
|
/// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat
|
|
/// manifest. Throws if *any* level fails - a partial manifest would look
|
|
/// like the missing part had been deleted on the server.
|
|
func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] {
|
|
var result: [SyncRemoteEntry] = []
|
|
var queue = [root]
|
|
var index = 0
|
|
while index < queue.count {
|
|
try Task.checkCancellation()
|
|
let children = try await propfindChildren(creds, path: queue[index])
|
|
index += 1
|
|
for child in children {
|
|
result.append(child)
|
|
if child.isFolder { queue.append(child.path) }
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - GET / PUT
|
|
|
|
/// Downloads [remotePath] to [destination]. Written to a temp file first and
|
|
/// moved into place, so an interrupted download never leaves a truncated
|
|
/// file where a good one used to be. False on any failure.
|
|
func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool {
|
|
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
|
else { return false }
|
|
var request = URLRequest(url: url)
|
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
|
do {
|
|
let (temp, response) = try await session.download(for: request)
|
|
guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else {
|
|
try? FileManager.default.removeItem(at: temp)
|
|
return false
|
|
}
|
|
try Task.checkCancellation()
|
|
let fm = FileManager.default
|
|
try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
var isDirectory: ObjCBool = false
|
|
if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) {
|
|
// Never replace a folder with a file, and replace a file atomically:
|
|
// the old copy stays until the new one is fully in place.
|
|
guard !isDirectory.boolValue else {
|
|
try? fm.removeItem(at: temp)
|
|
return false
|
|
}
|
|
_ = try fm.replaceItemAt(destination, withItemAt: temp)
|
|
} else {
|
|
try fm.moveItem(at: temp, to: destination)
|
|
}
|
|
return true
|
|
} catch {
|
|
try Self.rethrowIfCancelled(error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// PUTs [source] to [remotePath]. False on any failure.
|
|
func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool {
|
|
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
|
else { return false }
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "PUT"
|
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
|
do {
|
|
let (_, response) = try await session.upload(for: request, fromFile: source)
|
|
guard let status = (response as? HTTPURLResponse)?.statusCode else { return false }
|
|
return (200..<300).contains(status)
|
|
} catch {
|
|
try Self.rethrowIfCancelled(error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// A cancelled run must stop, not be mistaken for a flaky network.
|
|
private static func rethrowIfCancelled(_ error: Error) throws {
|
|
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
|
|
throw CancellationError()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's
|
|
/// unit-tested.
|
|
enum DavSyncParser {
|
|
private struct Raw {
|
|
var href = ""
|
|
var etag = ""
|
|
var modified = ""
|
|
var length = ""
|
|
var fileId = ""
|
|
var isCollection = false
|
|
}
|
|
|
|
private final class Delegate: NSObject, XMLParserDelegate {
|
|
var responses: [Raw] = []
|
|
var sawMultistatus = false
|
|
private var current: Raw?
|
|
private var text = ""
|
|
|
|
func parser(
|
|
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
|
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
|
) {
|
|
text = ""
|
|
if elementName == "multistatus" { sawMultistatus = true }
|
|
if elementName == "response" { current = Raw() }
|
|
if elementName == "collection" { current?.isCollection = true }
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
text += string
|
|
}
|
|
|
|
func parser(
|
|
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
|
qualifiedName qName: String?
|
|
) {
|
|
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
switch elementName {
|
|
case "href": current?.href = value
|
|
case "getetag": current?.etag = value
|
|
case "getlastmodified": current?.modified = value
|
|
case "getcontentlength": current?.length = value
|
|
case "fileid": current?.fileId = value
|
|
case "response":
|
|
if let current { responses.append(current) }
|
|
current = nil
|
|
default: break
|
|
}
|
|
text = ""
|
|
}
|
|
}
|
|
|
|
private static let httpDate: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
|
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
|
return f
|
|
}()
|
|
|
|
/// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for
|
|
/// anything that isn't a complete, plausible answer - malformed or
|
|
/// truncated XML, no `multistatus` envelope, no responses at all (a 207
|
|
/// always has at least the requested resource), or an href that isn't
|
|
/// under this account's files root or tries to climb out of it. A partial
|
|
/// manifest would look like the missing part was deleted on the server
|
|
/// (and an empty one like the whole path was), so the caller must treat
|
|
/// those as "couldn't reach the server", never as data.
|
|
static func entries(
|
|
from xml: Data, username: String, requestedPath: String, skipSelf: Bool
|
|
) throws -> [SyncRemoteEntry] {
|
|
let delegate = Delegate()
|
|
let parser = XMLParser(data: xml)
|
|
parser.shouldProcessNamespaces = true
|
|
parser.delegate = delegate
|
|
guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else {
|
|
throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer")
|
|
}
|
|
|
|
let marker = "/remote.php/dav/files/\(username)"
|
|
let selfPath = trimTrailingSlashes(requestedPath)
|
|
|
|
var entries: [SyncRemoteEntry] = []
|
|
for raw in delegate.responses {
|
|
let decoded = raw.href.removingPercentEncoding ?? raw.href
|
|
guard let range = decoded.range(of: marker) else {
|
|
throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root")
|
|
}
|
|
let hrefPath = String(decoded[range.upperBound...])
|
|
// A well-formed path continues the marker at a component boundary and
|
|
// never contains a dot component.
|
|
guard hrefPath.isEmpty || hrefPath.hasPrefix("/"),
|
|
!hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." })
|
|
else {
|
|
throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path")
|
|
}
|
|
let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath)
|
|
if skipSelf && normalized == selfPath { continue } // the folder itself, not a child
|
|
entries.append(
|
|
SyncRemoteEntry(
|
|
path: normalized.isEmpty ? "/" : normalized,
|
|
fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId,
|
|
etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
|
|
lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0,
|
|
size: Int64(raw.length) ?? 0,
|
|
isFolder: raw.isCollection))
|
|
}
|
|
return entries
|
|
}
|
|
|
|
private static func trimTrailingSlashes(_ path: String) -> String {
|
|
var p = path
|
|
while p.hasSuffix("/") { p.removeLast() }
|
|
return p
|
|
}
|
|
}
|