diff --git a/README.md b/README.md index 4f8c3d9..15fee25 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. A clean checkout installs them with `npm ci`, without a source clone or sibling repository. The vendored set is the core release -[core-2.60.0-core.hb0d0b54f320a8e3f](https://github.com/ZenNotes/zennotes/releases/tag/core-2.60.0-core.hb0d0b54f320a8e3f) -(desktop commit `15829394`, clean tree). +[core-2.60.1-core.h05ebb55c14afffb2](https://github.com/ZenNotes/zennotes/releases/tag/core-2.60.1-core.h05ebb55c14afffb2) +(desktop commit `4c74b478`, clean tree). `npm run boundaries:check` verifies the pins, installed versions, singleton React/CodeMirror peers, and public export usage. `npm run core:adopt -- ` diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 4895593..04d555a 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -40,10 +40,12 @@ B1F519EA73BD94F20A3F1DD1 /* ICloudVaultPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA3D1BD7D10EE9B188D5FBF /* ICloudVaultPlugin.swift */; }; B516881658A9D3C9AFC1259E /* RecentNotesWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BE1DA68F6223E14C0761AA /* RecentNotesWidget.swift */; }; BA7E23B65E109265261E0F5E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 155E04D763AD80DA2E56D38D /* Foundation.framework */; }; + C10DF11E5A2B4C7D9E8F0A1B /* CloudFilesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10DF11F5A2B4C7D9E8F0A1C /* CloudFilesPlugin.swift */; }; D17E4E8551A97CF08FDE5F82 /* FolderPickerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A91962841901607F2906263 /* FolderPickerPlugin.swift */; }; DE19FCF1249C6A4D528227C2 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E1424FEA5946506EA505941C /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F59E347AA2D96EB117D7C2CD /* ZenWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */; }; FB56F003004840A2A553E931 /* KeyboardBackdropPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F5A2D64394F429AF0ED50 /* KeyboardBackdropPlugin.swift */; }; + FD65529207121AA61FF892C5 /* CloudFileStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9115A46C405EC261610EC467 /* CloudFileStream.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -112,10 +114,12 @@ 85847153B31EF561562A9281 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/SwiftUI.framework; sourceTree = DEVELOPER_DIR; }; 8A91962841901607F2906263 /* FolderPickerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FolderPickerPlugin.swift; sourceTree = ""; }; 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; + 9115A46C405EC261610EC467 /* CloudFileStream.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudFileStream.swift; sourceTree = ""; }; A24C226FEA7EA30D28C20477 /* ZNViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZNViewController.swift; sourceTree = ""; }; A24C2270EA7EA30D28C20477 /* SceneDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + C10DF11F5A2B4C7D9E8F0A1C /* CloudFilesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudFilesPlugin.swift; sourceTree = ""; }; C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZenWidgetsBundle.swift; sourceTree = ""; }; CC30A5CCEA5991955FBC0360 /* CloudFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudFlowUITests.swift; sourceTree = ""; }; CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeepLinkUITests.swift; sourceTree = ""; }; @@ -223,6 +227,8 @@ 8A91962841901607F2906263 /* FolderPickerPlugin.swift */, D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */, 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */, + C10DF11F5A2B4C7D9E8F0A1C /* CloudFilesPlugin.swift */, + 9115A46C405EC261610EC467 /* CloudFileStream.swift */, ); path = App; sourceTree = ""; @@ -515,6 +521,8 @@ B1F519EA73BD94F20A3F1DD1 /* ICloudVaultPlugin.swift in Sources */, D17E4E8551A97CF08FDE5F82 /* FolderPickerPlugin.swift in Sources */, 66714DDFC6EB0A9732DE176A /* WidgetBridgePlugin.swift in Sources */, + C10DF11E5A2B4C7D9E8F0A1B /* CloudFilesPlugin.swift in Sources */, + FD65529207121AA61FF892C5 /* CloudFileStream.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/CloudFileStream.swift b/ios/App/App/CloudFileStream.swift new file mode 100644 index 0000000..b624574 --- /dev/null +++ b/ios/App/App/CloudFileStream.swift @@ -0,0 +1,144 @@ +import CryptoKit +import Foundation + +/// File integrity checks shared by the native plugin and host-side regression tests. +enum CloudFileStream { + static let bufferSize = 64 * 1024 + static let inlineLimit = 5 * 1024 * 1024 + struct Fingerprint { let byteLength: Int; let sha256: String; let utf8: Bool; let inline: Data? } + struct Expectation { let byteLength: Int; let sha256: String } + enum Failure: Error { case unreadable, lengthMismatch, hashMismatch, overflow } + + static func fingerprint(_ url: URL, textCandidate: Bool) throws -> Fingerprint { + guard let input = InputStream(url: url) else { throw Failure.unreadable } + input.open(); defer { input.close() } + guard input.streamStatus != .error else { throw input.streamError ?? Failure.unreadable } + var hasher = SHA256() + var length = 0 + var inline: Data? = Data() + var utf8Check = textCandidate ? Utf8Validator() : nil + var buffer = [UInt8](repeating: 0, count: bufferSize) + while true { + let count = input.read(&buffer, maxLength: buffer.count) + if count < 0 { throw input.streamError ?? Failure.unreadable } + if count == 0 { break } + length += count + buffer.withUnsafeBufferPointer { hasher.update(bufferPointer: UnsafeRawBufferPointer(UnsafeBufferPointer(rebasing: $0.prefix(count)))) } + if inline != nil { + if length <= inlineLimit { inline!.append(buffer, count: count) } else { inline = nil } + } + utf8Check?.accept(buffer.prefix(count)) + } + let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + return Fingerprint(byteLength: length, sha256: digest, utf8: utf8Check?.finish() ?? false, inline: inline) + } + + static func copyVerified(_ input: InputStream, to destination: URL, expected: Expectation) throws { + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + input.open() + defer { input.close() } + guard input.streamStatus != .error else { throw input.streamError ?? Failure.unreadable } + guard let output = OutputStream(url: destination, append: false) else { throw Failure.unreadable } + output.open() + defer { output.close() } + guard output.streamStatus != .error else { throw output.streamError ?? Failure.unreadable } + var hasher = SHA256() + var written = 0 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while true { + let count = input.read(&buffer, maxLength: buffer.count) + if count < 0 { throw input.streamError ?? Failure.unreadable } + if count == 0 { break } + written += count + if written > expected.byteLength { throw Failure.overflow } + buffer.withUnsafeBufferPointer { hasher.update(bufferPointer: UnsafeRawBufferPointer(UnsafeBufferPointer(rebasing: $0.prefix(count)))) } + var offset = 0 + while offset < count { + let out = buffer.withUnsafeBufferPointer { + output.write($0.baseAddress!.advanced(by: offset), maxLength: count - offset) + } + if out <= 0 { throw output.streamError ?? Failure.unreadable } + offset += out + } + } + if written != expected.byteLength { throw Failure.lengthMismatch } + let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + if digest != expected.sha256 { throw Failure.hashMismatch } + } + + static func confinedURL(_ value: String, roots: [URL]) -> URL? { + guard let url = URL(string: value), url.isFileURL else { return nil } + guard !url.pathComponents.contains(".."), let resolved = canonicalDestination(url) else { return nil } + let path = resolved.path + return roots.contains { root in + let rootPath = root.resolvingSymlinksInPath().standardizedFileURL.path + return path.hasPrefix(rootPath.hasSuffix("/") ? rootPath : rootPath + "/") + } ? resolved : nil + } + + static func allowedHeader(_ name: String, value: String, url: URL) -> Bool { + guard name.range(of: "^[A-Za-z0-9-]+$", options: .regularExpression) != nil, + name.rangeOfCharacter(from: .newlines) == nil, + value.rangeOfCharacter(from: .newlines) == nil else { return false } + if name.lowercased() == "host" { + guard let host = url.host else { return false } + let authority = host + (url.port.map { ":\($0)" } ?? "") + return value.lowercased() == authority.lowercased() + } + return !["authorization", "proxy-authorization", "cookie", "connection", "transfer-encoding"].contains(name.lowercased()) + } + + private static func canonicalDestination(_ url: URL) -> URL? { + var ancestor = url.standardizedFileURL + var missing: [String] = [] + let fm = FileManager.default + // Foundation does not resolve a symlinked parent when the leaf does + // not exist. Resolve the nearest existing ancestor before adding it. + while !fm.fileExists(atPath: ancestor.path) { + if (try? fm.attributesOfItem(atPath: ancestor.path)[.type]) as? FileAttributeType == .typeSymbolicLink { + return nil + } + guard ancestor.path != "/" else { return nil } + missing.append(ancestor.lastPathComponent) + ancestor.deleteLastPathComponent() + } + var resolved = ancestor.resolvingSymlinksInPath().standardizedFileURL + for component in missing.reversed() { resolved.appendPathComponent(component) } + return resolved + } +} + +/// Incremental UTF-8 validation across chunk boundaries. +struct Utf8Validator { + private var remaining = 0 + private var lower: UInt8 = 0x80 + private var upper: UInt8 = 0xBF + private var valid = true + + mutating func accept(_ chunk: ArraySlice) { + guard valid else { return } + for byte in chunk { + if remaining > 0 { + guard byte >= lower && byte <= upper else { valid = false; return } + remaining -= 1 + lower = 0x80; upper = 0xBF + } else { + switch byte { + case 0...0x7F: break + case 0xC2...0xDF: remaining = 1 + case 0xE0: remaining = 2; lower = 0xA0 + case 0xE1...0xEC, 0xEE...0xEF: remaining = 2 + case 0xED: remaining = 2; upper = 0x9F + case 0xF0: remaining = 3; lower = 0x90 + case 0xF1...0xF3: remaining = 3 + case 0xF4: remaining = 3; upper = 0x8F + default: valid = false; return + } + } + } + } + + mutating func finish() -> Bool { + valid && remaining == 0 + } +} diff --git a/ios/App/App/CloudFilesPlugin.swift b/ios/App/App/CloudFilesPlugin.swift new file mode 100644 index 0000000..5bb1973 --- /dev/null +++ b/ios/App/App/CloudFilesPlugin.swift @@ -0,0 +1,219 @@ +import Capacitor +import Foundation + +/// Native file transfer for Cloud sync. Large vault files never cross the +/// WebView bridge: this plugin fingerprints them in place, copies them between +/// vault paths, and streams signed revision downloads to disk with length and +/// SHA-256 verified before anything is published. Same jsName and method shapes +/// as the Android ZenDirectUpload plugin so src/bridge/*.ts stays shared. +@objc(CloudFilesPlugin) +public class CloudFilesPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "CloudFilesPlugin" + public let jsName = "ZenDirectUpload" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "inspect", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "copy", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "put", returnType: CAPPluginReturnPromise) + ] + + private let queue = DispatchQueue(label: "md.zennotes.cloud-files", qos: .utility) + + @objc func inspect(_ call: CAPPluginCall) { + guard let uri = call.getString("uri"), let url = Self.vaultFileURL(uri) else { + call.reject("Invalid vault file URI.", "INVALID_URI") + return + } + let textCandidate = call.getBool("textCandidate") ?? false + queue.async { + do { + let result = try CloudFileStream.fingerprint(url, textCandidate: textCandidate) + var payload: [String: Any] = [ + "uri": uri, + "byteLength": result.byteLength, + "sha256": result.sha256, + "utf8": result.utf8 + ] + if let inline = result.inline { payload["inlineBase64"] = inline.base64EncodedString() } + call.resolve(payload) + } catch { + call.reject("Cloud file inspection failed.", "CLOUD_FILE_READ_FAILED", error) + } + } + } + + @objc func copy(_ call: CAPPluginCall) { + guard let from = call.getString("from").flatMap(Self.vaultFileURL), + let to = call.getString("to").flatMap(Self.vaultFileURL), + let expected = Self.expectation(call), from != to else { + call.reject("Invalid Cloud copy request.", "INVALID_COPY_REQUEST") + return + } + queue.async { + do { + guard let input = InputStream(url: from) else { throw CloudFileError.unreadable } + try CloudFileStream.copyVerified(input, to: to, expected: expected) + call.resolve() + } catch { + try? FileManager.default.removeItem(at: to) + call.reject("Cloud file copy failed.", "CLOUD_FILE_COPY_FAILED", error) + } + } + } + + /// Streams a signed GET into a vault staging file. Redirects are refused, + /// no account bearer is ever attached, and a mismatched length or hash + /// removes the partial file and rejects. + @objc func download(_ call: CAPPluginCall) { + guard let urlString = call.getString("url"), let url = URL(string: urlString), + let to = call.getString("to").flatMap(Self.vaultFileURL), + let expected = Self.expectation(call), Self.allowed(url) else { + call.reject("Invalid Cloud download request.", "INVALID_DOWNLOAD_REQUEST") + return + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 300 + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + for (name, value) in call.getObject("headers") ?? [:] { + guard let value = value as? String, + CloudFileStream.allowedHeader(name, value: value, url: url) else { + call.reject("Invalid signed download header.", "INVALID_DOWNLOAD_REQUEST") + return + } + request.setValue(value, forHTTPHeaderField: name) + } + let delegate = NoRedirectDelegate() + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + let task = session.downloadTask(with: request) { location, response, error in + defer { session.finishTasksAndInvalidate() } + if let error = error { + call.reject("Cloud object download failed.", "DIRECT_DOWNLOAD_FAILED", error) + return + } + guard let http = response as? HTTPURLResponse, let location = location else { + call.reject("Cloud object download failed.", "DIRECT_DOWNLOAD_FAILED") + return + } + guard (200..<300).contains(http.statusCode) else { + call.reject("Cloud object download failed (\(http.statusCode)).", "DIRECT_DOWNLOAD_FAILED", nil, ["status": http.statusCode]) + return + } + do { + guard let input = InputStream(url: location) else { throw CloudFileError.unreadable } + try CloudFileStream.copyVerified(input, to: to, expected: expected) + call.resolve() + } catch { + try? FileManager.default.removeItem(at: to) + call.reject("Cloud object download failed.", "DIRECT_DOWNLOAD_FAILED", error) + } + } + task.resume() + } + + /// Streams a vault file to its short-lived signed object URL with a fixed + /// Content-Length. A private, verified snapshot keeps subsequent local + /// edits from changing the bytes URLSession transmits. + @objc func put(_ call: CAPPluginCall) { + guard let urlString = call.getString("url"), let url = URL(string: urlString), + let source = call.getString("uri").flatMap(Self.vaultFileURL), + let expected = Self.expectation(call), Self.allowed(url) else { + call.reject("Invalid direct-upload request.", "INVALID_DIRECT_UPLOAD_REQUEST") + return + } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.timeoutInterval = 300 + request.setValue(String(expected.byteLength), forHTTPHeaderField: "Content-Length") + var hasType = false + for (name, value) in call.getObject("headers") ?? [:] { + guard let value = value as? String, CloudFileStream.allowedHeader(name, value: value, url: url) else { + call.reject("Invalid signed upload header.", "INVALID_DIRECT_UPLOAD_REQUEST") + return + } + if name.lowercased() == "content-length" { + guard value == String(expected.byteLength) else { + call.reject("Invalid upload length.", "INVALID_DIRECT_UPLOAD_REQUEST") + return + } + } + if name.lowercased() == "content-type" { hasType = true } + request.setValue(value, forHTTPHeaderField: name) + } + if !hasType { request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") } + queue.async { + let snapshot = FileManager.default.temporaryDirectory.appendingPathComponent("cloud-upload-\(UUID().uuidString)") + do { + guard let input = InputStream(url: source) else { throw CloudFileError.unreadable } + try CloudFileStream.copyVerified(input, to: snapshot, expected: expected) + } catch { + try? FileManager.default.removeItem(at: snapshot) + call.reject("The upload file changed after its scan.", "DIRECT_UPLOAD_FAILED", error) + return + } + let delegate = NoRedirectDelegate() + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + let task = session.uploadTask(with: request, fromFile: snapshot) { _, response, error in + defer { + try? FileManager.default.removeItem(at: snapshot) + session.finishTasksAndInvalidate() + } + if let error = error { + call.reject("Cloud object upload failed.", "DIRECT_UPLOAD_FAILED", error) + return + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + call.resolve(["status": status]) + } + task.resume() + } + } + + // MARK: - Streaming helpers + + typealias Expectation = CloudFileStream.Expectation + typealias CloudFileError = CloudFileStream.Failure + + /// External-folder vault roots whose security scope FolderPickerPlugin has + /// activated this session. Only these, Documents, and the ubiquity + /// container may be read or written; "any readable file" would let a + /// signed PUT exfiltrate preferences or another vault. + private static var externalVaultRoots: [String] = [] + private static let rootsLock = NSLock() + + static func registerExternalVaultRoot(_ url: URL) { + rootsLock.lock(); defer { rootsLock.unlock() } + let path = url.resolvingSymlinksInPath().standardizedFileURL.path + if !externalVaultRoots.contains(path) { externalVaultRoots.append(path) } + } + + static func vaultFileURL(_ value: String) -> URL? { + rootsLock.lock(); let external = externalVaultRoots; rootsLock.unlock() + let roots = ([ + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("ZenNotes"), + FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents/ZenNotes") + ].compactMap { $0 }) + external.map { URL(fileURLWithPath: $0) } + return CloudFileStream.confinedURL(value, roots: roots) + } + + static func expectation(_ call: CAPPluginCall) -> Expectation? { + guard let number = call.getDouble("byteLength"), number.isFinite, + number >= 0, number <= 200_000_000, number.rounded(.towardZero) == number, + let hash = call.getString("sha256"), hash.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { return nil } + return Expectation(byteLength: Int(number), sha256: hash) + } + + static func allowed(_ url: URL) -> Bool { + guard url.user == nil, url.password == nil, url.fragment == nil else { return false } + if url.scheme == "https" { return true } + let host = url.host?.lowercased() ?? "" + return url.scheme == "http" && (host == "localhost" || host == "::1" || host.hasPrefix("127.")) + } +} + +final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + } +} diff --git a/ios/App/App/FolderPickerPlugin.swift b/ios/App/App/FolderPickerPlugin.swift index 4f3c97a..7a30717 100644 --- a/ios/App/App/FolderPickerPlugin.swift +++ b/ios/App/App/FolderPickerPlugin.swift @@ -47,6 +47,7 @@ public class FolderPickerPlugin: CAPPlugin, CAPBridgedPlugin, UIDocumentPickerDe call.reject("Could not access the selected folder.") return } + CloudFilesPlugin.registerExternalVaultRoot(url) do { let bookmark = try url.bookmarkData( options: [], includingResourceValuesForKeys: nil, relativeTo: nil) @@ -82,6 +83,7 @@ public class FolderPickerPlugin: CAPPlugin, CAPBridgedPlugin, UIDocumentPickerDe call.reject("Access to the bookmarked folder was denied.") return } + CloudFilesPlugin.registerExternalVaultRoot(url) var result: [String: Any] = [ "url": url.absoluteString, "name": url.lastPathComponent diff --git a/ios/App/App/ZNViewController.swift b/ios/App/App/ZNViewController.swift index eada332..98afe17 100644 --- a/ios/App/App/ZNViewController.swift +++ b/ios/App/App/ZNViewController.swift @@ -16,6 +16,7 @@ class ZNViewController: CAPBridgeViewController { bridge?.registerPluginInstance(FolderPickerPlugin()) bridge?.registerPluginInstance(KeyboardBackdropPlugin()) bridge?.registerPluginInstance(WidgetBridgePlugin()) + bridge?.registerPluginInstance(CloudFilesPlugin()) } override open func viewDidAppear(_ animated: Bool) { diff --git a/package-lock.json b/package-lock.json index 088f9fc..1ac917f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.4", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.0-core.hb0d0b54f320a8e3f.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.1-core.h05ebb55c14afffb2.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.1-boundaries.ha881a2f8575a38bf.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.1-boundaries.ha881a2f8575a38bf.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.16", "function-plot": "^1.25.3", @@ -696,6 +696,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -712,6 +713,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -728,6 +730,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -744,6 +747,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -760,6 +764,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -776,6 +781,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -792,6 +798,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -808,6 +815,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -824,6 +832,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -840,6 +849,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -856,6 +866,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -872,6 +883,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -888,6 +900,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -904,6 +917,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -920,6 +934,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -936,6 +951,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -952,6 +968,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -968,6 +985,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -984,6 +1002,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1000,6 +1019,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1016,6 +1036,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1032,6 +1053,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1048,6 +1070,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1064,6 +1087,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1080,6 +1104,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1096,6 +1121,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2577,6 +2603,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2593,6 +2620,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2609,6 +2637,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2625,6 +2654,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2641,6 +2671,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2657,6 +2688,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2673,9 +2705,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2692,9 +2722,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2711,9 +2739,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2730,9 +2756,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2749,9 +2773,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2768,9 +2790,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2787,6 +2807,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2803,6 +2824,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2819,6 +2841,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3147,7 +3170,7 @@ "version": "22.20.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3328,9 +3351,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.60.0-core.hb0d0b54f320a8e3f", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.60.0-core.hb0d0b54f320a8e3f.tgz", - "integrity": "sha512-aMZ8DegEHQTwT4tf6kdvAHEUMfhi4nayN81lHKw4PSZZfDr4UKvhpufIZEm5xO4UR4gyKEJSwd4/1QNBB+aC+Q==", + "version": "2.60.1-core.h05ebb55c14afffb2", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.60.1-core.h05ebb55c14afffb2.tgz", + "integrity": "sha512-pTn7+3VmHG1le5pzBruIAzlHg4Nc+CRucL/u4FLbktdPDeIQ/PWECeS+fkia7JM5w6UVhqFxY5ZP24gj77vfFg==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3358,8 +3381,8 @@ "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.60.0-boundaries.hbdc4a2a12368fec1", - "@zennotes/shared-domain": "2.60.0-boundaries.hbdc4a2a12368fec1", + "@zennotes/bridge-contract": "2.60.1-boundaries.ha881a2f8575a38bf", + "@zennotes/shared-domain": "2.60.1-boundaries.ha881a2f8575a38bf", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3403,18 +3426,18 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.60.0-boundaries.hbdc4a2a12368fec1", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", - "integrity": "sha512-5uh7dQjtrWNds26/4FH3wcrEQ0cF6SgUYd0+bGHKtl8GOLlayJWmDaJPXgm7FQ97Gc18KNM47k6k2rxYuECx3A==", + "version": "2.60.1-boundaries.ha881a2f8575a38bf", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.60.1-boundaries.ha881a2f8575a38bf.tgz", + "integrity": "sha512-/BjWqbzR4+S8Bg8gOwT6OZO8Ikr8qO8zNmVVscjr45vIWasAykfaLS9H6IuFfIE48/wbAXb3W8J6aTHNEdk0xA==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.60.0-boundaries.hbdc4a2a12368fec1", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", - "integrity": "sha512-Gr/F9IkAnX24T9apcPWLhErAtQ4N/QUr/rIdHJqNOTxERVSjrvIqP+wGCzNyuVRDujX9eXOfAt0y4Ir6Ol0TwQ==", + "version": "2.60.1-boundaries.ha881a2f8575a38bf", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.60.1-boundaries.ha881a2f8575a38bf.tgz", + "integrity": "sha512-5Sb02aV5yUy3MkqR9rkrx7eHQTRmurivhMVpeVa4kU0S07dm08N6kpstB8uIeKVlxhDvLILQHhBw606N3wx+aA==", "license": "MIT", "dependencies": { - "@zennotes/bridge-contract": "2.60.0-boundaries.hbdc4a2a12368fec1", + "@zennotes/bridge-contract": "2.60.1-boundaries.ha881a2f8575a38bf", "lz-string": "^1.5.0" } }, @@ -4703,7 +4726,7 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5529,7 +5552,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -5702,6 +5725,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5722,6 +5746,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5742,6 +5767,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5762,6 +5788,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5782,6 +5809,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5802,6 +5830,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5822,6 +5851,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5842,6 +5872,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5862,6 +5893,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5882,6 +5914,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5902,6 +5935,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8203,6 +8237,16 @@ "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8218,16 +8262,6 @@ "node": ">=8" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -8596,7 +8630,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unified": { diff --git a/package.json b/package.json index ad7573d..c539df6 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,9 @@ "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", "zustand": "^5.0.15", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.0-core.hb0d0b54f320a8e3f.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.60.1-core.h05ebb55c14afffb2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.60.1-boundaries.ha881a2f8575a38bf.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.60.1-boundaries.ha881a2f8575a38bf.tgz", "@lezer/common": "^1.5.3" }, "devDependencies": { diff --git a/src/bridge/cloud-rate-limit.test.ts b/src/bridge/cloud-rate-limit.test.ts new file mode 100644 index 0000000..f19e6f2 --- /dev/null +++ b/src/bridge/cloud-rate-limit.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() } + +it('retries the same native manifest page after the complete Retry-After delay', async (test) => { + const requests: string[] = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client', { + '@capacitor/core': { registerPlugin: () => ({}), CapacitorHttp: { request: async ({ url }: { url: string }) => { + requests.push(url) + return requests.length === 1 + ? { status: 429, data: 'Slow down', headers: { 'rEtRy-AfTeR': '2' } } + : { status: 200, data: { data: [], cursor: 500, next_page: null } } + } } } + }) + test.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }) + const client = createCloudSyncClient('https://retry.example.test', 'test-token', { accountId: 'account' }) + let settled = false + const pending = client.manifest('vault', { includeContent: false, page: 3, perPage: 250 }) + .then((data: unknown) => ({ data }), (error: unknown) => ({ error })).finally(() => { settled = true }) + await flush() + assert.equal(settled, false) + test.mock.timers.tick(1999) + await flush() + assert.equal(requests.length, 1) + test.mock.timers.tick(1) + assert.deepEqual(await pending, { data: { data: [], cursor: 500, next_page: null } }) + assert.equal(requests.length, 2) + assert.equal(requests[0], requests[1]) +}) + +it('cancels a native retry wait and preserves its cooldown across recreated clients', async (test) => { + const requests: string[] = [] + const api = await loadMobileModule('./src/bridge/cloud-sync-client', { + '@capacitor/core': { registerPlugin: () => ({}), CapacitorHttp: { request: async ({ url }: { url: string }) => { + requests.push(url) + return requests.length === 1 + ? { status: 429, data: {}, headers: { 'Retry-After': '2' } } + : { status: 200, data: { data: [] } } + } } } + }) + test.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 }) + const first = api.createCloudSyncClient('https://cancel.example.test', 'old-token', { accountId: 'account' }) + .listVaults().catch((error: unknown) => error) + await flush() + api.stopMobileCloudRequests() + assert.equal((await first).name, 'AbortError') + api.resumeMobileCloudRequests() + const pending = api.createCloudSyncClient('https://cancel.example.test', 'new-token', { accountId: 'account' }).listVaults() + await flush() + assert.equal(requests.length, 1) + test.mock.timers.tick(2000) + assert.deepEqual(await pending, { data: [] }) + assert.equal(requests.length, 2) +}) diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts index b4928a5..eeb76be 100644 --- a/src/bridge/cloud-sync-client.test.ts +++ b/src/bridge/cloud-sync-client.test.ts @@ -6,6 +6,7 @@ it('allows the full publishing timeout through the native iOS transport', async const requests: Array<{ connectTimeout?: number; readTimeout?: number }> = [] const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { '@capacitor/core': { + registerPlugin: () => ({}), CapacitorHttp: { request: async (options: { connectTimeout?: number; readTimeout?: number }) => { requests.push(options) @@ -14,7 +15,7 @@ it('allows the full publishing timeout through the native iOS transport', async } } }) - const client = createCloudSyncClient('https://example.test', 'test-only') + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account' }) const note = { note_path: 'Test.md', title: 'Test', markdown: 'Latest content' } await client.publishNote(note) await client.updatePublishedNote(1, note) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 91114ed..525acf1 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -1,6 +1,8 @@ -import { CapacitorHttp } from '@capacitor/core' +import { CapacitorHttp, registerPlugin } from '@capacitor/core' import { CloudSyncApiClient, + cloudSyncRateLimits, + type CloudSyncResponseHeaders, type CloudSyncHttpRequest, type CloudSyncHttpTransport } from '@zennotes/shared-domain/cloud-sync-api' @@ -15,6 +17,21 @@ import { type MobileObjectUpload } from './mobile-direct-upload' +let requestLifetime = new AbortController() + +export function mobileCloudRequestSignal(): AbortSignal { + return requestLifetime.signal +} + +export function stopMobileCloudRequests(): void { + requestLifetime.abort() + cloudSyncRateLimits.cancelAll() +} + +export function resumeMobileCloudRequests(): void { + if (requestLifetime.signal.aborted) requestLifetime = new AbortController() +} + export class CloudServiceRequestError extends Error { readonly status: number readonly code: string | null @@ -24,7 +41,8 @@ export class CloudServiceRequestError extends Error { message: string, status: number, code: string | null, - details: Record | null = null + details: Record | null = null, + readonly headers: CloudSyncResponseHeaders = {} ) { super(message) this.name = 'CloudServiceRequestError' @@ -34,8 +52,9 @@ export class CloudServiceRequestError extends Error { } } -export function createCloudSyncClient(baseUrl: string, token: string): CloudSyncApiClient { +export function createCloudSyncClient(baseUrl: string, token: string, options: { accountId: string; signal?: AbortSignal }): CloudSyncApiClient { const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, '') + const lifetime = options.signal ?? requestLifetime.signal const transport: CloudSyncHttpTransport = { async request(request: CloudSyncHttpRequest): Promise { const multipart = request.body instanceof FormData @@ -73,7 +92,8 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync : `ZenNotes Cloud request failed (${response.status}).`), response.status, typeof error?.code === 'string' ? error.code : null, - isRecord(error?.details) ? error.details : null + isRecord(error?.details) ? error.details : null, + response.headers ?? {} ) } @@ -96,15 +116,31 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync } } - return new MobileCloudSyncApiClient(transport, uploadObject) + const loopback = /^http:\/\/(localhost|127\.\d+\.\d+\.\d+|\[::1\])(:\d+)?$/.test(normalizedBaseUrl) + return new MobileCloudSyncApiClient(cloudSyncRateLimits.wrap(transport, { + baseUrl: normalizedBaseUrl, accountId: options.accountId, signal: lifetime + }), async (request) => { + if (lifetime.aborted) throw new DOMException('Cloud request cancelled.', 'AbortError') + await uploadObject(request) + if (lifetime.aborted) throw new DOMException('Cloud request cancelled.', 'AbortError') + }, { + // Streaming host: large revisions arrive as references and are downloaded + // natively into staging, never as base64 through the WebView bridge. + contentReferences: true, + accountScope: { baseUrl: normalizedBaseUrl, accountId: options.accountId }, + signal: lifetime, + bootstrapContentPageBytes: 1024 * 1024, + allowInsecureLoopbackDownloads: loopback + }) } class MobileCloudSyncApiClient extends CloudSyncApiClient { constructor( http: CloudSyncHttpTransport, - private readonly uploadObject: MobileObjectUpload + private readonly uploadObject: MobileObjectUpload, + options: ConstructorParameters[1] ) { - super(http) + super(http, options) } override async mutate( @@ -125,8 +161,17 @@ class MobileCloudSyncApiClient extends CloudSyncApiClient { } } +/** Same jsName as Android so the file-backed upload path stays shared. */ +const CloudFiles = registerPlugin<{ + put(options: { url: string; headers: Record; uri: string; sha256: string; byteLength: number }): Promise<{ status: number }> +}>('ZenDirectUpload') + const uploadObject: MobileObjectUpload = async (request) => { - const response = await CapacitorHttp.request(mobileObjectUploadOptions(request)) + // Large scanned files never had their bytes in JS; stream them natively. + const response = request.uri !== undefined + ? await CloudFiles.put({ url: request.url, headers: request.headers, uri: request.uri, sha256: request.sha256, byteLength: request.byteLength }) + .catch(() => { throw new MobileDirectUploadError('ZenNotes could not reach Cloud object storage. Check your connection and try again.', 0, 'DIRECT_UPLOAD_FAILED') }) + : await CapacitorHttp.request(mobileObjectUploadOptions(request)) if (response.status < 200 || response.status >= 300) { throw new MobileDirectUploadError( `ZenNotes Cloud object upload failed (${response.status}).`, diff --git a/src/bridge/cloud-sync-repository.test.ts b/src/bridge/cloud-sync-repository.test.ts index 292c82e..a343387 100644 --- a/src/bridge/cloud-sync-repository.test.ts +++ b/src/bridge/cloud-sync-repository.test.ts @@ -9,7 +9,9 @@ import type { CloudSyncRepository } from '@zennotes/shared-domain/cloud-sync-coo import { loadMobileModule } from '../../tooling/load-mobile-module.ts' -const { CachedCloudSyncRepository } = await loadMobileModule('./src/bridge/cloud-sync-repository') +const { CachedCloudSyncRepository, mutateWithMobileDirectUploads } = await loadMobileModule([ + './src/bridge/cloud-sync-repository', './src/bridge/mobile-direct-upload' +]) const { CloudSyncCoordinator } = await loadMobileModule('@zennotes/shared-domain/cloud-sync-coordinator') type StoredFile = { bytes: Buffer; mtime: number } @@ -21,8 +23,13 @@ function harness(initial: Record = { 'note.md': 'Hello' let state: CloudSyncState | null = null let clock = 1000 const reads: string[] = [] + const writes: string[] = [] + const copies: string[] = [] const failures = { cacheRead: false, cacheWrite: false, stateRead: false, directory: false, file: false } let onRead: ((path: string) => void) | undefined + let onWrite: ((path: string) => void) | undefined + let onRename: ((from: string, to: string) => void) | undefined + let onCopy: ((from: string, to: string) => void) | undefined const put = (path: string, body: string | Buffer) => { files.set(path, { bytes: Buffer.from(body), mtime: ++clock }) } @@ -55,23 +62,56 @@ function harness(initial: Record = { 'note.md': 'Hello' onRead?.(path) const file = files.get(path) if (!file) throw new Error('File missing') + if (file.bytes.length > 5 * 1024 * 1024) throw new Error('Whole-file bridge read exceeded the inline limit') return file.bytes.toString('base64') } const fs = { readdir, stat: async (path: string) => (await stat(path))?.type ?? null, readBase64, - writeText: async (path: string, data: string) => put(path, data), - writeBase64: async (path: string, data: string) => put(path, Buffer.from(data, 'base64')), - deleteFile: async (path: string) => { files.delete(path) }, + writeText: async (path: string, data: string) => { writes.push(path); put(path, data); onWrite?.(path) }, + writeBase64: async (path: string, data: string) => { writes.push(path); put(path, Buffer.from(data, 'base64')); onWrite?.(path) }, + deleteFile: async (path: string) => { writes.push(path); files.delete(path) }, rename: async (from: string, to: string) => { const file = files.get(from) if (!file) throw new Error('File missing') + writes.push(to) files.set(to, file) files.delete(from) + onRename?.(from, to) + } + } + const native = { + readdirStrict: readdir, readBase64, statOrNull: stat, stat, + async copyForSync(from: string, to: string) { + copies.push(from) + const file = files.get(from) + if (!file) throw new Error('File missing') + writes.push(to) + put(to, file.bytes) + onCopy?.(from, to) + }, + async readForSync(path: string, textCandidate: boolean) { + reads.push(path) + if (failures.file) throw new Error('File unavailable') + onRead?.(path) + const file = files.get(path) + if (!file) throw new Error('File missing') + let utf8 = false + try { + if (textCandidate) { + new TextDecoder('utf-8', { fatal: true }).decode(file.bytes) + utf8 = true + } + } catch {} + return { + uri: `file:///vault/${path}`, + sha256: createHash('sha256').update(file.bytes).digest('hex'), + byteLength: file.bytes.length, utf8, + ...(file.bytes.length <= 5 * 1024 * 1024 ? { inlineBase64: file.bytes.toString('base64') } : {}) + } } } - const native = { readdirStrict: readdir, readBase64, statOrNull: stat, stat } const store = { loadTracked: async () => { if (failures.stateRead) throw new Error('State unavailable') @@ -97,10 +137,10 @@ function harness(initial: Record = { 'note.md': 'Hello' }])) } } - const coordinator = () => { + const coordinator = (manifestItems: unknown[] = [], syncRepository = repository) => { const mutations: CloudSyncMutation[] = [] const remote = { - manifest: async () => ({ data: [], cursor: state?.cursor ?? 0, next_page: null }), + manifest: async () => ({ data: manifestItems, cursor: state?.cursor ?? 0, next_page: null }), changes: async () => ({ data: [], cursor: state?.cursor ?? 0, has_more: false }), mutate: async (_vaultId: string, body: { mutations: CloudSyncMutation[] }) => { // Serialization is deliberately real: a cache placeholder must never be uploaded. @@ -116,15 +156,18 @@ function harness(initial: Record = { 'note.md': 'Hello' let id = 0 return { mutations, - service: new CloudSyncCoordinator('vault-1', remote, repository, { + service: new CloudSyncCoordinator('vault-1', remote, syncRepository, { load: async () => state, save: async (next: CloudSyncState) => { state = structuredClone(next) } }, { itemId: () => `new-${++id}`, operationId: () => `op-${++id}` }) } } return { - files, reads, failures, repository, acknowledge, coordinator, put, + files, reads, writes, copies, failures, repository, acknowledge, coordinator, put, fs, native, store, setReadHook: (hook: typeof onRead) => { onRead = hook }, + setWriteHook: (hook: typeof onWrite) => { onWrite = hook }, + setRenameHook: (hook: typeof onRename) => { onRename = hook }, + setCopyHook: (hook: typeof onCopy) => { onCopy = hook }, get cache() { return cache }, set cache(next: unknown) { cache = next }, get state() { return state }, set state(next: CloudSyncState | null) { state = next } } @@ -147,6 +190,53 @@ function pending(path: string, local: CloudSyncContent, cloud = content('Other d } describe('cached mobile Cloud scan', () => { + it('scans a large attachment without materializing its contents across the bridge', async () => { + const bytes = Buffer.alloc(8_000_000, 129) + const h = harness({ 'attachements/large.bin': bytes }) + const [item] = await h.repository.scan() + assert.equal(item.content.byte_length, bytes.length) + assert.equal(item.content.sha256, createHash('sha256').update(bytes).digest('hex')) + assert.equal(item.content.data, '') + assert.equal(item.kind, 'binary') + assert.ok(JSON.stringify(item).length < 500) + }) + + it('preserves UTF-8 classification and raw-byte hashes for file-backed text', async () => { + const bytes = Buffer.from('日本語 café\n'.repeat(400_000)) + const h = harness({ 'large.md': bytes }) + const [item] = await h.repository.scan() + assert.equal(item.kind, 'text') + assert.equal(item.content.encoding, 'utf8') + assert.equal(item.content.sha256, createHash('sha256').update(bytes).digest('hex')) + assert.equal(item.content.data, '') + }) + + it('passes a scanned file to the uploader by URI and completes only after the native transfer', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'attachements/large.bin': bytes }) + const [item] = await h.repository.scan() + let uploaded = false + const result = await mutateWithMobileDirectUploads({ + mutate: async () => { throw new Error('Unexpected inline upload') }, + initiateUpload: async (_vault: string, request: any) => ({ data: { + id: 'upload', operation_id: request.operation_id, expected_bytes: bytes.length, + upload: { method: 'PUT', url: 'https://storage.example.test/object', headers: {} } + } }), + completeUpload: async () => { + assert.equal(uploaded, true) + return { data: { result: { acknowledged: [{ item_id: 'item' }], conflicts: [], cursor: 1 } } } + }, + abortUpload: async () => { throw new Error('Unexpected abort') } + }, 'vault', { mutations: [{ ...item, type: 'upsert', item_id: 'item', operation_id: 'operation', base_revision: null }] }, async (request: any) => { + assert.equal(request.uri, 'file:///vault/attachements/large.bin') + assert.equal(request.base64, undefined) + assert.equal(request.sha256, createHash('sha256').update(bytes).digest('hex')) + assert.equal(request.byteLength, bytes.length) + uploaded = true + }) + assert.equal(result.acknowledged.length, 1) + }) + it('reads and hashes new text and binary files with the same portable semantics', async () => { const bytes = Buffer.from([0, 255, 1, 128]) const h = harness({ 'note.md': 'Hello', 'assets/photo.png': bytes, '.zennotes/cache.json': '{}' }) @@ -294,6 +384,220 @@ describe('cached mobile Cloud scan', () => { }) describe('cached scan with the pinned conflict coordinator', () => { + it('keeps all 6 MB of the local version while replacing the original with Cloud bytes', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + const [local] = await h.repository.scan() + h.acknowledge([local]) + const cloud = content('Cloud replacement') + h.state!.pending_conflicts = { 'conflict-1': pending('asset.bin', local.content, cloud) } + const coordinator = h.coordinator([{ + item_id: 'item-0', path: 'asset.bin', kind: 'text', revision: 2, + sha256: cloud.sha256, byte_length: cloud.byte_length, media_type: cloud.media_type + }]) + + await coordinator.service.resolveConflict({ + conflict_id: 'conflict-1', choice: 'both', keep_both_path: 'copies/local.bin', + expected_local_sha256: local.content.sha256, expected_cloud_revision: 2 + }) + + assert.deepEqual(h.files.get('copies/local.bin')?.bytes, bytes) + assert.equal(h.files.get('asset.bin')?.bytes.toString(), cloud.data) + assert.equal(h.state!.pending_conflicts?.['conflict-1'], undefined) + assert.deepEqual([...h.files.keys()].sort(), ['asset.bin', 'copies/local.bin']) + assert.ok(h.copies.length > 0) + }) + + it('rejects a changed file-backed source before writing any resolution files', async () => { + const h = harness({ 'source.bin': Buffer.alloc(6_000_000, 197), 'note.md': 'Keep me' }) + const source = (await h.repository.scan()).find((item) => item.path === 'source.bin')! + h.put('source.bin', Buffer.alloc(6_000_000, 198)) + await assert.rejects(h.repository.applyConflictResolutionFiles!({ + expected_path: 'note.md', expected_sha256: content('Keep me').sha256, + files: [{ path: 'first.md', content: content('First') }, { path: 'copy.bin', content: source.content }] + }), /changed|source/i) + assert.deepEqual(h.writes, []) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Keep me') + }) + + it('preserves raw UTF-8 bytes when a large local text version is copied', async () => { + const bytes = Buffer.from('日本語é\n'.repeat(500_000)) + assert.equal(bytes.length, 6_000_000) + const h = harness({ 'large.md': bytes }) + const [local] = await h.repository.scan() + assert.equal(local.content.encoding, 'utf8') + await h.repository.applyConflictResolutionFiles!({ + expected_path: 'large.md', expected_sha256: local.content.sha256, + files: [{ path: 'large.md', content: content('Cloud') }, { path: 'local.md', content: local.content }] + }) + assert.deepEqual(h.files.get('local.md')?.bytes, bytes) + assert.equal(h.files.get('large.md')?.bytes.toString(), 'Cloud') + }) + + it('does not upload a deletion when an interrupted replacement left a rollback file', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + h.acknowledge(await h.repository.scan()) + const rollback = '.zennotes/sync/rollback-interrupted.bin' + h.files.delete('asset.bin') + h.put(rollback, bytes) + const restarted = new CachedCloudSyncRepository(h.fs, h.native, h.store) + const coordinator = h.coordinator([], restarted) + await assert.rejects(coordinator.service.sync(), /needs recovery/) + assert.deepEqual(coordinator.mutations, []) + assert.deepEqual(h.files.get(rollback)?.bytes, bytes) + }) + + it('rechecks recovery files on the next scan of an already-running repository', async () => { + const h = harness({ 'note.md': 'original' }) + h.acknowledge(await h.repository.scan()) + h.files.delete('note.md') + h.put('.zennotes/sync/rollback-failed.md', 'original') + const coordinator = h.coordinator() + await assert.rejects(coordinator.service.sync(), /needs recovery/) + assert.deepEqual(coordinator.mutations, []) + }) + + it('rejects unknown metadata-only content before a bootstrap rename or a multi-file write', async () => { + const h = harness({ 'note.md': 'Keep me' }) + const missing = { ...content('Missing bytes'), data: '' } + await assert.rejects(h.repository.resolveBootstrapConflict!({ + path: 'note.md', expectedLocalSha256: content('Keep me').sha256, cloudContent: missing, + resolution: { choice: 'both', keep_both_path: 'copy.md', conflict: { + code: 'BOOTSTRAP_CONTENT_CONFLICT', item_id: 'item', path: 'note.md', + local_sha256: content('Keep me').sha256, remote_sha256: missing.sha256 + } } + }), /source|bytes|content/i) + await assert.rejects(h.repository.applyConflictResolutionFiles!({ + expected_path: 'note.md', expected_sha256: content('Keep me').sha256, + files: [{ path: 'first.md', content: content('First') }, { path: 'note.md', content: missing }] + }), /source|bytes|content/i) + assert.deepEqual(h.writes, []) + assert.deepEqual([...h.files.keys()], ['note.md']) + }) + + it('rolls back a failed Cloud replacement after creating the large local copy', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + const [local] = await h.repository.scan() + let failed = false + h.setRenameHook((_from, to) => { + if (to === 'asset.bin' && !failed) { + failed = true + h.put(to, 'Partial write') + throw new Error('Native replacement failed after modifying the target') + } + }) + await assert.rejects(h.repository.applyConflictResolutionFiles!({ + expected_path: 'asset.bin', expected_sha256: local.content.sha256, + files: [{ path: 'asset.bin', content: content('Cloud') }, { path: 'copy.bin', content: local.content }] + }), /failed/) + assert.equal(failed, true) + assert.deepEqual(h.files.get('asset.bin')?.bytes, bytes) + assert.deepEqual([...h.files.keys()], ['asset.bin']) + }) + + it('rejects a corrupt native copy before replacing the original', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + const [local] = await h.repository.scan() + h.setCopyHook((_from, to) => h.put(to, 'Truncated copy')) + await assert.rejects(h.repository.applyConflictResolutionFiles!({ + expected_path: 'asset.bin', expected_sha256: local.content.sha256, + files: [{ path: 'copy.bin', content: local.content }, { path: 'asset.bin', content: content('Cloud') }] + }), /bytes|hash|changed|verification/i) + assert.deepEqual(h.files.get('asset.bin')?.bytes, bytes) + assert.deepEqual([...h.files.keys()], ['asset.bin']) + }) + + it('retains a source edited during copying and does not publish the stale copy', async () => { + const h = harness({ 'source.bin': Buffer.alloc(6_000_000, 197), 'note.md': 'Keep me' }) + const source = (await h.repository.scan()).find((item) => item.path === 'source.bin')! + const changed = Buffer.alloc(6_000_000, 198) + h.setCopyHook((from) => h.put(from, changed)) + await assert.rejects(h.repository.applyConflictResolutionFiles!({ + expected_path: 'note.md', expected_sha256: content('Keep me').sha256, + files: [{ path: 'copy.bin', content: source.content }, { path: 'note.md', content: content('Cloud') }] + }), /changed/) + assert.deepEqual(h.files.get('source.bin')?.bytes, changed) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Keep me') + assert.deepEqual([...h.files.keys()].sort(), ['note.md', 'source.bin']) + }) + + it('preserves the large original when staging a replacement fails after a partial write', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + const [local] = await h.repository.scan() + h.setWriteHook((path) => { + h.put(path, 'Partial') + throw new Error('Staging failed') + }) + await assert.rejects(h.repository.replaceConflictFile!({ + path: local.path, expectedSha256: local.content.sha256, content: content('Cloud') + }), /Staging failed/) + assert.deepEqual(h.files.get('asset.bin')?.bytes, bytes) + assert.deepEqual([...h.files.keys()], ['asset.bin']) + }) + + it('keeps a large bootstrap local copy without reading its body across the bridge', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + const [local] = await h.repository.scan() + const cloud = content('Cloud') + await h.repository.resolveBootstrapConflict!({ + path: local.path, expectedLocalSha256: local.content.sha256, cloudContent: cloud, + resolution: { choice: 'both', keep_both_path: 'local.bin', conflict: { + code: 'BOOTSTRAP_CONTENT_CONFLICT', item_id: 'item', path: local.path, + local_sha256: local.content.sha256, remote_sha256: cloud.sha256 + } } + }) + assert.deepEqual(h.files.get('local.bin')?.bytes, bytes) + assert.equal(h.files.get('asset.bin')?.bytes.toString(), 'Cloud') + }) + + it('rolls back the bootstrap rename when the Cloud write fails', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + const [local] = await h.repository.scan() + const cloud = content('Cloud') + h.setWriteHook(() => { throw new Error('Write failed') }) + await assert.rejects(h.repository.resolveBootstrapConflict!({ + path: local.path, expectedLocalSha256: local.content.sha256, cloudContent: cloud, + resolution: { choice: 'both', keep_both_path: 'local.bin', conflict: { + code: 'BOOTSTRAP_CONTENT_CONFLICT', item_id: 'item', path: local.path, + local_sha256: local.content.sha256, remote_sha256: cloud.sha256 + } } + }), /Write failed/) + assert.deepEqual(h.files.get('asset.bin')?.bytes, bytes) + assert.deepEqual([...h.files.keys()], ['asset.bin']) + }) + + it('rejects unknown metadata on inherited apply and replace before changing files', async () => { + const h = harness({ 'note.md': 'Keep me' }) + const missing = { ...content('Unavailable'), data: '' } + await assert.rejects(h.repository.apply({ sequence: 2, revision: 2, item_id: 'item', + path: 'note.md', previous_path: null, type: 'upsert', content: missing }, undefined), /source/) + await assert.rejects(h.repository.replaceConflictFile!({ + path: 'note.md', expectedSha256: content('Keep me').sha256, content: missing + }), /source/) + assert.deepEqual(h.writes, []) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Keep me') + }) + + it('uses streaming reads for inherited apply and preserves an unsynced large edit', async () => { + const bytes = Buffer.alloc(6_000_000, 197) + const h = harness({ 'asset.bin': bytes }) + const [local] = await h.repository.scan() + const change = { sequence: 2, item_id: 'item', revision: 2, type: 'upsert' as const, + path: 'asset.bin', previous_path: null, content: content('Cloud') } + const conflict = await h.repository.apply(change, undefined) + assert.equal(conflict?.code, 'LOCAL_EDIT_CONFLICT') + assert.deepEqual(h.files.get('asset.bin')?.bytes, bytes) + await h.repository.apply(change, { item_id: 'item', path: local.path, kind: local.kind, revision: 1, + sha256: local.content.sha256, byte_length: bytes.length, media_type: local.content.media_type }) + assert.equal(h.files.get('asset.bin')?.bytes.toString(), 'Cloud') + }) + it('returns real bytes for review when pending local content matches acknowledged content', async () => { const h = harness() h.acknowledge(await h.repository.scan()) diff --git a/src/bridge/cloud-sync-repository.ts b/src/bridge/cloud-sync-repository.ts index cf5063f..5246f89 100644 --- a/src/bridge/cloud-sync-repository.ts +++ b/src/bridge/cloud-sync-repository.ts @@ -37,6 +37,9 @@ import { import type { CloudSyncLocalItem, CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' import type { NativeFs } from './native-fs' import { cloudSyncWorkBudget, decodeCloudSyncBase64 } from './cloud-sync-work' +import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, rememberMobileUploadSource } from './mobile-direct-upload' +import { isNotFoundError } from './native-fs' +import { NativeCloudStaging, type CloudStagingNative } from './cloud-sync-staging' export interface ScanCacheEntry { mtime: number @@ -49,6 +52,8 @@ export interface ScanCacheEntry { export type ScanCache = Record +type CloudSyncNativeFiles = Pick & Partial + export interface ScanCacheStore { loadTracked(): Promise loadCache(): Promise @@ -56,16 +61,51 @@ export interface ScanCacheStore { } export class CachedCloudSyncRepository extends PortableCloudSyncRepository { + private readonly contentFiles: NativeCloudSyncContent + constructor( fs: PortableCloudSyncFileSystem, - private readonly native: Pick, + private readonly native: CloudSyncNativeFiles, private readonly store: ScanCacheStore, private readonly onChanged: () => void = () => {} ) { - super(fs) + const contentFiles = new NativeCloudSyncContent(fs, native) + const staging = isStagingNative(native) + ? new NativeCloudStaging(native, (path) => TEXT_EXTENSIONS.has(extension(path)), (path) => contentFiles.read(path)) + : null + const sourceAwareFs = { + ...(staging ? { + stageCloudContent: async (source: Parameters[0]) => { + await contentFiles.assertReady(true) + return staging.stage(source) + }, + applyStagedCloudContent: (...args: Parameters) => staging.apply(...args), + resolveStagedCloudConflict: (input: Parameters[0]) => staging.resolve(input) + } : {}), + readdir: (path: string) => fs.readdir(path), + stat: (path: string) => fs.stat(path), + readBase64: (path: string) => fs.readBase64(path), + writeText: (path: string, value: string) => fs.writeText(path, value), + writeBase64: (path: string, value: string) => fs.writeBase64(path, value), + deleteFile: (path: string) => fs.deleteFile(path), + rename: (from: string, to: string) => fs.rename(from, to), + readItem: (path: string) => contentFiles.read(path), + validateContent: (content: CloudSyncContent) => contentFiles.validate(content), + writeContent: (path: string, content: CloudSyncContent) => contentFiles.write(path, content) + } + super(sourceAwareFs) + this.contentFiles = contentFiles + this.staging = staging + } + + private readonly staging: NativeCloudStaging | null + + override async matchesCloudContent(path: string, reference: Parameters[1]): Promise { + return this.staging ? this.staging.matches(path, reference) : super.matchesCloudContent(path, reference) } override async scan(): Promise { + await this.contentFiles.assertReady(true) const trackedSha = trackedShaByPath(await this.store.loadTracked().catch(() => null)) const cache = normalizeScanCache(await this.store.loadCache().catch(() => null)) const nextCache: ScanCache = {} @@ -113,7 +153,7 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { continue } - const item = await this.readItemFresh(path) + const item = await this.readItemFresh(path, entry.uri) if (!cached || cached.mtime !== entry.mtime || cached.size !== entry.size || cached.sha256 !== item.content.sha256) { this.onChanged() } @@ -135,12 +175,53 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { } // --------------------------------------------------------------------- - // Preserve upstream readItem's encoding/hash semantics while yielding - // during large base64 decoding and avoiding a binary re-encode. + // Only inline-sized bodies cross the native bridge. Large files retain a + // host-only source reference, like the desktop's disk-backed uploader. // --------------------------------------------------------------------- - private async readItemFresh(path: string): Promise { - const { bytes, base64 } = await decodeCloudSyncBase64(await this.native.readBase64(path)) + private async readItemFresh(path: string, uri?: string): Promise { + return this.contentFiles.read(path, uri) + } +} + +/** Large local content remains an identity-bound source, never an empty payload. */ +class NativeCloudSyncContent { + private readonly sources = new WeakMap() + private recoveryChecked = false + + constructor(private readonly fs: PortableCloudSyncFileSystem, private readonly native: CloudSyncNativeFiles) {} + + async assertReady(force = false): Promise { + if (this.recoveryChecked && !force) return + const entries = await this.native.readdirStrict('.zennotes/sync').catch((error) => { + if (!isNotFoundError(error)) throw error + return [] + }) + const recovery = entries.find((entry) => entry.name.startsWith('rollback-')) + if (recovery) { + // Never turn a process-interrupted rename into a new local deletion. + throw new Error(`Cloud sync needs recovery of .zennotes/sync/${recovery.name} before it can continue.`) + } + this.recoveryChecked = true + } + + async read(path: string, uri?: string): Promise { + await this.assertReady() + const file = await this.native.readForSync(path, TEXT_EXTENSIONS.has(extension(path)), uri) + if (file.byteLength > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES) { + const content = rememberMobileUploadSource({ + encoding: file.utf8 ? 'utf8' : 'base64', data: '', sha256: file.sha256, + byte_length: file.byteLength, media_type: mediaType(path, file.utf8) + }, file.uri) + this.sources.set(content, { path, hash: file.sha256, bytes: file.byteLength }) + return { + path, + kind: file.utf8 ? 'text' : 'binary', + content + } + } + if (file.inlineBase64 === undefined) throw new Error('Native file inspection omitted inline content.') + const { bytes, base64 } = await decodeCloudSyncBase64(file.inlineBase64) const text = decodeText(path, bytes) return { path, @@ -148,12 +229,89 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { content: { encoding: text === null ? 'base64' : 'utf8', data: text === null ? base64 : text, - sha256: await sha256(bytes), + sha256: file.sha256, byte_length: bytes.byteLength, media_type: mediaType(path, text !== null) } } } + + async validate(content: CloudSyncContent): Promise { + if (content.encoding !== 'utf8' && content.encoding !== 'base64') { + throw new Error('Encrypted cloud sync content must be decrypted before filesystem apply') + } + if (content.data !== '' || content.byte_length === 0) return + const source = this.sources.get(content) + if (!source || source.hash !== content.sha256 || source.bytes !== content.byte_length) { + throw new Error('This file content has no recognized source bytes. Sync again and retry.') + } + await this.verify(source.path, content) + } + + private async verify(path: string, content: CloudSyncContent): Promise { + // Resolve the current path again: a saved document URI may name a replaced file. + const file = await this.native.readForSync(path, false) + if (file.sha256 !== content.sha256 || file.byteLength !== content.byte_length) { + throw new Error('The file source changed or its copy failed byte verification. Sync again and retry.') + } + } + + async write(path: string, content: CloudSyncContent): Promise { + await this.assertReady() + await this.validate(content) + const kind = await this.fs.stat(path) + if (kind === 'directory') throw new Error('The destination is a directory.') + const before = kind === 'file' ? await this.read(path) : null + const temporary = `.zennotes/sync/write-${crypto.randomUUID()}${extension(path)}` + const backup = `.zennotes/sync/rollback-${crypto.randomUUID()}${extension(path)}` + let publishing = false + let completed = false + try { + const source = this.sources.get(content) + if (content.data === '' && content.byte_length > 0 && source) { + await this.native.copyForSync(source.path, temporary, content.byte_length) + await this.validate(content) + } else if (content.encoding === 'utf8') { + await this.fs.writeText(temporary, content.data) + } else { + await this.fs.writeBase64(temporary, content.data) + } + await this.verify(temporary, content) + if (before) { + await this.verify(path, before.content) + await this.fs.rename(path, backup) + } else if (await this.fs.stat(path) !== null) { + throw new Error('The destination changed before its Cloud write.') + } + publishing = true + await this.fs.rename(temporary, path) + await this.verify(path, content) + completed = true + } catch (error) { + // Native operations may modify the destination and then reject. The + // original is kept separately until the published bytes are verified. + if (await this.fs.stat(backup) === 'file') { + try { + if (await this.fs.stat(path) !== null) await this.fs.deleteFile(path) + await this.fs.rename(backup, path) + } catch (rollbackError) { + throw new Error(`Cloud write rollback failed; the original is preserved at ${backup}.`, { cause: rollbackError }) + } + } else if (!before && publishing && await this.fs.stat(path) !== null) { + await this.fs.deleteFile(path) + } + throw error + } finally { + this.recoveryChecked = false + if (await this.fs.stat(temporary) === 'file') await this.fs.deleteFile(temporary).catch(() => {}) + if (completed) await this.fs.deleteFile(backup).catch(() => {}) + } + } +} + +function isStagingNative(native: CloudSyncNativeFiles): native is CloudSyncNativeFiles & CloudStagingNative { + return ['statVerified', 'download', 'rename', 'deleteFile', 'mkdir', 'readText'] + .every((method) => typeof (native as Record)[method] === 'function') } function itemFromCache(path: string, cached: ScanCacheEntry): CloudSyncLocalItem { @@ -288,10 +446,3 @@ function extension(path: string): string { function mediaType(path: string, text: boolean): string { return MEDIA_TYPES[extension(path)] ?? (text ? 'text/plain' : 'application/octet-stream') } - -async function sha256(bytes: Uint8Array): Promise { - const digest = await crypto.subtle.digest('SHA-256', bytes.buffer) - return [...new Uint8Array(digest)] - .map((byte) => byte.toString(16).padStart(2, '0')) - .join('') -} diff --git a/src/bridge/cloud-sync-staging.test.ts b/src/bridge/cloud-sync-staging.test.ts new file mode 100644 index 0000000..6054bce --- /dev/null +++ b/src/bridge/cloud-sync-staging.test.ts @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +// One bundle: the staging token registry is a module-private WeakMap, so the +// helper that reads a handle must share the module instance that wrote it. +const { NativeCloudStaging, cloudSyncStagedHandle } = await loadMobileModule([ + './src/bridge/cloud-sync-staging', '@zennotes/shared-domain/cloud-sync-content' +]) + +const hash = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex') + +/** In-memory vault with a fake native downloader; bytes never pass through JS strings. */ +function harness(initial: Record = {}) { + const files = new Map(Object.entries(initial)) + const objects = new Map() + const log: string[] = [] + const native = { + async statVerified(path: string) { return files.has(path) ? 'file' as const : null }, + async readForSync(path: string) { + const bytes = files.get(path) + if (!bytes) throw new Error(`missing ${path}`) + let utf8 = true + try { new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { utf8 = false } + return { uri: `file:///${path}`, sha256: hash(bytes), byteLength: bytes.length, utf8 } + }, + async download(options: { url: string; to: string; byteLength: number; sha256: string }) { + log.push(`download ${options.url}`) + const bytes = objects.get(options.url) + if (!bytes) throw Object.assign(new Error('not found'), { status: 404 }) + if (bytes.length !== options.byteLength || hash(bytes) !== options.sha256) throw new Error('verification failed') + files.set(options.to, bytes) + }, + async copyForSync(from: string, to: string) { + log.push(`copy ${from} -> ${to}`) + files.set(to, Buffer.from(files.get(from)!)) + }, + async rename(from: string, to: string) { + log.push(`rename ${from} -> ${to}`) + const bytes = files.get(from) + if (!bytes) throw new Error(`rename missing ${from}`) + files.set(to, bytes) + files.delete(from) + }, + async deleteFile(path: string) { files.delete(path) }, + async mkdir() {}, + async readText(path: string) { return files.get(path)!.toString('utf8') } + } + const staging = new NativeCloudStaging(native, (path: string) => path.endsWith('.md'), async (path: string) => { + const bytes = files.get(path)! + return { path, kind: 'text', content: { encoding: 'utf8', data: bytes.toString('utf8'), + sha256: hash(bytes), byte_length: bytes.length, media_type: 'text/markdown' } } + }) + const reference = (itemId: string, revision: number, bytes: Buffer, encoding = 'base64') => ({ + item_id: itemId, revision, encoding, sha256: hash(bytes), byte_length: bytes.length, media_type: 'application/octet-stream' + }) + const source = (ref: any, url: string) => ({ + reference: ref, previewLimitBytes: 262_144, allowInsecureLoopback: false, + getInstruction: async () => ({ item_id: ref.item_id, revision: ref.revision, + content: { encoding: ref.encoding, sha256: ref.sha256, byte_length: ref.byte_length, media_type: ref.media_type }, + download: { url, method: 'GET', headers: {}, expires_at: new Date(Date.now() + 300_000).toISOString() } }) + }) + return { files, objects, log, staging, reference, source, native } +} + +describe('native Cloud staging', () => { + for (const failure of ['copy', 'publish', 'restore', 'cancel'] as const) { + it(`preserves the original and cleans partial writes after ${failure} failure`, async () => { + const local = Buffer.from('local') + const h = harness({ 'note.md': local }) + const cloud = Buffer.from('cloud') + const ref = h.reference('item', 2, cloud, 'utf8') + const url = 'https://objects.example.test/item/2' + h.objects.set(url, cloud) + const controller = new AbortController() + const file = await h.staging.stage({ ...h.source(ref, url), signal: controller.signal }) + const rename = h.native.rename + if (failure === 'copy') h.native.copyForSync = async (_from, to) => { + h.files.set(to, Buffer.from('partial')) + throw new Error('copy failed') + } + if (failure === 'publish' || failure === 'restore') h.native.rename = async (from, to) => { + if (failure === 'restore' && from.startsWith('.zennotes/sync/rollback-')) throw new Error('restore failed') + await rename(from, to) + if (from.startsWith('.zennotes/sync/downloads/')) { + h.files.set(to, Buffer.from('corrupt')) + if (failure === 'restore') throw new Error('publish failed') + } + } + if (failure === 'cancel') controller.abort() + await assert.rejects(h.staging.resolve({ + expected_path: 'note.md', expected_sha256: hash(local), cloud_path: 'note.md', file, + ...(failure === 'copy' ? { keep_both_path: 'local.md' } : {}) + })) + assert.equal(h.files.has('local.md'), false) + if (failure === 'restore') { + const recovery = [...h.files.keys()].find((path) => /^\.zennotes\/sync\/rollback-[^/]+$/.test(path)) + assert.ok(recovery, 'stranded original must be visible to the scan recovery guard') + assert.deepEqual(h.files.get(recovery), local) + } else { + assert.deepEqual(h.files.get('note.md'), local) + } + }) + } + + it('removes a newly created destination when native rename partially publishes then rejects', async () => { + const h = harness() + const bytes = Buffer.from('cloud') + const ref = h.reference('item', 1, bytes) + const url = 'https://objects.example.test/item/1' + h.objects.set(url, bytes) + const file = await h.staging.stage(h.source(ref, url)) + h.native.rename = async (_from, to) => { + h.files.set(to, Buffer.from('partial')) + throw new Error('rename failed') + } + await assert.rejects(h.staging.apply({ sequence: 1, item_id: 'item', type: 'upsert', + path: 'new.bin', previous_path: null, revision: 1, content_ref: ref }, undefined, file)) + assert.equal(h.files.has('new.bin'), false) + }) + + for (const failure of ['publish', 'edited-copy', 'rollback'] as const) { + it(`keeps a failed keep-both resolution safe and retryable after ${failure}`, async () => { + const local = Buffer.from('local') + const cloud = Buffer.from('cloud') + const h = harness({ 'note.md': local }) + const ref = h.reference('item', 2, cloud, 'utf8') + const url = 'https://objects.example.test/item/2' + h.objects.set(url, cloud) + const file = await h.staging.stage(h.source(ref, url)) + const rename = h.native.rename + h.native.rename = async (from, to) => { + if (from.startsWith('.zennotes/sync/downloads/') && to === 'note.md') { + if (failure === 'edited-copy') h.files.set('local.md', Buffer.from('later user edit')) + throw new Error('disk full') + } + if (failure === 'rollback' && from.startsWith('.zennotes/sync/rollback-')) throw new Error('rollback failed') + await rename(from, to) + } + const input = { expected_path: 'note.md', expected_sha256: hash(local), cloud_path: 'note.md', file, keep_both_path: 'local.md' } + await assert.rejects(h.staging.resolve(input)) + if (failure === 'publish') { + assert.deepEqual(h.files.get('note.md'), local) + assert.equal(h.files.has('local.md'), false, 'the unfinished decision must not block its own retry') + h.native.rename = rename + await h.staging.resolve(input) + assert.deepEqual(h.files.get('note.md'), cloud) + assert.deepEqual(h.files.get('local.md'), local) + } else { + assert.deepEqual(h.files.get('local.md'), failure === 'edited-copy' ? Buffer.from('later user edit') : local) + } + }) + } + + it('refreshes an expired signed URL reported as a native Capacitor rejection', async () => { + const h = harness() + const bytes = Buffer.from('cloud') + const ref = h.reference('item', 1, bytes) + const url = 'https://objects.example.test/item/1' + h.objects.set(url, bytes) + const download = h.native.download + let attempts = 0 + h.native.download = async (options) => { + if (++attempts === 1) { + h.files.set(options.to, Buffer.from('partial')) + throw Object.assign(new Error('expired'), { data: { status: 403 } }) + } + assert.equal(h.files.has(options.to), false) + await download(options) + } + await h.staging.stage(h.source(ref, url)) + assert.equal(attempts, 2) + }) + + it('downloads into staging, verifies, then publishes atomically', async () => { + const h = harness() + const bytes = Buffer.alloc(6_000_000, 42) + const ref = h.reference('item', 3, bytes) + h.objects.set('https://objects.example.test/item/3', bytes) + const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/3')) + assert.equal(cloudSyncStagedHandle(file).owner, h.staging) + const staged = [...h.files.keys()].find((path) => path.startsWith('.zennotes/sync/downloads/'))! + assert.ok(staged, 'file staged in vault-private directory') + const conflict = await h.staging.apply( + { sequence: 9, item_id: 'item', type: 'upsert', path: 'attachements/big.bin', previous_path: null, revision: 3, content_ref: ref }, + undefined, file + ) + assert.equal(conflict, undefined) + assert.deepEqual(h.files.get('attachements/big.bin'), bytes) + assert.equal(h.files.has(staged), false, 'staging file was moved, not copied') + }) + + it('rejects a download whose bytes do not match the reference and leaves nothing behind', async () => { + const h = harness() + const bytes = Buffer.alloc(1000, 1) + const ref = h.reference('item', 1, bytes) + h.objects.set('https://objects.example.test/item/1', Buffer.alloc(1000, 2)) + await assert.rejects(h.staging.stage(h.source(ref, 'https://objects.example.test/item/1')), /verification/) + assert.equal([...h.files.keys()].some((path) => path.startsWith('.zennotes/sync/downloads/')), false) + }) + + it('preserves a local edit as a conflict instead of overwriting it', async () => { + const localEdit = Buffer.from('my local edit') + const h = harness({ 'note.md': localEdit }) + const cloud = Buffer.from('cloud version') + const ref = h.reference('item', 2, cloud, 'utf8') + h.objects.set('https://objects.example.test/item/2', cloud) + const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/2')) + const tracked = { item_id: 'item', path: 'note.md', kind: 'text', revision: 1, sha256: hash(Buffer.from('original')), byte_length: 8, media_type: 'text/markdown' } + const conflict = await h.staging.apply( + { sequence: 5, item_id: 'item', type: 'upsert', path: 'note.md', previous_path: null, revision: 2, content_ref: ref }, + tracked, file + ) + assert.equal(conflict?.code, 'LOCAL_EDIT_CONFLICT') + assert.equal(conflict?.local?.content.data, 'my local edit') + assert.deepEqual(h.files.get('note.md'), localEdit, 'local bytes untouched') + }) + + it('keeps both versions with a byte-verified copy of the local file', async () => { + const local = Buffer.alloc(6_000_000, 7) + const h = harness({ 'attachements/big.bin': local }) + const cloud = Buffer.alloc(6_000_000, 9) + const ref = h.reference('item', 4, cloud) + h.objects.set('https://objects.example.test/item/4', cloud) + const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/4')) + await h.staging.resolve({ + expected_path: 'attachements/big.bin', expected_sha256: hash(local), + cloud_path: 'attachements/big.bin', file, keep_both_path: 'attachements/big (local).bin' + }) + assert.deepEqual(h.files.get('attachements/big.bin'), cloud) + assert.deepEqual(h.files.get('attachements/big (local).bin'), local) + assert.ok(h.log.some((entry) => entry.startsWith('copy attachements/big.bin'))) + }) + + it('refuses to resolve when the local file changed since the conflict was recorded', async () => { + const h = harness({ 'note.md': Buffer.from('changed again') }) + const cloud = Buffer.from('cloud') + const ref = h.reference('item', 1, cloud, 'utf8') + h.objects.set('https://objects.example.test/item/1', cloud) + const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/1')) + await assert.rejects(h.staging.resolve({ + expected_path: 'note.md', expected_sha256: hash(Buffer.from('stale')), cloud_path: 'note.md', file + }), /changed on this device/) + assert.equal(h.files.get('note.md')!.toString(), 'changed again') + }) +}) diff --git a/src/bridge/cloud-sync-staging.ts b/src/bridge/cloud-sync-staging.ts new file mode 100644 index 0000000..57e64bd --- /dev/null +++ b/src/bridge/cloud-sync-staging.ts @@ -0,0 +1,263 @@ +import type { CloudSyncChange, CloudSyncContentReference } from '@zennotes/bridge-contract/cloud-sync' +import { cloudSyncPathKey, normalizeCloudSyncPath, shouldSyncVaultPath } from '@zennotes/shared-domain/cloud-sync' +import { + cloudSyncStagedHandle, + registerCloudSyncStagedFile, + throwIfCloudSyncCancelled, + validateCloudSyncContentReference, + validateCloudSyncDownloadInstruction, + type CloudSyncDownloadSource, + type CloudSyncStagedConflict, + type CloudSyncStagedFile +} from '@zennotes/shared-domain/cloud-sync-content' +import type { CloudSyncRepositoryConflict } from '@zennotes/shared-domain/cloud-sync-coordinator' +import type { CloudSyncLocalItem, CloudSyncTrackedItem } from '@zennotes/shared-domain/cloud-sync-engine' +import type { CloudFileFingerprint } from './native-fs' + +const ROLLBACK_DIRECTORY = '.zennotes/sync' +const STAGING_DIRECTORY = `${ROLLBACK_DIRECTORY}/downloads` + +/** Native file operations the staging layer needs; bytes never cross the bridge. */ +export interface CloudStagingNative { + statVerified(path: string): Promise<'file' | 'directory' | null> + readForSync(path: string, textCandidate: boolean): Promise + download(options: { url: string; headers: Record; to: string; byteLength: number; sha256: string }): Promise + copyForSync(from: string, to: string, byteLength: number): Promise + rename(from: string, to: string): Promise + deleteFile(path: string): Promise + mkdir(path: string): Promise + readText(path: string): Promise +} + +interface StagedHandle { + owner: object + path: string + signal?: AbortSignal +} + +/** Downloads a referenced revision into vault-private staging, then publishes + * it only after the local destination is re-verified against sync state. */ +export class NativeCloudStaging { + constructor( + private readonly native: CloudStagingNative, + private readonly textCandidate: (path: string) => boolean, + private readonly readLocal: (path: string) => Promise + ) {} + + async stage(source: CloudSyncDownloadSource): Promise { + const reference = validateCloudSyncContentReference(source.reference) + throwIfCloudSyncCancelled(source.signal) + await this.native.mkdir(STAGING_DIRECTORY) + const path = `${STAGING_DIRECTORY}/${crypto.randomUUID()}` + const discard = async () => { await this.native.deleteFile(path).catch(() => {}) } + try { + for (let attempt = 0; attempt < 2; attempt++) { + throwIfCloudSyncCancelled(source.signal) + const instruction = validateCloudSyncDownloadInstruction( + { data: await source.getInstruction() }, reference, source.allowInsecureLoopback + ) + if (Date.parse(instruction.download.expires_at) <= Date.now()) { + if (attempt === 0) continue + throw new Error('Cloud returned an expired download URL.') + } + try { + await this.native.download({ + url: instruction.download.url, headers: instruction.download.headers, to: path, + byteLength: reference.byte_length, sha256: reference.sha256 + }) + } catch (error) { + if (attempt === 0 && isExpiredUrlError(error)) { + await discard() + continue + } + throw error + } + throwIfCloudSyncCancelled(source.signal) + const staged = await this.native.readForSync(path, false) + if (staged.sha256 !== reference.sha256 || staged.byteLength !== reference.byte_length) { + throw new Error('Cloud download failed length or SHA-256 verification.') + } + const preview = reference.encoding === 'utf8' && reference.byte_length <= Math.min(source.previewLimitBytes, 262_144) + ? { encoding: 'utf8' as const, data: await this.native.readText(path), sha256: reference.sha256, + byte_length: reference.byte_length, media_type: reference.media_type } + : undefined + if (preview && new TextEncoder().encode(preview.data).byteLength !== reference.byte_length) { + throw new Error('Cloud preview did not match its downloaded bytes.') + } + throwIfCloudSyncCancelled(source.signal) + return registerCloudSyncStagedFile(reference, { owner: this, path, signal: source.signal } satisfies StagedHandle, discard, preview) + } + throw new Error('Cloud download could not be refreshed.') + } catch (error) { + await discard() + throw error + } + } + + async matches(path: string, reference: CloudSyncContentReference): Promise { + if (await this.native.statVerified(path) !== 'file') return false + const local = await this.native.readForSync(path, false) + return local.sha256 === reference.sha256 && local.byteLength === reference.byte_length + } + + async apply(change: CloudSyncChange, previous: CloudSyncTrackedItem | undefined, file: CloudSyncStagedFile): Promise { + const handle = this.handle(file) + if (change.type !== 'upsert' || !change.content_ref || change.item_id !== file.reference.item_id || change.revision < file.reference.revision) { + throw new Error('Invalid staged Cloud change.') + } + cloudSyncStagedHandle(file, change.content_ref) + const target = normalizeCloudSyncPath(change.path) + const sourcePath = previous?.path ? normalizeCloudSyncPath(previous.path) : target + if (![target, sourcePath].every(shouldSyncVaultPath)) return + if (await this.matches(target, file.reference)) { + if (sourcePath !== target && await this.native.statVerified(sourcePath) === 'file') { + if (!await this.vouched(sourcePath, previous)) return this.conflict(sourcePath) + await this.native.deleteFile(sourcePath) + } + return + } + for (const candidate of new Set([sourcePath, target])) { + if (await this.native.statVerified(candidate) === 'file' && !await this.vouched(candidate, previous)) { + return this.conflict(candidate) + } + } + const targetExists = await this.native.statVerified(target) === 'file' + await this.publish(target, handle, file.reference, targetExists ? previous?.sha256 ?? null : null, previous) + if (sourcePath !== target && await this.native.statVerified(sourcePath) === 'file') { + if (!await this.vouched(sourcePath, previous)) return this.conflict(sourcePath) + throwIfCloudSyncCancelled(handle.signal) + await this.native.deleteFile(sourcePath) + } + } + + async resolve(input: CloudSyncStagedConflict): Promise { + const handle = this.handle(input.file) + const expected = input.expected_path === null ? null : normalizeCloudSyncPath(input.expected_path) + const cloud = normalizeCloudSyncPath(input.cloud_path) + const keep = input.keep_both_path === undefined ? null : normalizeCloudSyncPath(input.keep_both_path) + if (!shouldSyncVaultPath(cloud) || (keep && (!shouldSyncVaultPath(keep) || cloudSyncPathKey(keep) === cloudSyncPathKey(cloud) || + (expected && cloudSyncPathKey(keep) === cloudSyncPathKey(expected))))) { + throw new Error('Choose a different filename inside the synced vault.') + } + const local = expected ? await this.fingerprint(expected) : null + if ((local?.sha256 ?? null) !== input.expected_sha256) throw new Error('This file changed on this device.') + const replacing = expected !== null && cloudSyncPathKey(expected) === cloudSyncPathKey(cloud) + if (!replacing && await this.native.statVerified(cloud) !== null) throw new Error(`${cloud} already exists.`) + if (keep) { + if (!expected || !local) throw new Error('Both versions are no longer available.') + if (await this.native.statVerified(keep) !== null) throw new Error(`${keep} already exists.`) + // keep_both_path is user-visible and scanned: a partial copy left behind + // would be uploaded as the "conflict copy". Any failure removes it. + try { + await this.native.copyForSync(expected, keep, local.byteLength) + const copy = await this.native.readForSync(keep, false) + if (copy.sha256 !== local.sha256 || copy.byteLength !== local.byteLength) { + throw new Error('The local copy failed byte verification.') + } + } catch (error) { + await this.native.deleteFile(keep).catch(() => {}) + throw error + } + } + try { + if (expected && (await this.fingerprint(expected))?.sha256 !== input.expected_sha256) { + throw new Error('This file changed on this device.') + } + await this.publish(cloud, handle, input.file.reference, replacing ? input.expected_sha256 : null) + } catch (error) { + // Remove only our unchanged duplicate after the original is confirmed + // safe. A later edit or incomplete rollback must retain both copies. + if (keep && expected && local) { + const original = await this.fingerprint(expected).catch(() => null) + const duplicate = await this.fingerprint(keep).catch(() => null) + if (original?.sha256 === local.sha256 && original.byteLength === local.byteLength && + duplicate?.sha256 === local.sha256 && duplicate.byteLength === local.byteLength) { + await this.native.deleteFile(keep).catch(() => {}) + } + } + throw error + } + if (expected && !replacing) { + if ((await this.fingerprint(expected))?.sha256 !== input.expected_sha256) throw new Error('This file changed on this device.') + throwIfCloudSyncCancelled(handle.signal) + await this.native.deleteFile(expected) + } + } + + private async publish(path: string, handle: StagedHandle, reference: CloudSyncContentReference, expectedHash: string | null, previous?: CloudSyncTrackedItem): Promise { + throwIfCloudSyncCancelled(handle.signal) + if (!await this.matches(handle.path, reference)) throw new Error('The staged Cloud file changed before publication.') + const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '' + if (parent) await this.native.mkdir(parent) + const current = await this.fingerprint(path) + if ((current?.sha256 ?? null) !== expectedHash && !(current && previous && await this.vouched(path, previous))) { + throw new Error('This file changed on this device.') + } + // The backup lives directly under .zennotes/sync so the repository's + // fail-closed recovery check (which lists that directory) sees it if the + // process dies mid-publish. A stranded original must never read as a + // local deletion on the next scan. + const backup = `${ROLLBACK_DIRECTORY}/rollback-${crypto.randomUUID()}` + let publishing = false + try { + throwIfCloudSyncCancelled(handle.signal) + if (current) await this.native.rename(path, backup) + throwIfCloudSyncCancelled(handle.signal) + publishing = true + await this.native.rename(handle.path, path) + const published = await this.native.readForSync(path, false) + if (published.sha256 !== reference.sha256 || published.byteLength !== reference.byte_length) { + throw new Error('Published Cloud file failed byte verification.') + } + throwIfCloudSyncCancelled(handle.signal) + } catch (error) { + if (await this.native.statVerified(backup) === 'file') { + try { + if (await this.native.statVerified(path) !== null) await this.native.deleteFile(path) + await this.native.rename(backup, path) + } catch (restoreError) { + throw new Error(`Cloud publish rollback failed; the original is preserved at ${backup}.`, { cause: restoreError }) + } + } else if (!current && publishing && await this.native.statVerified(path) !== null) { + await this.native.deleteFile(path) + } + throw error + } + if (current) await this.native.deleteFile(backup).catch(() => {}) + } + + private async vouched(path: string, previous: CloudSyncTrackedItem | undefined): Promise { + if (!previous) return false + const local = await this.fingerprint(path) + return local !== null && local.sha256 === previous.sha256 && local.byteLength === previous.byte_length + } + + private async conflict(path: string): Promise { + const local = await this.localItem(path) + return { code: 'LOCAL_EDIT_CONFLICT', path, conflict_copy_path: null, local } + } + + private async localItem(path: string): Promise { + if (await this.native.statVerified(path) !== 'file') return null + return this.readLocal(path) + } + + private async fingerprint(path: string): Promise { + if (await this.native.statVerified(path) !== 'file') return null + return this.native.readForSync(path, this.textCandidate(path)) + } + + private handle(file: CloudSyncStagedFile): StagedHandle { + const handle = cloudSyncStagedHandle(file) + if (handle.owner !== this) throw new Error('Cloud staging belongs to another repository.') + throwIfCloudSyncCancelled(handle.signal) + return handle + } +} + +function isExpiredUrlError(error: unknown): boolean { + // Capacitor plugin rejections carry the HTTP status under `data`. + const candidate = error as { status?: unknown; data?: { status?: unknown } } + const status = candidate?.status ?? candidate?.data?.status + return status === 401 || status === 403 +} diff --git a/src/bridge/mobile-cloud-auth.test.ts b/src/bridge/mobile-cloud-auth.test.ts index 8787175..d9ec325 100644 --- a/src/bridge/mobile-cloud-auth.test.ts +++ b/src/bridge/mobile-cloud-auth.test.ts @@ -12,6 +12,10 @@ const account = { const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account }) async function coldLaunch(saved: Map) { + const lifecycle: string[] = [] + const listeners = new Map void>() + let lifetime = new AbortController() + let delayRead: { entered(): void; wait: Promise } | null = null // Exercise the real Capacitor lazy proxy: concurrent first calls can // instantiate separate implementations, each with its own key prefix. const secureStorage = registerPlugin(`TestCloudStorage${randomUUID()}`, { @@ -22,7 +26,16 @@ async function coldLaunch(saved: Map) { async setKeyPrefix(prefix: string) { this.prefix = prefix } async setSynchronize(_value: boolean) {} async setDefaultKeychainAccess(_value: unknown) {} - async getItem(key: string) { return saved.get(this.prefix + key) ?? null } + async getItem(key: string) { + const value = saved.get(this.prefix + key) ?? null + if (key === 'credential' && delayRead) { + const delayed = delayRead + delayRead = null + delayed.entered() + await delayed.wait + } + return value + } async setItem(key: string, value: string) { saved.set(this.prefix + key, value) } async removeItem(key: string) { saved.delete(this.prefix + key) } }() @@ -30,14 +43,32 @@ async function coldLaunch(saved: Map) { }) const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', { '@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} }, - '@capacitor/app': { App: { addListener: async () => ({}), getLaunchUrl: async () => null } }, + '@capacitor/app': { App: { addListener: async (name: string, listener: (event: any) => void) => { + listeners.set(name, listener) + return {} + }, getLaunchUrl: async () => null } }, '@aparajita/capacitor-secure-storage': { SecureStorage: secureStorage, KeychainAccess: { whenUnlockedThisDeviceOnly: 'device-only' } }, - './cloud-sync-client': { createCloudSyncClient: () => assert.fail('status must only read storage') } + './cloud-sync-client': { + createCloudSyncClient: () => assert.fail('status must only read storage'), + stopMobileCloudRequests: () => { lifecycle.push('stop'); lifetime.abort() }, + resumeMobileCloudRequests: () => { lifecycle.push('resume'); if (lifetime.signal.aborted) lifetime = new AbortController() }, + mobileCloudRequestSignal: () => lifetime.signal + } }) await api.configureMobileCloudAuth('test-version') + api.lifecycle = lifecycle + api.appState = (isActive: boolean) => listeners.get('appStateChange')!({ isActive }) + api.pauseNextCredentialRead = () => { + let entered!: () => void + let release!: () => void + const started = new Promise(resolve => { entered = resolve }) + const wait = new Promise(resolve => { release = resolve }) + delayRead = { entered, wait } + return { started, release } + } return api } @@ -61,10 +92,32 @@ it('preserves the canonical account and prevents a legacy credential from return const api = await coldLaunch(saved) assert.equal((await api.authenticatedCredential()).token, 'test-only') await api.logoutMobileCloudAccount() + assert.ok(api.lifecycle.includes('stop')) assert.equal(saved.size, 0) assert.deepEqual(await (await coldLaunch(saved)).getMobileCloudAccountStatus(), { state: 'disconnected', account: null }) }) +it('stops pending Cloud requests in the background and resumes on foreground', async () => { + const api = await coldLaunch(new Map([['zennotes.cloud.credential', credential]])) + api.appState(false) + api.appState(true) + assert.deepEqual(api.lifecycle, ['stop', 'resume']) +}) + +it('rejects a credential read spanning logout even after foreground creates a new lifetime', async () => { + const saved = new Map([['zennotes.cloud.credential', credential]]) + const api = await coldLaunch(saved) + await api.getMobileCloudAccountStatus() + const paused = api.pauseNextCredentialRead() + const pending = api.authenticatedClient().catch((error: unknown) => error) + await paused.started + await api.logoutMobileCloudAccount() + api.appState(true) + paused.release() + assert.equal((await pending).name, 'AbortError') + assert.equal(saved.size, 0) +}) + it('rejects invalid recovered credentials through the shared auth validator', async () => { const invalid = JSON.stringify({ base_url: 'https://wrong.example.test', token: 'test-only', account }) const saved = new Map([['capacitor-storage_credential', invalid]]) diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index 6bc2712..b892cd7 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -21,7 +21,7 @@ import { type CloudAuthPending, type CloudAuthStorage } from '@zennotes/shared-domain/cloud-auth-flow' -import { createCloudSyncClient, firstValidationMessage } from './cloud-sync-client' +import { createCloudSyncClient, firstValidationMessage, stopMobileCloudRequests, resumeMobileCloudRequests, mobileCloudRequestSignal } from './cloud-sync-client' // The trailing-slash strip matters: allowedInsecureOrigins and the exchange // URL are compared/joined against an origin with no trailing slash. @@ -35,6 +35,7 @@ const accountListeners = new Set<(status: CloudAccountStatus) => void>() let authFlow: CloudAuthFlow | null = null let callbackQueue = Promise.resolve() +let appActive = true // Lazy and retryable so a transient native storage failure does not poison // every later account read for the session. @@ -115,12 +116,15 @@ const storage: CloudAuthStorage = { return credential.value }, async saveCredential(credential: CloudAuthCredential): Promise { + stopMobileCloudRequests() assertNativeCloudAuth() await secureStorageReady() const canonicalCredential = migrateLegacyCloudCredential(credential).value await SecureStorage.setItem(CREDENTIAL_KEY, JSON.stringify(canonicalCredential)) + if (appActive) resumeMobileCloudRequests() }, async deleteCredential(): Promise { + stopMobileCloudRequests() if (!Capacitor.isNativePlatform()) return await secureStorageReady() await SecureStorage.removeItem(CREDENTIAL_KEY) @@ -146,6 +150,11 @@ export async function configureMobileCloudAuth(appVersion: string): Promise { if (isCloudAuthUrl(url)) scheduleAuthCallback(url) }) + await CapApp.addListener('appStateChange', ({ isActive }) => { + appActive = isActive + if (isActive) resumeMobileCloudRequests() + else stopMobileCloudRequests() + }) const launch = await CapApp.getLaunchUrl() if (launch?.url && isCloudAuthUrl(launch.url)) scheduleAuthCallback(launch.url) } @@ -178,6 +187,7 @@ export async function connectMobileCloudAccount( } export async function logoutMobileCloudAccount(): Promise { + stopMobileCloudRequests() const status = await requireAuthFlow().logout() notify(status) return status @@ -220,8 +230,10 @@ export async function listMobileCloudVaults(): Promise { } export async function authenticatedClient() { + const signal = mobileCloudRequestSignal() const credential = await authenticatedCredential() - return createCloudSyncClient(credential.base_url, credential.token) + if (signal.aborted) throw new DOMException('Cloud account changed while loading credentials.', 'AbortError') + return createCloudSyncClient(credential.base_url, credential.token, { accountId: credential.account.user.email, signal }) } export async function authenticatedCredential(): Promise { diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts index 28fcc8e..d21d7c6 100644 --- a/src/bridge/mobile-cloud-sync.integration.test.ts +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -128,6 +128,16 @@ async function fixture(initial: Record = { 'note.md': 'Original' assert.ok(file) return file.bytes.toString('base64') }, + async readForSync(path: string, textCandidate: boolean) { + reads.push(path) + const file = files.get(path) + assert.ok(file) + return { + uri: `file:///vault/${path}`, byteLength: file.bytes.length, + sha256: createHash('sha256').update(file.bytes).digest('hex'), utf8: textCandidate, + inlineBase64: file.bytes.toString('base64') + } + }, async writeText(path: string, data: string) { put(path, data) if (path === failWritePath) throw new Error('Native write failed after writing') @@ -143,6 +153,7 @@ async function fixture(initial: Record = { 'note.md': 'Original' assert.ok(file) files.set(to, file) files.delete(from) + if (to === failWritePath) throw new Error('Native write failed after publishing') } } const vault = { @@ -272,7 +283,7 @@ describe('mobile Cloud adapter wiring', () => { if (uploaded?.type === 'upsert') assert.equal(uploaded.content.data, 'Local changes') }) - it('exposes partial native writes when a pull fails, and can retry safely', async () => { + it('rolls back a partial native publish while preserving earlier completed pulls', async () => { const h = await fixture() await h.sync() h.refreshes.length = 0 @@ -280,7 +291,8 @@ describe('mobile Cloud adapter wiring', () => { h.remoteText('second.md', 'Partially written') h.setFailWrite('second.md') await assert.rejects(h.sync(), /Native write failed/) - assert.deepEqual(h.refreshes, [{ 'note.md': 'Remote update', 'second.md': 'Partially written' }]) + assert.deepEqual(h.refreshes, [{ 'note.md': 'Remote update' }]) + assert.equal(h.files.has('second.md'), false) h.setFailWrite(null) await h.sync() assert.equal(h.files.get('note.md')?.bytes.toString(), 'Remote update') diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index 2c3ec09..b8d91c4 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -2,6 +2,7 @@ import type { CloudSyncCapacityConflict, CloudSyncConflict, CloudSyncConflictCode, + CloudSyncContent, CloudSyncMutation, CloudSyncMutationRequest, CloudSyncMutationResponse, @@ -13,6 +14,15 @@ import type { export const CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 +/** Large scanned files carry no bytes in `data`; the native file URI lives + * here, keyed by identity, so only the uploader that created it can read it. */ +const uploadSources = new WeakMap() + +export function rememberMobileUploadSource(content: CloudSyncContent, uri: string): CloudSyncContent { + uploadSources.set(content, uri) + return content +} + const DIRECT_UPLOAD_COMPLETION_ATTEMPTS = 3 const SYNC_CONFLICT_CODES = new Set([ 'REVISION_CONFLICT', @@ -33,13 +43,12 @@ export interface MobileDirectUploadApi { abortUpload(vaultId: string, uploadId: string): Promise } -export interface MobileObjectUploadRequest { +export type MobileObjectUploadRequest = { url: string method: 'PUT' headers: Record - base64: string byteLength: number -} +} & ({ uri: string; sha256: string; base64?: never } | { base64: string; uri?: never; sha256?: string }) export type MobileObjectUpload = (request: MobileObjectUploadRequest) => Promise @@ -57,6 +66,7 @@ export interface MobileObjectUploadOptions { export function mobileObjectUploadOptions( request: MobileObjectUploadRequest ): MobileObjectUploadOptions { + if (request.uri !== undefined) throw new Error('File-backed uploads require the native file uploader.') // Capacitor iOS only attaches the binary body when Content-Type exists. // Production presigned URLs may return just Host; preserve signed headers. const headers = { ...request.headers } @@ -139,7 +149,8 @@ async function directUpload( mutation: CloudSyncUpsertMutation, uploadObject: MobileObjectUpload ): Promise { - const base64 = uploadBase64(mutation) + const uri = uploadSources.get(mutation.content) + const source = uri === undefined ? { base64: uploadBase64(mutation) } : { uri } let initiation: CloudSyncUploadInitiationResponse try { @@ -164,7 +175,8 @@ async function directUpload( url: secureDirectUploadUrl(instruction.upload.url), method: instruction.upload.method, headers: instruction.upload.headers, - base64, + ...source, + sha256: mutation.content.sha256, byteLength: mutation.content.byte_length }) } catch (error) { diff --git a/src/bridge/native-fs-cloud-sync.test.ts b/src/bridge/native-fs-cloud-sync.test.ts new file mode 100644 index 0000000..1c61824 --- /dev/null +++ b/src/bridge/native-fs-cloud-sync.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +for (const scenario of ['materialized', 'missing', 'directory', 'pending'] as const) { + it(`handles an iCloud ${scenario} file without hashing its stale listing URI`, async () => { + const inspected: string[] = [] + const logical = 'file:///cloud/vault/note.md' + const stub = 'file:///cloud/vault/.note.md.icloud' + const { NativeFs } = await loadMobileModule('./src/bridge/native-fs.ts', { + '@capacitor/core': { Capacitor: {}, registerPlugin: () => ({ + inspect: async ({ uri }: { uri: string }) => { inspected.push(uri); return { uri } } + }) }, + '@capacitor/filesystem': { Directory: { Documents: 'DOCUMENTS' }, Encoding: {}, Filesystem: { + stat: async ({ path }: { path: string }) => { + if (path === stub) return { uri: stub, type: 'file' } + assert.equal(path, logical) + if (scenario === 'missing') throw Object.assign(new Error('does not exist'), { code: 'OS-PLUG-FILE-0008' }) + return { uri: logical, type: scenario === 'directory' ? 'directory' : 'file' } + } + } }, + './icloud': { ensureDownloaded: async () => scenario === 'pending' ? 1 : 0 } + }) + const fs = new NativeFs('vault', 'file:///cloud/vault') + if (scenario === 'materialized') { + await fs.readForSync('note.md', true, stub) + assert.deepEqual(inspected, [logical]) + } else { + await assert.rejects(fs.readForSync('note.md', true, stub)) + assert.deepEqual(inspected, []) + } + }) +} diff --git a/src/bridge/native-fs.ts b/src/bridge/native-fs.ts index 6fa73fe..b69d0c6 100644 --- a/src/bridge/native-fs.ts +++ b/src/bridge/native-fs.ts @@ -16,13 +16,28 @@ * translation to the on-device location. Nothing above this file touches * Capacitor directly for file I/O. */ -import { Capacitor } from '@capacitor/core' +import { Capacitor, registerPlugin } from '@capacitor/core' import { Directory, Encoding, Filesystem, type FileInfo } from '@capacitor/filesystem' import { ensureDownloaded } from './icloud' import { bytesToBase64 } from './base64' export const VAULTS_DIR = 'ZenNotes' +export interface CloudFileFingerprint { + uri: string + sha256: string + byteLength: number + utf8: boolean + inlineBase64?: string +} + +/** App-local CloudFilesPlugin.swift; same jsName and shapes as Android. */ +const CloudFiles = registerPlugin<{ + inspect(options: { uri: string; textCandidate: boolean }): Promise + copy(options: { from: string; to: string; byteLength: number; sha256: string }): Promise + download(options: { url: string; headers: Record; to: string; byteLength: number; sha256: string }): Promise +}>('ZenDirectUpload') + export interface StatResult { type: 'file' | 'directory' size: number @@ -147,6 +162,49 @@ export class NativeFs { } } + /** Native fingerprint: SHA-256, length and UTF-8 validity without bringing + * the bytes into the WebView. Small files also return inline base64. */ + async readForSync(relPath: string, textCandidate: boolean, knownUri?: string): Promise { + if (this.cloudRootUri) { + // On an iCloud vault the listing may hand us the `.name.icloud` stub's + // URI for an evicted file. Hashing that would fingerprint the stub + // plist, not the note. Materialize the logical path and refuse to + // continue if it is still evicted; never trust the listed URI here. + if (await ensureDownloaded(this.loc(relPath).path, 15000) > 0) { + throw new Error(`${relPath} is still downloading from iCloud.`) + } + const s = await Filesystem.stat(this.loc(relPath)) + if (s.type !== 'file') throw new Error(`${relPath} is not a materialized file.`) + return CloudFiles.inspect({ uri: s.uri, textCandidate }) + } + const uri = knownUri ?? this.fileUri(relPath) + if (!uri) throw new Error('Vault root is not resolved.') + return CloudFiles.inspect({ uri, textCandidate }) + } + + /** Verified native copy into a new staging path. */ + async copyForSync(fromRel: string, toRel: string, byteLength: number): Promise { + if (fromRel === toRel || !Number.isSafeInteger(byteLength) || byteLength < 0) throw new Error('Invalid Cloud file copy.') + if (await this.statVerified(toRel) !== null) throw new Error('Cloud copy destination already exists.') + const source = await this.readForSync(fromRel, false) + if (source.byteLength !== byteLength) throw new Error('The Cloud copy source changed size.') + const parent = toRel.includes('/') ? toRel.slice(0, toRel.lastIndexOf('/')) : '' + if (parent) await this.mkdir(parent) + const to = this.fileUri(toRel) + if (!to) throw new Error('Vault root is not resolved.') + await CloudFiles.copy({ from: source.uri, to, byteLength, sha256: source.sha256 }) + } + + /** Stream a signed Cloud revision to a staging file; native verifies before resolving. */ + async download(options: { url: string; headers: Record; to: string; byteLength: number; sha256: string }): Promise { + if (await this.statVerified(options.to) !== null) throw new Error('Cloud download destination already exists.') + const parent = options.to.includes('/') ? options.to.slice(0, options.to.lastIndexOf('/')) : '' + if (parent) await this.mkdir(parent) + const to = this.fileUri(options.to) + if (!to) throw new Error('Vault root is not resolved.') + await CloudFiles.download({ ...options, to }) + } + /** * Plain overwrite, matching desktop `writeNote` (which uses fs.writeFile, * not the atomic path). No `.tmp`/`.bak` siblings: anything non-md left in diff --git a/tooling/add-cloud-files.rb b/tooling/add-cloud-files.rb new file mode 100644 index 0000000..21d2061 --- /dev/null +++ b/tooling/add-cloud-files.rb @@ -0,0 +1,13 @@ +require 'xcodeproj' + +project = Xcodeproj::Project.open(File.expand_path('../ios/App/App.xcodeproj', __dir__)) +target = project.targets.find { |candidate| candidate.name == 'App' } +group = project.main_group['App'] +raise 'App target or group not found' unless target && group + +%w[CloudFilesPlugin.swift CloudFileStream.swift].each do |name| + reference = group.files.find { |file| file.display_name == name } || group.new_reference(name) + target.add_file_references([reference]) unless target.source_build_phase.files_references.include?(reference) +end + +project.save diff --git a/tooling/cloud-file-stream-tests.swift b/tooling/cloud-file-stream-tests.swift new file mode 100644 index 0000000..46b036e --- /dev/null +++ b/tooling/cloud-file-stream-tests.swift @@ -0,0 +1,84 @@ +import CryptoKit +import Foundation + +@main +enum CloudFileStreamTests { + enum Failure: Error { case assertion(String) } + static func check(_ value: Bool, _ message: String) throws { + if !value { throw Failure.assertion(message) } + } + static func rejects(_ message: String, _ operation: () throws -> Void) throws { + do { try operation() } catch { return } + throw Failure.assertion(message) + } + static func sha(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + static func main() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("cloud-file-tests-\(UUID().uuidString)") + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + let missing = root.appendingPathComponent("missing") + try rejects("Missing files must not fingerprint as empty") { + _ = try CloudFileStream.fingerprint(missing, textCandidate: true) + } + let empty = root.appendingPathComponent("empty") + try Data().write(to: empty) + let emptyPrint = try CloudFileStream.fingerprint(empty, textCandidate: true) + try check(emptyPrint.byteLength == 0 && emptyPrint.sha256 == sha(Data()), "Real empty file") + try rejects("Missing streams must not copy as empty") { + try CloudFileStream.copyVerified(InputStream(url: missing)!, to: root.appendingPathComponent("copy"), + expected: .init(byteLength: 0, sha256: sha(Data()))) + } + + for text in ["日本語", "café", "🙂", String(repeating: "a", count: 65_535) + "🙂日本"] { + let file = root.appendingPathComponent("utf8") + let bytes = Data(text.utf8) + try bytes.write(to: file) + let result = try CloudFileStream.fingerprint(file, textCandidate: true) + try check(result.utf8 && result.sha256 == sha(bytes), "Valid UTF-8 misclassified") + for split in 0...min(bytes.count, 8) { + var validator = Utf8Validator() + validator.accept(Array(bytes.prefix(split))[...]) + validator.accept(Array(bytes.dropFirst(split))[...]) + try check(validator.finish(), "Split UTF-8 misclassified") + } + } + for bytes: [UInt8] in [[0xC0, 0xAF], [0xED, 0xA0, 0x80], [0xF4, 0x90, 0x80, 0x80], [0xE2, 0x82], [0x80]] { + var validator = Utf8Validator() + for byte in bytes { validator.accept([byte][...]) } + try check(!validator.finish(), "Malformed UTF-8 accepted") + } + + let bytes = Data((0..<150_007).map { UInt8($0 % 251) }) + let copied = root.appendingPathComponent("verified") + try CloudFileStream.copyVerified(InputStream(data: bytes), to: copied, + expected: .init(byteLength: bytes.count, sha256: sha(bytes))) + try check(try Data(contentsOf: copied) == bytes, "Copy lost bytes across buffer boundaries") + try rejects("Corrupt content must fail") { + try CloudFileStream.copyVerified(InputStream(data: bytes), to: copied, + expected: .init(byteLength: bytes.count, sha256: sha(Data()))) + } + + let vault = root.appendingPathComponent("vault") + let outside = root.appendingPathComponent("vault-other") + try fm.createDirectory(at: vault, withIntermediateDirectories: true) + try fm.createDirectory(at: outside, withIntermediateDirectories: true) + try fm.createSymbolicLink(at: vault.appendingPathComponent("escape"), withDestinationURL: outside) + for path in [outside.appendingPathComponent("secret"), vault.appendingPathComponent("escape/new"), + vault.appendingPathComponent("../vault-other/secret"), vault] { + try check(CloudFileStream.confinedURL(path.absoluteString, roots: [vault]) == nil, "Escaping path allowed") + } + try check(CloudFileStream.confinedURL(vault.appendingPathComponent("new/file").absoluteString, roots: [vault]) != nil, + "New file under selected vault denied") + let url = URL(string: "http://127.0.0.1:19101/object")! + try check(CloudFileStream.allowedHeader("Host", value: "127.0.0.1:19101", url: url), "Presigned Host header denied") + for header in [("Host", "other.example.test"), ("Authorization", "Bearer secret"), + ("Cookie", "session=secret"), ("X-Test", "value\r\nCookie: secret")] { + try check(!CloudFileStream.allowedHeader(header.0, value: header.1, url: url), "Unsafe signed header accepted") + } + print("Native file integrity, UTF-8, and confinement regressions passed") + } +} diff --git a/tooling/cloud-file-stream.test.mjs b/tooling/cloud-file-stream.test.mjs new file mode 100644 index 0000000..bd907f9 --- /dev/null +++ b/tooling/cloud-file-stream.test.mjs @@ -0,0 +1,23 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { it } from 'node:test' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +const exec = promisify(execFile) +const root = fileURLToPath(new URL('../', import.meta.url)) + +it('verifies production Swift file integrity and confinement with real Foundation streams', { + skip: process.platform !== 'darwin', timeout: 120_000 +}, async () => { + const directory = await mkdtemp(join(tmpdir(), 'cloud-file-native-tests-')) + try { + const binary = join(directory, 'tests') + await exec('xcrun', ['swiftc', 'ios/App/App/CloudFileStream.swift', 'tooling/cloud-file-stream-tests.swift', '-o', binary], { cwd: root }) + await exec(binary, [], { timeout: 30_000 }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/vendor/zennotes/manifest.json b/vendor/zennotes/manifest.json index 51a076d..65e306d 100644 --- a/vendor/zennotes/manifest.json +++ b/vendor/zennotes/manifest.json @@ -1,12 +1,12 @@ { "name": "@zennotes/app-core", - "version": "2.60.0-core.hb0d0b54f320a8e3f", - "file": "zennotes-app-core-2.60.0-core.hb0d0b54f320a8e3f.tgz", - "sha256": "bef83d372725736b26b62724e90126dfb474ad4a3ed88205b806cf5a3be2f97f", - "integrity": "sha512-aMZ8DegEHQTwT4tf6kdvAHEUMfhi4nayN81lHKw4PSZZfDr4UKvhpufIZEm5xO4UR4gyKEJSwd4/1QNBB+aC+Q==", - "sourceCommit": "158293947e451f2a88011ddf647517c847cc0a76", + "version": "2.60.1-core.h05ebb55c14afffb2", + "file": "zennotes-app-core-2.60.1-core.h05ebb55c14afffb2.tgz", + "sha256": "2a1781c1121f37cf494b5f34d67ea8dce25b5bdfe6dee0d7a9837ceaec8cfd75", + "integrity": "sha512-pTn7+3VmHG1le5pzBruIAzlHg4Nc+CRucL/u4FLbktdPDeIQ/PWECeS+fkia7JM5w6UVhqFxY5ZP24gj77vfFg==", + "sourceCommit": "4c74b478148a68adc8d77cc5c0d54a46ab469342", "workingTreeDirty": false, - "sourceLockSha256": "34573e8de5f27f233eef597e71c61a5d1e161d5679469b1049dd9b5c708a255c", + "sourceLockSha256": "503c4639d5dbcb1a909ad122882c66aea751747f1a2a8fbcb8bdf8077eb8d871", "toolchain": { "node": "v22.23.3", "typescript": "5.9.3", @@ -15,20 +15,20 @@ "dependencies": [ { "name": "@zennotes/bridge-contract", - "version": "2.60.0-boundaries.hbdc4a2a12368fec1", - "file": "zennotes-bridge-contract-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", - "sha256": "02f9beedd7d6d9454571bcaeb158d5194615c9f0b9dad42a428757aa774d068c", - "integrity": "sha512-5uh7dQjtrWNds26/4FH3wcrEQ0cF6SgUYd0+bGHKtl8GOLlayJWmDaJPXgm7FQ97Gc18KNM47k6k2rxYuECx3A==", - "sourceCommit": "158293947e451f2a88011ddf647517c847cc0a76", + "version": "2.60.1-boundaries.ha881a2f8575a38bf", + "file": "zennotes-bridge-contract-2.60.1-boundaries.ha881a2f8575a38bf.tgz", + "sha256": "79f327d998e999bbeaaa12f25272be3f4c2276fc36e4a1470d252586b6e40e3f", + "integrity": "sha512-/BjWqbzR4+S8Bg8gOwT6OZO8Ikr8qO8zNmVVscjr45vIWasAykfaLS9H6IuFfIE48/wbAXb3W8J6aTHNEdk0xA==", + "sourceCommit": "4c74b478148a68adc8d77cc5c0d54a46ab469342", "workingTreeDirty": false }, { "name": "@zennotes/shared-domain", - "version": "2.60.0-boundaries.hbdc4a2a12368fec1", - "file": "zennotes-shared-domain-2.60.0-boundaries.hbdc4a2a12368fec1.tgz", - "sha256": "5bec93dfe153ad0687c5e003f3db3bf53cae33f5ba37deefb4327fba96f77711", - "integrity": "sha512-Gr/F9IkAnX24T9apcPWLhErAtQ4N/QUr/rIdHJqNOTxERVSjrvIqP+wGCzNyuVRDujX9eXOfAt0y4Ir6Ol0TwQ==", - "sourceCommit": "158293947e451f2a88011ddf647517c847cc0a76", + "version": "2.60.1-boundaries.ha881a2f8575a38bf", + "file": "zennotes-shared-domain-2.60.1-boundaries.ha881a2f8575a38bf.tgz", + "sha256": "4e97e45e1f319091c85ead57ebaf4b0e64928d4d2d6d814af00cae2ca13535a2", + "integrity": "sha512-5Sb02aV5yUy3MkqR9rkrx7eHQTRmurivhMVpeVa4kU0S07dm08N6kpstB8uIeKVlxhDvLILQHhBw606N3wx+aA==", + "sourceCommit": "4c74b478148a68adc8d77cc5c0d54a46ab469342", "workingTreeDirty": false } ] diff --git a/vendor/zennotes/zennotes-app-core-2.60.0-core.hb0d0b54f320a8e3f.tgz b/vendor/zennotes/zennotes-app-core-2.60.1-core.h05ebb55c14afffb2.tgz similarity index 51% rename from vendor/zennotes/zennotes-app-core-2.60.0-core.hb0d0b54f320a8e3f.tgz rename to vendor/zennotes/zennotes-app-core-2.60.1-core.h05ebb55c14afffb2.tgz index 13db6b2..0124ea0 100644 Binary files a/vendor/zennotes/zennotes-app-core-2.60.0-core.hb0d0b54f320a8e3f.tgz and b/vendor/zennotes/zennotes-app-core-2.60.1-core.h05ebb55c14afffb2.tgz differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.60.0-boundaries.hbdc4a2a12368fec1.tgz b/vendor/zennotes/zennotes-bridge-contract-2.60.0-boundaries.hbdc4a2a12368fec1.tgz deleted file mode 100644 index c8afe37..0000000 Binary files a/vendor/zennotes/zennotes-bridge-contract-2.60.0-boundaries.hbdc4a2a12368fec1.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.60.1-boundaries.ha881a2f8575a38bf.tgz b/vendor/zennotes/zennotes-bridge-contract-2.60.1-boundaries.ha881a2f8575a38bf.tgz new file mode 100644 index 0000000..c934918 Binary files /dev/null and b/vendor/zennotes/zennotes-bridge-contract-2.60.1-boundaries.ha881a2f8575a38bf.tgz differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.60.0-boundaries.hbdc4a2a12368fec1.tgz b/vendor/zennotes/zennotes-shared-domain-2.60.0-boundaries.hbdc4a2a12368fec1.tgz deleted file mode 100644 index fce5729..0000000 Binary files a/vendor/zennotes/zennotes-shared-domain-2.60.0-boundaries.hbdc4a2a12368fec1.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.60.1-boundaries.ha881a2f8575a38bf.tgz b/vendor/zennotes/zennotes-shared-domain-2.60.1-boundaries.ha881a2f8575a38bf.tgz new file mode 100644 index 0000000..1c73e99 Binary files /dev/null and b/vendor/zennotes/zennotes-shared-domain-2.60.1-boundaries.ha881a2f8575a38bf.tgz differ