diff --git a/CONVERSIONS.md b/CONVERSIONS.md index cb4c8f3..37f0f2e 100644 --- a/CONVERSIONS.md +++ b/CONVERSIONS.md @@ -17,7 +17,9 @@ | **PDF** | ✅ `pdf-to-md` | · | ✅ `pdf-to-docx` | ✅ `pdf-to-latex` | · | — | · | | **BibLaTeX (.bib)** | ✅ `bib-apa-to-md` | ✅ `bib-apa-to-html` | · | · | ✅ `bib-apa-to-json` | · | · | | **SRT** | · | ✅ `srt-to-html` | · | · | · | · | — | -| **Note (.note)** | · | ✅ `note-to-html` | · | · | · | · | · | +| **舊版 Note (.note)** | · | ✅ `note-to-html` | · | · | · | ✅ `note-to-pdf` | · | + +Notability 轉換目前支援舊版 plist-based `.note`(`Session.plist`)。現代 `.ntb`(FlatBuffers `noteBundle`)會被辨識,但尚不支援轉換;尚未實作 FlatBuffers 的手寫/時間軸重播,也不會抽取錄音、縮圖或其他資產作為替代輸出。 ## Converter Details @@ -36,7 +38,8 @@ | HTML → Word | `html-to-word-swift` | ✅ implemented | SwiftSoup → OOXML writer | | Markdown → Word | `md-to-word-swift` | ✅ implemented | swift-markdown AST → OOXML writer | | PDF → DOCX | `pdf-to-docx-swift` | ✅ implemented | PDFKit text extraction → OOXML writer | -| Note → HTML | `note-to-html-swift` | ✅ implemented | Notability .note → interactive HTML player with audio-synced stroke replay | +| 舊版 Note → HTML | `note-to-html-swift` | ✅ implemented | plist-based `.note` → interactive HTML player with audio-synced stroke replay | +| 舊版 Note → PDF | `note-to-pdf-swift` | ✅ implemented | plist-based `.note` → rendered PDF | ## Rules diff --git a/Package.swift b/Package.swift index 0ff3e08..54345a6 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( .package(name: "DocxWorkflowLib", path: "packages/docx-workflow-swift"), .package(url: "https://github.com/PsychQuant/note-to-html-swift.git", from: "0.1.1"), .package(url: "https://github.com/PsychQuant/note-to-pdf-swift.git", from: "0.1.3"), + .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.20"), // NoteCore is transitively pulled via note-to-html-swift/note-to-pdf-swift, // but declaring it here lets MacDocCLITests `import NoteCore` to verify // synthetic .note generation against the parser (per #100 Plan). @@ -45,9 +46,16 @@ let package = Package( .package(url: "https://github.com/PsychQuant/ooxml-swift.git", from: "3.0.0"), ], targets: [ + .target( + name: "NotabilityContainerDetection", + dependencies: [ + .product(name: "ZIPFoundation", package: "ZIPFoundation"), + ] + ), .executableTarget( name: "MacDocCLI", dependencies: [ + "NotabilityContainerDetection", .product(name: "CommonConverterSwift", package: "common-converter-swift"), .product(name: "OOXMLSwift", package: "ooxml-swift"), .product(name: "WordToMD", package: "word-to-md-swift"), @@ -75,6 +83,7 @@ let package = Package( .testTarget( name: "MacDocCLITests", dependencies: [ + "NotabilityContainerDetection", .product(name: "NoteCore", package: "note-core-swift"), // Authoring API for building synthetic docx fixtures in tests // (WordReverseCoverageTests → emptyAuthoringDocument). diff --git a/README.md b/README.md index be23f5b..935aac9 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,13 @@ macdoc convert --to html refs.bib --full macdoc convert --to md refs.bib macdoc convert --to json refs.bib -# Note → HTML(Notability 筆記互動播放器) -macdoc convert --to html notes.note --full +# Note → HTML / PDF(Notability 筆記;僅限舊版容器) macdoc convert --to html notes.note --full --css dark +macdoc convert --to pdf notes.note --output notes.pdf ``` +Notability 轉換目前支援舊版 plist-based `.note`(`Session.plist`)。現代 `.ntb`(FlatBuffers `noteBundle`)會被辨識,但尚不支援轉換;尚未實作 FlatBuffers 的手寫/時間軸重播,也不會抽取錄音、縮圖或其他資產作為替代輸出。 + 常用選項: | 選項 | 說明 | @@ -233,7 +235,8 @@ macdoc config ai set transcription codex | BibLaTeX → HTML | `convert --to html` | | BibLaTeX → Markdown | `convert --to md` | | BibLaTeX → JSON | `convert --to json` | -| Note → HTML | `convert --to html` | +| 舊版 Note → HTML | `convert --to html` | +| 舊版 Note → PDF | `convert --to pdf` | ## MCP Servers diff --git a/Sources/MacDocCLI/MacDoc+Convert.swift b/Sources/MacDocCLI/MacDoc+Convert.swift index 179d1e4..67e8351 100644 --- a/Sources/MacDocCLI/MacDoc+Convert.swift +++ b/Sources/MacDocCLI/MacDoc+Convert.swift @@ -16,7 +16,8 @@ import BibAPAToJSON import BibAPAToMD import MarkerWordConverter import NoteToHTML -import NoteToPDF +import NoteToPDF +import NotabilityContainerDetection // MARK: - Convert 子命令(textutil-compatible 統一入口) extension MacDoc { @@ -105,10 +106,12 @@ extension MacDoc { case ("tex", "docx"): try convertTeXToWord(inputURL: inputURL) - case ("note", "html"): - try convertNoteToHTML(inputURL: inputURL) - - case ("note", "pdf"): + case ("note", "html"), ("ntb", "html"): + try rejectUnsupportedNotabilityGeneration(inputURL: inputURL) + try convertNoteToHTML(inputURL: inputURL) + + case ("note", "pdf"), ("ntb", "pdf"): + try rejectUnsupportedNotabilityGeneration(inputURL: inputURL) try convertNoteToPDF(inputURL: inputURL) default: @@ -410,6 +413,18 @@ extension MacDoc { // MARK: - Note → HTML + private func rejectUnsupportedNotabilityGeneration(inputURL: URL) throws { + switch NotabilityContainerDetector.classify(at: inputURL) { + case .legacy: + return + case .modernFlatBuffers: + throw ValidationError(NotabilityContainerDetector.modernContainerDiagnostic) + case .unknown: + guard inputURL.pathExtension.lowercased() == "ntb" else { return } + throw ValidationError(NotabilityContainerDetector.unrecognizedNTBContainerDiagnostic) + } + } + private func convertNoteToHTML(inputURL: URL) throws { if css != .dark && css != .light { throw ValidationError("Note → HTML 的 --css 只支援 dark 或 light") diff --git a/Sources/NotabilityContainerDetection/NotabilityContainerDetector.swift b/Sources/NotabilityContainerDetection/NotabilityContainerDetector.swift new file mode 100644 index 0000000..d5d93f1 --- /dev/null +++ b/Sources/NotabilityContainerDetection/NotabilityContainerDetector.swift @@ -0,0 +1,258 @@ +import Foundation +import ZIPFoundation + +package enum NotabilityContainerGeneration: Equatable { + case legacy + case modernFlatBuffers + case unknown +} + +package enum NotabilityContainerDetector { + package static let modernContainerDiagnostic = + "偵測到新版 Notability .ntb 容器(noteBundle/FlatBuffers);目前僅支援舊版 plist-based .note(Session.plist)" + package static let unrecognizedNTBContainerDiagnostic = + "無法安全辨識 Notability .ntb 容器;目前僅支援舊版 plist-based .note(Session.plist)" + + private static let maximumInspectedEntries = 4_096 + private static let maximumEntryPathByteCount = 4_096 + private static let maximumCentralDirectoryByteCount = 16 * 1_024 * 1_024 + private static let endOfCentralDirectorySize = 22 + private static let centralDirectoryHeaderSize = 46 + private static let maximumZIPCommentByteCount = 65_535 + private static let endOfCentralDirectorySignature: [UInt8] = [0x50, 0x4b, 0x05, 0x06] + private static let centralDirectorySignature: [UInt8] = [0x50, 0x4b, 0x01, 0x02] + + private struct ArchiveDirectoryMetadata { + let entryCount: Int + } + + package static func classify(at archiveURL: URL) -> NotabilityContainerGeneration { + do { + guard let metadata = try boundedDirectoryMetadata(at: archiveURL), + metadata.entryCount <= maximumInspectedEntries else { + return .unknown + } + let archive = try Archive(url: archiveURL, accessMode: .read) + var visitedEntryCount = 0 + var regularEntryPaths: [String] = [] + + for entry in archive { + visitedEntryCount += 1 + guard visitedEntryCount <= metadata.entryCount else { return .unknown } + let path = entry.path + guard path.utf8.count <= maximumEntryPathByteCount else { return .unknown } + if entry.type == .file { + regularEntryPaths.append(path) + } + } + + // ZIPFoundation's iterator silently stops when a later local header is + // malformed. Compare against the bounded EOCD count so a partial walk + // cannot hide a trailing legacy marker or misclassify a damaged ZIP. + guard visitedEntryCount == metadata.entryCount else { return .unknown } + return classify(regularEntryPaths: regularEntryPaths) + } catch { + return .unknown + } + } + + package static func classify( + regularEntryPaths: EntryPaths + ) -> NotabilityContainerGeneration where EntryPaths.Element == String { + var foundModernMarker = false + var inspectedEntryCount = 0 + + for path in regularEntryPaths { + inspectedEntryCount += 1 + guard inspectedEntryCount <= maximumInspectedEntries, + path.utf8.count <= maximumEntryPathByteCount else { + return .unknown + } + switch finalComponent(of: path) { + case "Session.plist": + return .legacy + case "noteBundle": + foundModernMarker = true + default: + continue + } + } + + return foundModernMarker ? .modernFlatBuffers : .unknown + } + + /// Reads only the bounded ZIP tail and validates the classic EOCD record. + /// ZIP64 and multi-disk archives are rejected as unknown: neither is needed + /// for a Notability container marker and accepting them would reintroduce an + /// attacker-controlled entry count. + private static func boundedDirectoryMetadata( + at archiveURL: URL + ) throws -> ArchiveDirectoryMetadata? { + let handle = try FileHandle(forReadingFrom: archiveURL) + defer { try? handle.close() } + + let fileSize = try handle.seekToEnd() + guard fileSize >= UInt64(endOfCentralDirectorySize) else { return nil } + let maximumTailSize = endOfCentralDirectorySize + maximumZIPCommentByteCount + let tailSize = Int(min(fileSize, UInt64(maximumTailSize))) + try handle.seek(toOffset: fileSize - UInt64(tailSize)) + guard let tail = try handle.read(upToCount: tailSize), + tail.count == tailSize else { + return nil + } + + var candidateOffsets: [Int] = [] + var zipFoundationCandidateOffset: Int? + let lastCandidate = tail.count - endOfCentralDirectorySize + for offset in stride(from: lastCandidate, through: 0, by: -1) { + guard hasEOCDSignature(tail, at: offset) else { continue } + + // ZIPFoundation selects the first complete signature encountered + // while scanning backward, without checking that commentLength + // consumes the remaining bytes. Track that exact choice so our + // independently validated record cannot diverge from its parser. + if zipFoundationCandidateOffset == nil { + zipFoundationCandidateOffset = offset + } + + let commentLength = Int(readUInt16(tail, at: offset + 20)) + guard offset + endOfCentralDirectorySize + commentLength == tail.count else { + continue + } + + candidateOffsets.append(offset) + // A ZIP comment may contain a second syntactically valid EOCD. + // ZIPFoundation trusts the last one, which lets its entry count hide + // a later legacy marker. Ambiguity is therefore unsafe, not a tie to + // resolve heuristically. + guard candidateOffsets.count == 1 else { return nil } + } + + guard let offset = candidateOffsets.first, + let zipFoundationCandidateOffset, + offset == zipFoundationCandidateOffset else { + return nil + } + + let diskNumber = readUInt16(tail, at: offset + 4) + let centralDirectoryDisk = readUInt16(tail, at: offset + 6) + let entriesOnDisk = readUInt16(tail, at: offset + 8) + let totalEntries = readUInt16(tail, at: offset + 10) + let centralDirectorySize = readUInt32(tail, at: offset + 12) + let centralDirectoryOffset = readUInt32(tail, at: offset + 16) + + // 0xffff / 0xffffffff are ZIP64 sentinels. + guard diskNumber == 0, + centralDirectoryDisk == 0, + entriesOnDisk == totalEntries, + totalEntries != .max, + Int(totalEntries) <= maximumInspectedEntries, + centralDirectorySize != .max, + Int(centralDirectorySize) <= maximumCentralDirectoryByteCount, + centralDirectoryOffset != .max else { + return nil + } + + let absoluteEOCDOffset = fileSize - UInt64(tail.count) + UInt64(offset) + let centralDirectoryEnd = UInt64(centralDirectoryOffset) + UInt64(centralDirectorySize) + guard centralDirectoryEnd == absoluteEOCDOffset else { return nil } + + try handle.seek(toOffset: UInt64(centralDirectoryOffset)) + let directorySize = Int(centralDirectorySize) + guard let directory = try handle.read(upToCount: directorySize), + directory.count == directorySize, + validateCentralDirectory(directory, entryCount: Int(totalEntries)) else { + return nil + } + + return ArchiveDirectoryMetadata(entryCount: Int(totalEntries)) + } + + private static func hasEOCDSignature(_ data: Data, at offset: Int) -> Bool { + endOfCentralDirectorySignature.indices.allSatisfy { + data[offset + $0] == endOfCentralDirectorySignature[$0] + } + } + + private static func validateCentralDirectory(_ data: Data, entryCount: Int) -> Bool { + var offset = 0 + for _ in 0.. Bool { + var offset = start + while offset < end { + guard offset + 4 <= end else { return false } + let headerID = readUInt16(data, at: offset) + let fieldSize = Int(readUInt16(data, at: offset + 2)) + guard fieldSize <= end - offset - 4, + headerID != 0x0001 else { + return false + } + offset += 4 + fieldSize + } + return offset == end + } + + private static func readUInt16(_ data: Data, at offset: Int) -> UInt16 { + UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8) + } + + private static func readUInt32(_ data: Data, at offset: Int) -> UInt32 { + UInt32(data[offset]) + | (UInt32(data[offset + 1]) << 8) + | (UInt32(data[offset + 2]) << 16) + | (UInt32(data[offset + 3]) << 24) + } + + private static func finalComponent(of path: String) -> String? { + path + .replacingOccurrences(of: "\\", with: "/") + .split(separator: "/", omittingEmptySubsequences: true) + .filter { $0 != "." } + .last + .map(String.init) + } +} diff --git a/Tests/MacDocCLITests/NotabilityContainerDetectionTests.swift b/Tests/MacDocCLITests/NotabilityContainerDetectionTests.swift new file mode 100644 index 0000000..083fd4c --- /dev/null +++ b/Tests/MacDocCLITests/NotabilityContainerDetectionTests.swift @@ -0,0 +1,398 @@ +import Foundation +import XCTest +@testable import NotabilityContainerDetection + +final class NotabilityContainerDetectionTests: XCTestCase { + private static let modernContainerDiagnostic = + "偵測到新版 Notability .ntb 容器(noteBundle/FlatBuffers);目前僅支援舊版 plist-based .note(Session.plist)" + private static let unrecognizedNTBContainerDiagnostic = + "無法安全辨識 Notability .ntb 容器;目前僅支援舊版 plist-based .note(Session.plist)" + + func testEntryClassificationGivesLegacyMarkerPrecedence() { + XCTAssertEqual( + NotabilityContainerDetector.classify( + regularEntryPaths: ["export/noteBundle", "export/Session.plist"] + ), + .legacy + ) + } + + func testEntryClassificationRecognizesWrappedModernMarker() { + XCTAssertEqual( + NotabilityContainerDetector.classify( + regularEntryPaths: ["./export\\noteBundle", "manifest.json"] + ), + .modernFlatBuffers + ) + } + + func testEntryClassificationPreservesUnknownFallback() { + XCTAssertEqual( + NotabilityContainerDetector.classify( + regularEntryPaths: ["manifest.json", "thumbnail.png"] + ), + .unknown + ) + } + + func testEntryClassificationStopsBeforeMarkerBeyondSafetyLimit() { + let paths = Array(repeating: "assets/thumbnail.png", count: 4_096) + ["noteBundle"] + + XCTAssertEqual( + NotabilityContainerDetector.classify(regularEntryPaths: paths), + .unknown + ) + } + + func testEntryClassificationRejectsOversizedEntryName() { + XCTAssertEqual( + NotabilityContainerDetector.classify( + regularEntryPaths: [String(repeating: "a", count: 4_097) + "/noteBundle"] + ), + .unknown + ) + } + + func testUnreadableArchiveIsUnknown() { + let missing = temporaryURL(pathExtension: "ntb") + XCTAssertEqual(NotabilityContainerDetector.classify(at: missing), .unknown) + } + + func testLargeSparseFileWithoutEOCDIsUnknown() throws { + let file = temporaryURL(pathExtension: "ntb") + defer { removeIfPresent(file) } + + FileManager.default.createFile(atPath: file.path, contents: Data("not-a-zip".utf8)) + let handle = try FileHandle(forWritingTo: file) + try handle.truncate(atOffset: 256 * 1_024 * 1_024) + try handle.close() + + let clock = ContinuousClock() + let elapsed = clock.measure { + XCTAssertEqual(NotabilityContainerDetector.classify(at: file), .unknown) + } + XCTAssertLessThan(elapsed, .seconds(1)) + } + + func testTruncatedArchiveEnumerationIsUnknown() throws { + let archive = try makeArchive( + pathExtension: "ntb", + entries: ["noteBundle", "Session.plist"] + ) + defer { removeIfPresent(archive.deletingLastPathComponent()) } + + var bytes = try Data(contentsOf: archive) + let localHeader = Data([0x50, 0x4b, 0x03, 0x04]) + let first = try XCTUnwrap(bytes.range(of: localHeader)) + let second = try XCTUnwrap( + bytes.range(of: localHeader, in: first.upperBound.. URL { + try makeArchive( + pathExtension: pathExtension, + entries: ["noteBundle", "version", "manifest.json"] + ) + } + + private func makeArchive(pathExtension: String, entries: [String]) throws -> URL { + let root = temporaryURL(pathExtension: nil) + let staging = root.appendingPathComponent("payload", isDirectory: true) + let archive = root + .appendingPathComponent("modern") + .appendingPathExtension(pathExtension) + try FileManager.default.createDirectory( + at: staging, + withIntermediateDirectories: true + ) + + // Central-directory entry names are the only fixture signal. Contents + // are fixed synthetic bytes and contain no real Notability payload. + for entry in entries { + try Data("synthetic\n".utf8).write(to: staging.appendingPathComponent(entry)) + } + + let zip = Process() + zip.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + zip.arguments = [ + "-0", "-q", archive.path, + ] + entries + zip.currentDirectoryURL = staging + let stderr = Pipe() + zip.standardError = stderr + try zip.run() + zip.waitUntilExit() + + let errorText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + XCTAssertEqual(zip.terminationStatus, 0, "zip failed: \(errorText)") + return archive + } + + private func temporaryURL(pathExtension: String?) -> URL { + var url = FileManager.default.temporaryDirectory + .appendingPathComponent("macdoc-modern-note-\(UUID().uuidString)") + if let pathExtension { + url.appendPathExtension(pathExtension) + } + return url + } + + private func removeIfPresent(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } + + private func testReadUInt32(_ data: Data, at offset: Int) -> UInt32 { + UInt32(data[offset]) + | (UInt32(data[offset + 1]) << 8) + | (UInt32(data[offset + 2]) << 16) + | (UInt32(data[offset + 3]) << 24) + } + + private func testWriteUInt16(_ value: UInt16, to data: inout Data, at offset: Int) { + data[offset] = UInt8(truncatingIfNeeded: value) + data[offset + 1] = UInt8(truncatingIfNeeded: value >> 8) + } + + private func testWriteUInt32(_ value: UInt32, to data: inout Data, at offset: Int) { + data[offset] = UInt8(truncatingIfNeeded: value) + data[offset + 1] = UInt8(truncatingIfNeeded: value >> 8) + data[offset + 2] = UInt8(truncatingIfNeeded: value >> 16) + data[offset + 3] = UInt8(truncatingIfNeeded: value >> 24) + } +} diff --git a/openspec/changes/detect-notability-ntb-container/.openspec.yaml b/openspec/changes/detect-notability-ntb-container/.openspec.yaml new file mode 100644 index 0000000..cade3e6 --- /dev/null +++ b/openspec/changes/detect-notability-ntb-container/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-08-13 +created_by: che cheng +created_with: Codex diff --git a/openspec/changes/detect-notability-ntb-container/design.md b/openspec/changes/detect-notability-ntb-container/design.md new file mode 100644 index 0000000..87f3b34 --- /dev/null +++ b/openspec/changes/detect-notability-ntb-container/design.md @@ -0,0 +1,80 @@ +## Context + +macdoc's note routes are compiled against `NoteToHTML` and `NoteToPDF`, whose shared legacy parser expects a ZIP entry ending in `Session.plist`. Modern Notability exports observed from app version 16.8.1 instead contain the FlatBuffers document entry `noteBundle`. The private source fixture cannot be committed, and a full schema implementation is intentionally out of scope, but ZIP central-directory names are sufficient to distinguish the two generations without reading note content. + +The root resolver already contains ZIPFoundation 0.9.20 transitively. MacDocCLI must declare that package directly before importing its product; this changes the manifest dependency surface but does not introduce a second ZIP implementation. + +## Goals / Non-Goals + +**Goals:** + +- Identify legacy and modern Notability containers from archive entry names before calling either converter. +- Reject modern containers with one stable Traditional Chinese diagnostic for HTML and PDF, including files renamed from `.ntb` to `.note`. +- Preserve existing successful legacy `.note` conversion and existing malformed/unknown ZIP diagnostics. +- Ensure generation rejection happens before stdout, output-directory, or PDF-file creation. +- Document the exact support boundary. + +**Non-Goals:** + +- Parse FlatBuffers or inspect `noteBundle`, index, manifest, audio, image, or thumbnail payload bytes. +- Extract assets or synthesize degraded HTML/PDF. +- Change `NoteCore`, `NoteToHTML`, or `NoteToPDF` public APIs in this phase. +- Infer format generation from `manifest.json` contents or an app-version string. + +## Decisions + +### Classify from normalized ZIP entry names without extracting payloads + +`NotabilityContainerDetector` opens the input with ZIPFoundation in read-only mode and enumerates entry paths only. It normalizes path separators, discards empty and `.` components, and recognizes an entry by its final path component so both top-level modern archives and archives wrapped in one export directory are classified. It never calls archive extraction and never opens an entry payload. + +The classification is `legacy` when any regular entry ends in `Session.plist`; otherwise it is `modernFlatBuffers` when any regular entry ends in `noteBundle`; otherwise it is `unknown`. Legacy takes precedence if both markers exist because the downstream parser has a concrete supported path and must not be denied by an unrelated extra entry. + +Before constructing `Archive`, the detector reads at most the final 65,557 bytes and requires exactly one classic single-disk EOCD candidate. That canonical record must also be the first complete EOCD signature ZIPFoundation will encounter while scanning backward; a later non-canonical signature is unsafe even when its declared comment length does not reach EOF. The central directory must be at most 16 MiB, end exactly at EOCD, contain at most 4,096 structurally complete `0x02014b50` records, and use at most 4,096 UTF-8 bytes per entry path. Each record must begin on disk zero and reject ZIP64 version, size/offset sentinels, or ZIP64 extra fields. ZIP64 (including records present without classic EOCD sentinels), multiple/divergent EOCD candidates, malformed/trailing central-directory bytes, a missing EOCD, or an incomplete ZIPFoundation iteration is classified as `unknown`. Comparing the completed iteration count with the independently parsed central directory prevents a damaged local header, forged EOCD count, or shadow directory from silently hiding a later `Session.plist`. These bounds keep the new `.ntb` route from scanning an attacker-controlled file from end to start or following an attacker-controlled ZIP64 entry count. + +Alternatives rejected: + +- Suffix-only classification cannot detect a modern container renamed to `.note` and contradicts the issue evidence that entry structure is authoritative. +- Reading `manifest.json` exposes unnecessary payload and its `appVersion` is not a schema version. +- Extracting to a temporary directory repeats the downstream parser's work and expands archive traversal exposure. + +### Gate both HTML and PDF note routes before converter construction + +The `.note` and `.ntb` HTML/PDF switch cases call one preflight function before invoking either converter. `modernFlatBuffers` throws a fixed `ValidationError` stating that a modern `.ntb` FlatBuffers container was detected and only legacy plist-based `.note` with `Session.plist` is supported. `legacy` continues to the converter. An unknown `.note` also continues so the established invalid-ZIP or missing-session error remains authoritative for the pre-existing route. An unknown `.ntb` is rejected locally with `無法安全辨識 Notability .ntb 容器;目前僅支援舊版 plist-based .note(Session.plist)`; it is not passed to the legacy parser, preventing the newly recognized suffix from re-entering an unbounded parser or disclosing the input basename. + +Alternatives rejected: + +- Catching only `NoteError.missingSessionPlist` after conversion can occur after extraction and cannot serve `.ntb`, which is currently rejected by the extension switch. +- Implementing the message only in `NoteCore` would require an upstream release and would not make macdoc route `.ntb` inputs in the current delivery. + +### Prove behavior with synthetic metadata-only archives + +Tests create tiny ZIP archives containing fixed non-sensitive bytes for `noteBundle`, `version`, and `manifest.json`. They invoke the compiled CLI for `.ntb → html`, `.ntb → pdf`, and a modern archive renamed `.note → html`, assert exact non-zero diagnostics and empty stdout, and assert the requested output path remains absent. Existing synthetic `Session.plist` route tests continue to prove no legacy regression. + +No real Notability fixture, recording, thumbnail, handwritten data, title, identifier, or manifest value is committed. + +### Qualify documentation rather than overstate support + +README and CONVERSIONS SHALL describe legacy `.note` HTML/PDF as implemented and modern `.ntb` as detected-but-not-supported. They SHALL state that FlatBuffers handwriting/timeline replay is not implemented and SHALL not present asset extraction as available. + +## Implementation Contract + +**Behavior:** `macdoc convert --to html modern.ntb --output out` and the equivalent PDF command SHALL fail before creating `out`. A ZIP carrying `noteBundle` under a `.note` suffix SHALL fail identically. A ZIP carrying `Session.plist` SHALL retain the existing converter behavior. + +**Interface / data shape:** the detector is package-scoped in the lightweight `NotabilityContainerDetection` target, with a classification enum containing `legacy`, `modernFlatBuffers`, and `unknown`. MacDocCLI and MacDocCLITests depend on that target without exposing it as a public product. It accepts a file URL and returns a classification without exposing entry names or payloads to callers. The exact modern-container diagnostic is: + +`偵測到新版 Notability .ntb 容器(noteBundle/FlatBuffers);目前僅支援舊版 plist-based .note(Session.plist)` + +**Failure modes:** inability to open or safely classify an archive returns `unknown`. An unknown `.note` continues to the existing converter for its established invalid/missing-session diagnostic; an unknown `.ntb` fails locally with the fixed safe-classification diagnostic. The detector SHALL not log input paths, entry lists, manifest data, note contents, or archive bytes. A modern or unknown `.ntb` classification always fails locally without network activity. + +**Acceptance criteria:** RED tests first fail because `.ntb` is reported as an unsupported route and renamed `.note` receives the legacy missing-session diagnostic. After implementation, compiled CLI tests assert exact stderr, empty stdout, non-zero exit, and absent HTML/PDF destination; existing Note HTML/PDF smoke tests and the full root suite pass. Spectra validation and diff privacy scanning pass. + +**Scope boundaries:** phase 0 ends at classification, precise rejection, and documentation. Full FlatBuffers parsing and asset export remain explicit future work. + +## Risks / Trade-offs + +- [Risk] A future container uses a different root marker. → Unknown archives fall through to established diagnostics; do not guess from app version. +- [Risk] A malicious archive includes both markers. → Legacy precedence preserves the only supported parser path; ZIPFoundation payload extraction remains downstream and unchanged. +- [Risk] Entry enumeration itself processes an attacker-controlled central directory. → Use ZIPFoundation's read-only archive API and never extract or read entry payloads in the detector. +- [Risk] ZIPFoundation 0.9.20 scans backward without an EOCD bound, can select a non-canonical EOCD-looking ZIP comment, and silently ends iteration on a damaged local header. → Require our canonical EOCD to match ZIPFoundation's first complete signature, independently parse the bounded central directory to exact size/count/disk/ZIP64 constraints, and compare ZIPFoundation's completed iteration before trusting any marker. +- [Risk] Direct dependency declaration can drift from the transitive resolver. → Use the existing 0.9.20-compatible package requirement and verify Package.resolved remains on the audited revision already present in the root lockfile. +- [Risk] Making MacDocCLITests depend on the executable target loads its large OOXML graph into XCTest and can destabilize unrelated tests. → Keep detector code in a package-scoped lightweight target shared by the CLI and tests. diff --git a/openspec/changes/detect-notability-ntb-container/proposal.md b/openspec/changes/detect-notability-ntb-container/proposal.md new file mode 100644 index 0000000..349711f --- /dev/null +++ b/openspec/changes/detect-notability-ntb-container/proposal.md @@ -0,0 +1,38 @@ +## Why + +Notability exports from at least app version 16.8.1 can use a ZIP-based `.ntb` generation whose document body is the FlatBuffers `noteBundle` entry rather than the legacy plist-based `Session.plist`. macdoc currently forwards every `.note` input to the legacy parser and rejects `.ntb` as an unsupported extension, so modern Notability containers receive a misleading diagnostic or no generation-specific guidance. + +## What Changes + +- Add bounded, metadata-only ZIP entry classification for legacy `Session.plist` and modern `noteBundle` containers without extracting or reading document payloads. +- Accept `.ntb` as a recognized note input only to emit a precise fail-loud unsupported-generation diagnostic for HTML and PDF targets. +- Detect a modern container even when it has been renamed with a `.note` suffix, while preserving the existing legacy `.note` conversion path. +- Reject malformed, ZIP64, or over-complex `.ntb` inputs locally so classification cannot trigger unbounded central-directory work or leak an input path through the legacy parser. +- Add compiled CLI acceptance for exact diagnostics and no output creation. +- Qualify README and conversion-matrix claims so interactive conversion is explicitly limited to legacy plist-based `.note`; modern `.ntb` is detected but not converted. + +## Non-Goals + +- Reverse-engineering or parsing the `noteBundle`, handwriting, or recording FlatBuffers schemas. +- Extracting `assets/` audio or `thumbnail.png` as a degraded conversion path. +- Claiming interactive replay, HTML, or PDF support for modern `.ntb` files. +- Inspecting `manifest.json` contents or any private note payload. + +## Capabilities + +### New Capabilities + +- `notability-container-detection`: Metadata-only classification and generation-specific CLI rejection for modern Notability `.ntb` containers. + +### Modified Capabilities + +- `e2e-conversion-routes`: Add compiled route coverage for modern `.ntb` and renamed modern `.note` rejection without partial output. + +## Impact + +- Affected specs: `notability-container-detection`, `e2e-conversion-routes` +- Affected code: + - New: `Sources/NotabilityContainerDetection/NotabilityContainerDetector.swift`, `Tests/MacDocCLITests/NotabilityContainerDetectionTests.swift` + - Modified: `Package.swift`, `Sources/MacDocCLI/MacDoc+Convert.swift`, `README.md`, `CONVERSIONS.md` + - Removed: none +- Dependency surface: declare the already-resolved `ZIPFoundation` package and isolate it behind a lightweight `NotabilityContainerDetection` target used by the CLI and tests. This avoids linking the full executable target into XCTest merely to test the detector. diff --git a/openspec/changes/detect-notability-ntb-container/specs/e2e-conversion-routes/spec.md b/openspec/changes/detect-notability-ntb-container/specs/e2e-conversion-routes/spec.md new file mode 100644 index 0000000..8df7220 --- /dev/null +++ b/openspec/changes/detect-notability-ntb-container/specs/e2e-conversion-routes/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Modern Notability container rejection coverage + +The E2E suite SHALL invoke the compiled macdoc executable with synthetic metadata-only modern Notability ZIP archives. It SHALL verify the exact unsupported-generation diagnostic, non-zero exit, empty stdout, and absent destination for HTML and PDF without committing or reading a private Notability fixture. + +#### Scenario: Compiled .ntb HTML rejection + +- **WHEN** the compiled command receives a synthetic `.ntb` containing `noteBundle` for HTML output +- **THEN** the command returns the exact modern-container diagnostic and creates no output directory + +#### Scenario: Compiled .ntb PDF rejection + +- **WHEN** the compiled command receives the same synthetic `.ntb` for PDF output +- **THEN** the command returns the exact modern-container diagnostic and creates no PDF file + +#### Scenario: Compiled renamed .note rejection + +- **WHEN** the synthetic modern archive is renamed with a `.note` suffix and requested as HTML +- **THEN** the command returns the modern-container diagnostic rather than the legacy missing-session diagnostic + +#### Scenario: Compiled unsafe unknown .ntb rejection + +- **WHEN** the compiled command receives a malformed or metadata-over-limit `.ntb` +- **THEN** the command returns the fixed safe-classification diagnostic without an input path and creates no destination diff --git a/openspec/changes/detect-notability-ntb-container/specs/notability-container-detection/spec.md b/openspec/changes/detect-notability-ntb-container/specs/notability-container-detection/spec.md new file mode 100644 index 0000000..98f27ee --- /dev/null +++ b/openspec/changes/detect-notability-ntb-container/specs/notability-container-detection/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Classify Notability container generation from ZIP entry metadata + +MacDocCLI SHALL classify a safely enumerable classic single-disk Notability archive as legacy when a regular ZIP entry's final path component is `Session.plist`. If no legacy marker exists, it SHALL classify the archive as modern FlatBuffers when a regular entry's final path component is `noteBundle`. It SHALL otherwise classify the archive as unknown. Classification SHALL inspect at most 4,096 entries and 4,096 UTF-8 bytes per entry path, SHALL require exactly one canonical EOCD candidate from at most the final 65,557 bytes and require it to match ZIPFoundation's backward-scan choice, SHALL independently validate at most 16 MiB of central-directory records to their exact declared count and size, SHALL require each entry to begin on disk zero, SHALL reject ZIP64 version/sentinels/extra fields or incomplete enumeration as unknown, and SHALL NOT extract or read entry payloads, `manifest.json`, note content, media, or indexes. + +#### Scenario: Classify a legacy archive + +- **WHEN** a readable ZIP contains `export/Session.plist` +- **THEN** the detector classifies it as legacy even if another entry is named `noteBundle` + +#### Scenario: Classify a modern archive + +- **WHEN** a readable ZIP contains `noteBundle` and contains no `Session.plist` +- **THEN** the detector classifies it as modern FlatBuffers without reading the `noteBundle` payload + +#### Scenario: Preserve unknown archive handling + +- **WHEN** the file is not a readable ZIP, exceeds the metadata safety bounds, cannot be enumerated completely, or contains neither generation marker +- **THEN** the detector classifies it as unknown + +#### Scenario: Reject an unsafe unknown .ntb locally + +- **WHEN** an `.ntb` file is unknown because it is malformed or exceeds metadata safety bounds +- **THEN** MacDocCLI fails before the legacy parser with `無法安全辨識 Notability .ntb 容器;目前僅支援舊版 plist-based .note(Session.plist)`, emits no stdout or path, and creates no destination + +#### Scenario: Preserve unknown legacy .note diagnostics + +- **WHEN** a `.note` file is unknown +- **THEN** the established downstream parser remains responsible for the final malformed or missing-session diagnostic + +### Requirement: Reject modern FlatBuffers note conversion precisely and before output + +The HTML and PDF note routes SHALL recognize both `.note` and `.ntb` input suffixes. Before constructing a converter or creating output, they SHALL reject a modern FlatBuffers classification with the exact diagnostic `偵測到新版 Notability .ntb 容器(noteBundle/FlatBuffers);目前僅支援舊版 plist-based .note(Session.plist)`. The rejection SHALL be local, SHALL emit no stdout, SHALL create no destination file or directory, and SHALL NOT log archive entry lists, paths, payloads, or manifest content. + +#### Scenario: Reject a modern .ntb HTML conversion + +- **WHEN** `macdoc convert --to html modern.ntb --output out` receives a ZIP containing `noteBundle` +- **THEN** the command exits non-zero with the exact modern-container diagnostic, empty stdout, and no `out` destination + +#### Scenario: Reject a modern .ntb PDF conversion + +- **WHEN** `macdoc convert --to pdf modern.ntb --output out.pdf` receives a ZIP containing `noteBundle` +- **THEN** the command exits non-zero with the exact modern-container diagnostic, empty stdout, and no `out.pdf` + +#### Scenario: Reject a renamed modern container + +- **WHEN** the same modern ZIP is named `modern.note` and requested as HTML +- **THEN** the command produces the same modern-container rejection instead of the legacy missing-session diagnostic + +#### Scenario: Preserve legacy note conversion + +- **WHEN** a `.note` ZIP contains a parseable `Session.plist` +- **THEN** the detector permits the existing HTML or PDF converter to process the archive unchanged + +### Requirement: State the Notability generation support boundary + +User-facing note conversion documentation SHALL identify plist-based `.note` with `Session.plist` as the implemented interactive HTML/PDF input and modern `.ntb` with FlatBuffers `noteBundle` as detected-but-not-supported. The documentation SHALL NOT claim FlatBuffers handwriting, timeline, recording, thumbnail, HTML, or PDF support. + +#### Scenario: Read note conversion documentation + +- **WHEN** a user reads README or the conversion matrix +- **THEN** they can distinguish the supported legacy `.note` generation from the precisely detected but unsupported modern `.ntb` generation diff --git a/openspec/changes/detect-notability-ntb-container/tasks.md b/openspec/changes/detect-notability-ntb-container/tasks.md new file mode 100644 index 0000000..84f6f8e --- /dev/null +++ b/openspec/changes/detect-notability-ntb-container/tasks.md @@ -0,0 +1,37 @@ +## 1. RED Acceptance + +- [x] 1.1 Implement the **Prove behavior with synthetic metadata-only archives** test contract in `Tests/MacDocCLITests/NotabilityContainerDetectionTests.swift`: generate payload-free ZIP fixtures for `.ntb` and renamed `.note`, then verify the **Modern Notability container rejection coverage** scenarios are RED because the compiled CLI lacks the exact modern-generation diagnostic and no-output guarantee. + +## 2. Container Detection and Routing + +- [x] 2.1 Implement **Classify from normalized ZIP entry names without extracting payloads** and **Classify Notability container generation from ZIP entry metadata** in `Sources/NotabilityContainerDetection/NotabilityContainerDetector.swift`, including legacy precedence, modern detection, unknown fallback, and a direct ZIPFoundation dependency in `Package.swift`; verify focused unit assertions classify synthetic entry sets and `swift package describe --type json` succeeds without changing the resolved ZIPFoundation revision. +- [x] 2.2 Implement **Gate both HTML and PDF note routes before converter construction** and **Reject modern FlatBuffers note conversion precisely and before output** in `Sources/MacDocCLI/MacDoc+Convert.swift`; verify `.note` and `.ntb` HTML/PDF compiled cases emit the exact diagnostic, empty stdout, non-zero exit, and leave destinations absent while existing legacy note smoke tests remain green. +- [x] 2.3 Bound and independently validate EOCD discovery, central-directory size/records, entry count, entry path length, and enumeration completeness; reject unsafe unknown `.ntb` containers locally without leaking their path while preserving legacy unknown `.note` diagnostics. +- [x] 2.4 Isolate detector tests in a lightweight package target rather than linking the full `MacDocCLI` executable target into XCTest. + +## 3. Support Boundary Documentation + +- [x] [P] 3.1 Implement **Qualify documentation rather than overstate support** and **State the Notability generation support boundary** in `README.md` and `CONVERSIONS.md`; verify a documentation regression finds legacy plist-based `.note`, detected-but-not-supported modern `.ntb`, and an explicit statement that FlatBuffers replay is not implemented. + +## 4. Verification + +- [x] 4.1 Run the complete phase-0 acceptance: focused Notability tests, existing Note HTML/PDF smoke tests, root `swift test`, `spectra validate detect-notability-ntb-container`, dependency and privacy checks, and `git diff --check`; record exact results and confirm no real Notability fixture or payload content is added. + +### Verification evidence (2026-08-13) + +- RED: `swift test --filter NotabilityContainerDetectionTests` — 3 tests, 3 expected diagnostic failures before implementation. +- Focused GREEN: `swift test --filter NotabilityContainerDetectionTests` — 8 tests, 0 failures. +- Legacy smoke: `swift test --filter 'NoteHTMLConvertTests|NotePDFConvertTests'` — 3 tests, 0 failures, 1 existing single-page density skip. +- Full root: `swift test` — 53 tests, 0 failures, 3 environment/fixture skips. +- Spectra: `spectra validate detect-notability-ntb-container` — valid; analyze reports no Critical/Warning findings (one non-blocking concrete-example suggestion). +- Dependency: `swift package describe --type json` succeeded; `Package.resolved` remained byte-unchanged at SHA-256 `030f6531a76e1284444677ace153951a7f5e861b363d155fe3c7a339a3735f03`, including ZIPFoundation 0.9.20 revision `22787ffb59de99e5dc1fbfe80b19c97a904ad48d`. +- Privacy and packaging: no `.ntb`, `.note`, `noteBundle`, media, or private payload fixture was added; tests create only fixed synthetic metadata bytes in the temporary directory. +- Hygiene: changed source/test/docs privacy scan and `git diff --check` passed. + +### Verification addendum (2026-08-24) + +- Security RED: over-limit entry/path cases classified as modern and malformed `.ntb` fell through to the legacy parser, which disclosed its basename. +- Security GREEN: 17 focused `NotabilityContainerDetectionTests`, including a 256 MiB sparse missing-EOCD file, a damaged second local header, aligned and non-canonical EOCD-in-comment redirects, cross-disk central entries, ZIP64 version without classic sentinels, metadata bounds, and local unknown `.ntb` rejection — 0 failures. +- Regression RED: making `MacDocCLITests` depend directly on `MacDocCLI` caused an unrelated `WordRenderTests` SIGSEGV while initializing `WordDocument`. +- Regression GREEN: `NotabilityContainerDetection` is now a lightweight shared target; #148 tests retain direct unit coverage without loading the executable target into XCTest. +- Full root GREEN after a clean build with verified prerequisite #164 temporarily overlaid: 48 XCTest (0 failures, 4 fixture/environment skips) plus 26 Swift Testing tests (0 failures). The overlay was aborted after verification and is not part of #148.