prism branch T-1822/bugfix…root-urls-fails commits 3 files 3 touched lines +233 / -4 tests 17/17 pass (targeted) lint 0 violations

Pre-push review: T-1822 Share-with-Notes filename sanitisation

Bugfix for PR #362: sharing notes on a remote document opened from a host-only or trailing-slash root URL (e.g. https://example.com) always failed with “Export Failed”. The display-name fallback for such URLs is the full absolute URL string, and its / and : characters were interpolated straight into appendingPathComponent, so the temp-file write targeted nonexistent nested directories every time. The fix sanitises the display name into a single safe path component.

At a glance

  • Root cause: DocumentSource.urlDisplayTitle falls back to the full absolute URL for host-only/slash-terminated URLs (T-1177); InlineNotesShareHelper.share reused that string, unsanitised, as a filename — / became a path separator and the write always failed.
  • Fix: new sanitizedBaseName(from:) strips the .md/.markdown suffix case-insensitively, collapses runs of / \ : and control characters into a single -, drops the leading run of dots and dashes, byte-caps at 180 UTF-8 bytes on grapheme boundaries, and falls back to "document".
  • temporaryExportURL(for:) guarantees the export lands directly under temporaryDirectory with a -{8-char uuid}.md suffix; 180 + 12 bytes stays under APFS's 255-byte NAME_MAX.
  • Review round in this session fixed two minors (leading-dot alternation gap, maximal-truncation assertion) and two free simplifications; committed as 4164ae7.
  • One deferred sibling: the macOS NSSavePanel export path also seeds its default filename from the same unsanitised URL fallback — milder (AppKit maps separators), but worth a follow-up ticket.
  • Validated locally: InlineNotesShareHelperTests 17/17 green on macOS, SwiftLint 0 violations in 529 files. CI red checks are zero-job billing failures, not code failures.

Verdict

Ready to push

The fix is correct for the reported bug, well-scoped (every Share-with-Notes surface routes through the single fixed helper), and covered by behavioural tests that write real files to the temp directory. Three review agents found nothing critical or major; the two minor findings (a leading-dot invariant hole under dot/dash alternation, and an under-asserting grapheme test) were fixed in this review and committed as 4164ae7, with the targeted test class and SwiftLint re-run green afterwards. GitHub Actions is billing-blocked, so validation was local by design.

Review findings

9 raised · 4 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism lets you share a markdown document together with your notes. To do that, it writes a temporary file and hands it to the system share sheet. The temporary file's name was built from the document's display name — usually something like readme.md. But when you open a document straight from a website's root address, like https://example.com, there is no filename in the address, so Prism uses the whole address as the display name instead (that was an earlier fix, so the title bar is never blank).

The trouble: a web address contains / and : characters, and on a computer / means “go into a folder”. So when Prism tried to create a file literally named https://example.com-a1b2c3d4.md, the system read it as a path through folders named https:, then an empty folder, then example.com… — folders that don't exist. The write failed every single time, and you saw “Export Failed”.

Why it matters

Anyone who opened a document from a root URL and tried to share it with notes hit a guaranteed, unexplained failure. Now the address is cleaned up first — https://example.com becomes the filename https-example.com-a1b2c3d4.md — and sharing works.

Key concepts

  • Path separator: the / character splits a file path into folders. A filename itself must never contain one — like a street address where a comma separates the fields: put a comma inside a field and everything after it lands in the wrong slot.
  • Sanitisation: replacing or removing characters that have special meaning before using text somewhere it wasn't designed for.
  • Grapheme cluster: what a person sees as one character (like the flag emoji 🇦🇺) can be several bytes under the hood; cutting a name to a byte limit must not slice one of these in half or the text becomes garbage.

Changes overview

One production file and one new test file. prism/Services/InlineNotesShareHelper.swift gains three members: sanitizedBaseName(from:), temporaryExportURL(for:), and the constant maxBaseNameBytes = 180, plus a private grapheme-safe truncated(_:toUTF8ByteLimit:). The previously inline filename construction in share(...) collapses to one call: Self.temporaryExportURL(for: displayName). prismTests/InlineNotesShareHelperTests.swift adds 17 Swift Testing cases exercising the pure helpers directly — no UI presentation involved — including end-to-end writes to the real temp directory for the original failure shape.

Implementation approach

The pipeline is: strip .md/.markdown case-insensitively (an explicit allowlist — deletingPathExtension would eat .com) → walk unicode scalars, collapsing each run of /, \, :, and CharacterSet.controlCharacters into a single - → drop the leading run of . and - (hidden-file guard) → truncate to 180 UTF-8 bytes by popping whole Characters → trim trailing dashes → fall back to "document" if nothing survives. Uniqueness comes from an 8-char UUID suffix; 180 + -XXXXXXXX.md (12 ASCII bytes) = 192, comfortably under APFS's 255-byte NAME_MAX.

Trade-offs

  • Sanitise at the consumer, not the source. Special-casing root URLs inside DocumentSource.urlDisplayTitle was the obvious alternative, but that string feeds window titles and the notes store — changing it would ripple. Sanitising where the string crosses into filesystem-component context fixes every display name containing separators, not just root URLs.
  • A new human-readable sanitiser over reusing DocumentIdentifier.urlSafeEncoded. The existing utility percent-encodes for reversible internal storage names; a share-sheet filename like https%3A%2F%2Fexample.com… would be user-hostile, and it has no byte cap.
  • Stripping : although APFS allows it: it is the historical HFS separator, Finder renders it as /, and share destinations include non-APFS filesystems — conservative wins.

Technical deep dive

The scalar walk operates on unicodeScalars with per-run collapse state (lastWasReplaced), so :// → one -, giving the canonical https-example.com. Foundation's controlCharacters covers Cc and Cf, so ZWJ, soft hyphen, and — usefully — U+202E RLO get collapsed too, which incidentally hardens against extension-spoofing display tricks. The review round closed one genuine ordering hole: trim-dashes-then-drop-dots let an alternation like ".:.foo"".-.foo" resurface a leading dot after the final trim, violating the documented hidden-file guarantee. The fix drops the whole leading run of {., -} in a single pass before truncation; since truncation only removes from the tail, it can only expose a trailing -, trimmed last. Truncation itself is now a removeLast() loop — grapheme-safe by construction because removeLast removes a Character, and cheap because native strings answer utf8.count in O(1).

Architecture impact

Minimal and well-contained. Every Share-with-Notes surface (regular toolbar, NotesPanel, SidebarNotesView via ExportNotesFlow) already funnels through InlineNotesShareHelper.share, so one seam covers the whole feature; no other call site builds a filename from displayName (fileExporter uses a fixed "Untitled.md"). The helpers are non-@MainActor pure statics, which is what makes them directly testable. The one placement question: the macOS NSSavePanel export (DocumentReaderView.swift:532) seeds nameFieldStringValue from the same unsanitised URL fallback. Not the identical bug — AppKit maps separators per Finder convention — but an over-long URL-derived default can still exceed NAME_MAX and surface as exportError. Reusing the sanitiser there implies moving it somewhere shared; deferred as a follow-up rather than widening this bugfix.

Edge cases and residuals

  • Accepted residual: a trailing control character defeats the suffix strip ("notes.md\n" → base notes.md → cosmetic double extension). Order-of-operations consequence, not worth restructuring.
  • Accepted residual: lowered.hasSuffix + removeLast(suffix.count) assumes case-mapping preserves grapheme counts — unreachable for real display names ending in ASCII .md/.markdown.
  • Trailing dots are legal on APFS and always followed by the -uuid.md suffix, so no trailing-dot handling is needed.
  • Uniqueness is probabilistic (8 hex-ish UUID chars) with no fileExists pre-check — correctly avoiding TOCTOU; collision odds are negligible for a temp share file and a failed write is already handled.

Important changes — detailed

InlineNotesShareHelper: sanitizedBaseName(from:) — the core fix

prism/Services/InlineNotesShareHelper.swift

Why it matters. This is the bug's kill site: the raw URL-fallback display name is reduced to a single safe path component, so the temp write can no longer target phantom nested directories. Correctness of the whole PR rests on this pipeline's ordering.

What to look at. prism/Services/InlineNotesShareHelper.swift:126-158 (sanitizedBaseName)

Takeaway. Sanitise at the boundary where a string changes meaning — here, display text becoming a filesystem path component — rather than restricting what the source may produce. The doc comment carries the full causal chain (T-1177 fallback → T-1822 failure), so the next reader doesn't re-derive it.
Rationale. Sanitising centrally in the share helper, rather than special-casing root URLs in DocumentSource as originally suggested, keeps DocumentSource's display semantics untouched for its other callers and covers any display name containing separators, not just root URLs.

share() rewired through temporaryExportURL(for:)

prism/Services/InlineNotesShareHelper.swift

Why it matters. The previously inline filename construction becomes one tested call, and the guarantee — export lands directly under temporaryDirectory as a single path component — is stated and test-pinned. Every share surface funnels through this seam, so the fix covers the whole feature.

What to look at. prism/Services/InlineNotesShareHelper.swift:64-65, 177-184 (temporaryExportURL)

Takeaway. Extracting the failure-prone expression into a named, pure, non-MainActor static made the bug directly testable without any UI harness — the tests write real files to the real temp directory.
Rationale. Uniqueness via an 8-char UUID suffix with no fileExists pre-check avoids TOCTOU; the existing do/catch already owns write-failure handling. (inferred — not stated by the author)

Byte cap (180) with grapheme-safe truncation

prism/Services/InlineNotesShareHelper.swift

Why it matters. The same URL fallback that caused the separator bug can also exceed APFS's 255-byte NAME_MAX once the -{uuid}.md suffix is appended — a second, quieter way for the write to fail. The cap closes it; truncating on Character boundaries keeps multi-byte names valid.

What to look at. prism/Services/InlineNotesShareHelper.swift:160-175 (maxBaseNameBytes, truncated)

Takeaway. Byte-cap filenames in UTF-8 bytes (what the filesystem counts), but cut on grapheme boundaries (what users see). A removeLast() loop is grapheme-safe by construction and O(1) per step on native strings — no index arithmetic needed.
Rationale. 180 leaves ample headroom for the 12-byte -{8-char uuid}.md suffix under the 255-byte NAME_MAX; stated in the b55ed43 commit and the constant's doc comment.

Review fix: leading-run drop closes the dot/dash alternation gap

prism/Services/InlineNotesShareHelper.swift

Why it matters. The prior order (trim dashes, then drop dots, then truncate, then trim again) let ".:.foo" sanitise to the hidden-file name ".foo", contradicting the documented guarantee. Fixed in this review: the whole leading run of dots and dashes is dropped in one pass, and truncation can only expose a trailing dash, trimmed last.

What to look at. prism/Services/InlineNotesShareHelper.swift:145-153 and commit 4164ae7

Takeaway. When several trims each remove a different character class, sequencing them creates alternation gaps — each pass can expose what the previous pass was guarding against. Drop the union of unwanted leading characters as one run.
Rationale. Raised by the quality review agent (traced ".:.foo" → ".-.foo" → "-.foo" → ".foo"); the regression is now pinned by a test asserting ".:.foo" sanitises to "foo".

InlineNotesShareHelperTests: 17 behavioural cases

prismTests/InlineNotesShareHelperTests.swift

Why it matters. The original failure shape is pinned end-to-end: a root-URL display name produces a URL that is a direct child of the temp directory, and an actual write to it succeeds. Edge coverage includes encoded separators left intact, control-char stripping, hidden-file guards, byte-cap boundaries, and grapheme-safe maximal truncation.

What to look at. prismTests/InlineNotesShareHelperTests.swift:1-150

Takeaway. Testing pure non-MainActor helpers plus one real filesystem write gives regression coverage of a share-sheet bug with zero UI automation — the presentation half of share() stays out of scope, which is exactly why the sanitisation logic was extracted.
Rationale. Tests exercise behaviour (outputs and real writes), not implementation — the truncation-maximality assertion (22 whole flags at the 180-byte cap) was added in this review so a lazy truncate-to-one-character implementation could not pass. (inferred — not stated by the author)

Key decisions

Sanitise centrally in the share helper, not in DocumentSource.

The originally suggested fix was to special-case root URLs in DocumentSource. Rejected in the commit body: urlDisplayTitle feeds window titles and the notes store, so changing it would ripple to callers with display semantics; sanitising where the string becomes a path component covers any separator-bearing display name, not just root URLs.

Explicit .md/.markdown suffix allowlist instead of deletingPathExtension.

deletingPathExtension strips any extension, which would turn example.com into example. The two-entry case-insensitive allowlist only removes markdown extensions, preserving host names in URL-derived display names.

(inferred — not stated by the author.)
New human-readable sanitiser rather than reusing DocumentIdentifier.urlSafeEncoded.

The existing utility (NoteModels.swift:40, T-459) percent-encodes for reversible, collision-free internal storage names. A share-sheet filename like https%3A%2F%2Fexample.com-1a2b3c4d.md would be user-hostile, and the utility has no byte cap. Different requirements justify a second, user-facing sanitiser.

(inferred — not stated by the author.)
Strip ":" even though APFS permits it.

: is the historical HFS path separator; Cocoa and Finder render it as /, and share-sheet destinations (Files providers, AirDrop targets) include non-APFS filesystems. Stripping is the conservative, portable choice.

(inferred — not stated by the author.)
180-byte cap with grapheme-boundary truncation.

APFS caps a path component at 255 bytes (NAME_MAX); 180 leaves headroom for the 12-byte -{8-char uuid}.md suffix (192 total). Truncation pops whole Characters so a multi-byte cluster is never split. Stated in commit b55ed43.

Helper stays on InlineNotesShareHelper for now.

The macOS NSSavePanel export (DocumentReaderView.swift:532) seeds its default filename from the same unsanitised URL fallback and could reuse sanitizedBaseName — which would argue for a shared home (a small FilenameSanitizer in Services/). Keeping it here keeps this bugfix narrow; the reuse question is deferred to a follow-up ticket. Note that moving it later means moving the tests too.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorInlineNotesShareHelper.swift sanitisation orderingThe leading-dot guarantee was defeated by dot/dash alternation: ".:.foo" sanitised to the hidden-file base name ".foo" because trim-dashes and drop-dots ran as separate sequential passes, and the final trailing trim could expose a new leading dot.Drop the whole leading run of '.' and '-' in a single pass before truncation; truncation only removes from the tail so it can only expose a trailing '-', trimmed last. Regression test added (".:.foo" -> "foo"). Commit 4164ae7.
minorInlineNotesShareHelperTests.swift grapheme testtruncationPreservesGraphemes asserted safety (byte cap respected, only whole flags, prefix of input) but not maximality — an implementation truncating to a single flag would have passed.Added #expect(base.count == 22): every whole 8-byte flag that fits under the 180-byte cap must be kept. Commit 4164ae7.
minorDocumentReaderView.swift:532 (macOS File > Export)NSSavePanel's default filename is seeded from the same unsanitised URL-fallback displayTitle. Not the identical bug — AppKit maps separators per Finder convention — but the default name is the raw URL, and an over-long URL-derived title can exceed NAME_MAX and fail the save as exportError.Deferred: a different, milder bug than T-1822, and reusing the sanitiser there implies moving it to a shared home (and moving its tests). Worth a follow-up ticket rather than widening this bugfix.
nitInlineNotesShareHelper.swift scalar loopCharacterSet.controlCharacters was re-fetched on every scalar iteration.Hoisted to a local before the loop. Commit 4164ae7.
nitInlineNotesShareHelper.swift truncated()The index-walking implementation allocated a String per Character to count bytes and was longer than the problem warranted.Replaced with a grapheme-safe removeLast() loop (utf8.count is O(1) on native strings) — simpler and allocation-free. Commit 4164ae7.
nitInlineNotesShareHelper.swift suffix striplowercased() copies the full display name to test two short suffixes, and lowered.hasSuffix + removeLast(count) assumes case mapping preserves grapheme counts (unreachable for ASCII .md/.markdown endings).Skipped: once-per-share path, current form is the most readable, and the grapheme-count assumption cannot bite for the ASCII suffixes being stripped.
nitInlineNotesShareHelperTests.swift shortNameUnchangedByCapDuplicates the first assertion of extensionSuffixStripped rather than testing the cap boundary it names.Skipped: harmless duplication; replacing it with a 179-byte boundary case is optional polish, and test churn was kept minimal per the review constraints.
nitInlineNotesShareHelper.swift suffix-strip orderingA trailing control character defeats the suffix strip: "notes.md\n" doesn't match .md (strip runs before sanitisation), exporting as notes.md-XXXXXXXX.md — a cosmetic double extension.Skipped: cosmetic only; restructuring the pipeline for it isn't warranted.
nitspecs/bugfixes/sharing-notes-from-remote-root-urls-fails/The bugfix spec folder exists in the worktree but is empty — no report.md, unlike sibling bugfix folders. (Empty directories are untracked, so nothing ships in the PR either way.)Skipped: the CHANGELOG entry and the 71efee6 commit body carry the full root-cause narrative; whether a formal fix-bug report is wanted is the author's call.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9938073..f6e1186 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Share with Notes on a remote document opened from a host-only or trailing-slash root URL (e.g. `https://example.com`) no longer always fails (T-1822). The display name used for that document falls back to its full absolute URL so a root URL never shows a blank title (T-1177) — but Share with Notes reused that same string, unsanitised, as the temporary export file's name. The URL's `/` and `:` characters were read as path separators, so the write always targeted a nonexistent nested directory and "Export Failed" appeared every time. The export filename is now derived from a sanitised version of the display name, with path separators and control characters stripped rather than passed through.+ - Arrow keys, Page Up/Down, Space, and the View menu's **Page Down**/**Page Up**/**Scroll to Top**/**Scroll to Bottom** no longer scroll the document behind an open note, footnote, add-note, reply, or document-note modal (T-1099, reopened). This was fixed once before; the WebKit rendering cutover restructured the views that carried the fix and the binding was never rebuilt, so scroll commands kept reaching the document underneath a modal that should have blocked them. The gate is restored on both the iPhone and iPad/Mac layouts, taking effect immediately when a modal is already open and staying live across every presentation and dismissal. - Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before.
prism/Services/InlineNotesShareHelper.swift Modified +85 / -4
diff --git a/prism/Services/InlineNotesShareHelper.swift b/prism/Services/InlineNotesShareHelper.swiftindex 2f82203..559d49c 100644--- a/prism/Services/InlineNotesShareHelper.swift+++ b/prism/Services/InlineNotesShareHelper.swift@@ -62,10 +62,7 @@ enum InlineNotesShareHelper {             showHTMLComments: settings.showHTMLComments         )         let displayName = notesManager.documentNotes?.displayName ?? "document.md"-        let baseName = displayName.hasSuffix(".md")-            ? String(displayName.dropLast(3)) : displayName-        let filename = "\(baseName)-\(UUID().uuidString.prefix(8)).md"-        let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)+        let url = Self.temporaryExportURL(for: displayName)         do {             try content.write(to: url, atomically: true, encoding: .utf8)         } catch {@@ -105,6 +102,86 @@ enum InlineNotesShareHelper {         return true         #endif     }++    // MARK: - Filename Sanitisation++    /// Base filename (no extension, no unique suffix) derived from a+    /// document display name, safe to use as a single filesystem path+    /// component.+    ///+    /// `displayName` comes from `NotesManager.documentNotes?.displayName`,+    /// which for a remote document is `DocumentSource.urlDisplayTitle` —+    /// falling back to the full absolute URL string (containing `/` and `:`)+    /// for host-only or slash-terminated URLs so those never produce a blank+    /// display name (T-1177). Interpolating that fallback directly into+    /// `appendingPathComponent` let `/` act as a path separator, so the+    /// export write targeted nonexistent nested directories and always+    /// failed (T-1822). Strips path separators and control characters —+    /// collapsing runs of them into a single `-` — drops any leading run of+    /// `.` or `-` (a leading dot would make the exported temp file hidden),+    /// caps the result at+    /// `maxBaseNameBytes` UTF-8 bytes (APFS limits a path component to+    /// 255 bytes; the same absolute-URL fallback can exceed that once the+    /// `-{uuid}.md` suffix is appended), and falls back to a generic name+    /// if nothing safe remains.+    static func sanitizedBaseName(from displayName: String) -> String {+        var name = displayName+        let lowered = name.lowercased()+        for suffix in [".markdown", ".md"] where lowered.hasSuffix(suffix) {+            name.removeLast(suffix.count)+            break+        }++        var result = ""+        var lastWasReplaced = false+        let controlCharacters = CharacterSet.controlCharacters+        for scalar in name.unicodeScalars {+            let isUnsafe = scalar == "/" || scalar == "\\" || scalar == ":"+                || controlCharacters.contains(scalar)+            if isUnsafe {+                if !lastWasReplaced {+                    result.append("-")+                    lastWasReplaced = true+                }+            } else {+                result.unicodeScalars.append(scalar)+                lastWasReplaced = false+            }+        }++        // Drop the whole leading run of dots AND dashes in one pass: trimming+        // them separately lets an alternation like ".-.foo" sneak a dot back+        // to the front. Truncation only removes from the end, so it can never+        // reintroduce a leading dot — only a trailing "-", trimmed last.+        var trimmed = String(result.drop { $0 == "." || $0 == "-" })+        trimmed = truncated(trimmed, toUTF8ByteLimit: maxBaseNameBytes)+        while trimmed.hasSuffix("-") { trimmed.removeLast() }+        return trimmed.isEmpty ? "document" : trimmed+    }++    /// Maximum UTF-8 byte length for the sanitised base name. APFS caps a+    /// path component at 255 bytes (`NAME_MAX`); 180 leaves ample headroom+    /// for the `-{8-char uuid}.md` suffix appended by `temporaryExportURL`.+    static let maxBaseNameBytes = 180++    /// Truncates `name` to at most `limit` UTF-8 bytes without splitting a+    /// grapheme cluster (a `Character` is never cut mid-way).+    private static func truncated(_ name: String, toUTF8ByteLimit limit: Int) -> String {+        var result = name+        while result.utf8.count > limit {+            result.removeLast()+        }+        return result+    }++    /// A unique, single-path-component temporary file URL for exporting+    /// `displayName`'s notes, guaranteed to sit directly under+    /// `FileManager.default.temporaryDirectory` (T-1822).+    static func temporaryExportURL(for displayName: String) -> URL {+        let baseName = sanitizedBaseName(from: displayName)+        let filename = "\(baseName)-\(UUID().uuidString.prefix(8)).md"+        return FileManager.default.temporaryDirectory.appendingPathComponent(filename)+    } }  // MARK: - Export Button View
prismTests/InlineNotesShareHelperTests.swift Added +150 / -0
diff --git a/prismTests/InlineNotesShareHelperTests.swift b/prismTests/InlineNotesShareHelperTests.swiftnew file mode 100644index 0000000..631822c--- /dev/null+++ b/prismTests/InlineNotesShareHelperTests.swift@@ -0,0 +1,150 @@+//+//  InlineNotesShareHelperTests.swift+//  prismTests+//+//  T-1822 regression: `InlineNotesShareHelper.share` built its temp filename+//  directly from `NotesManager.documentNotes?.displayName`. For a remote+//  document at a host-only or slash-terminated URL, `DocumentSource+//  .urlDisplayTitle` falls back to the full absolute URL string (T-1177),+//  which contains "/" and ":". Interpolating that into+//  `appendingPathComponent` makes those characters act as path separators,+//  so the write targets nonexistent nested directories and always fails.+//+//  These tests exercise the pure, non-MainActor sanitisation helpers+//  directly (no UI presentation involved).+//++import Foundation+import Testing+@testable import prism++@Suite("InlineNotesShareHelper filename sanitisation")+struct InlineNotesShareHelperTests {++    // MARK: - sanitizedBaseName++    @Test("host-only URL display name has separators stripped")+    func hostOnlyURLIsSanitized() {+        let base = InlineNotesShareHelper.sanitizedBaseName(from: "https://example.com")+        #expect(!base.contains("/"))+        #expect(!base.contains(":"))+        #expect(base == "https-example.com")+    }++    @Test("slash-terminated URL display name has separators stripped")+    func slashTerminatedURLIsSanitized() {+        let base = InlineNotesShareHelper.sanitizedBaseName(from: "https://example.com/")+        #expect(!base.contains("/"))+        #expect(base == "https-example.com")+    }++    @Test(".md and .markdown suffixes are stripped case-insensitively")+    func extensionSuffixStripped() {+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: "notes.md") == "notes")+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: "notes.MD") == "notes")+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: "notes.Markdown") == "notes")+    }++    @Test("URL-encoded separators are left untouched")+    func encodedSeparatorsUntouched() {+        let base = InlineNotesShareHelper.sanitizedBaseName(from: "path%2Fsegment.md")+        #expect(base == "path%2Fsegment")+    }++    @Test("control characters are stripped")+    func controlCharactersStripped() {+        let base = InlineNotesShareHelper.sanitizedBaseName(from: "line\nbreak\ttab.md")+        #expect(!base.contains("\n"))+        #expect(!base.contains("\t"))+    }++    @Test("backslashes and colons are stripped")+    func backslashesAndColonsStripped() {+        let base = InlineNotesShareHelper.sanitizedBaseName(from: "C:\\Users\\name.md")+        #expect(!base.contains("\\"))+        #expect(!base.contains(":"))+    }++    @Test("a display name with nothing safe remaining falls back to a generic name")+    func fallsBackWhenNothingSafeRemains() {+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: "://") == "document")+    }++    @Test("leading dot is stripped so the temp file is not hidden")+    func leadingDotStripped() {+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: ".gitignore") == "gitignore")+        // Suffix strip runs first: ".gitignore.md" -> ".gitignore" -> "gitignore".+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: ".gitignore.md") == "gitignore")+        // Separators sanitise to a leading "-", trim, then dot-strip.+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: "/.hidden") == "hidden")+        // Alternating dots and separators must not sneak a dot back to the+        // front: ".:.foo" sanitises to ".-.foo", and dropping dots and+        // dashes as one leading run yields "foo" (not the hidden ".foo").+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: ".:.foo") == "foo")+    }++    @Test("a display name of only dots falls back to a generic name")+    func allDotsFallsBack() {+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: "...") == "document")+    }++    @Test("long display names are capped in UTF-8 bytes")+    func longNameIsByteCapped() {+        let longQuery = "https://example.com/?" + String(repeating: "a", count: 400)+        let base = InlineNotesShareHelper.sanitizedBaseName(from: longQuery)+        #expect(base.utf8.count <= InlineNotesShareHelper.maxBaseNameBytes)+        // The capped base plus the "-{8-char uuid}.md" suffix stays under+        // APFS's 255-byte NAME_MAX.+        let url = InlineNotesShareHelper.temporaryExportURL(for: longQuery)+        #expect(url.lastPathComponent.utf8.count <= 255)+    }++    @Test("byte-cap truncation never splits a grapheme cluster")+    func truncationPreservesGraphemes() {+        // 8 bytes per flag (two regional indicators, no format scalars the+        // sanitiser would strip); 60 of them is 480 bytes, well past the+        // cap, and 180 is not a multiple of 8, so a naive byte cut would+        // land mid-cluster.+        let flag = "🇦🇺"+        let name = String(repeating: flag, count: 60)+        let base = InlineNotesShareHelper.sanitizedBaseName(from: name)+        #expect(base.utf8.count <= InlineNotesShareHelper.maxBaseNameBytes)+        #expect(base.allSatisfy { $0 == Character(flag) })+        #expect(name.hasPrefix(base))+        // Truncation must be maximal, not merely safe: every whole cluster+        // that fits under the cap is kept (180 / 8 = 22.5 -> 22 flags).+        #expect(base.count == 22)+    }++    @Test("short names are not truncated")+    func shortNameUnchangedByCap() {+        #expect(InlineNotesShareHelper.sanitizedBaseName(from: "notes.md") == "notes")+    }++    @Test("writing to the temporary export URL for a very long display name succeeds")+    func writingLongNameExportSucceeds() throws {+        let longName = "https://example.com/?" + String(repeating: "x", count: 500)+        let url = InlineNotesShareHelper.temporaryExportURL(for: longName)+        defer { try? FileManager.default.removeItem(at: url) }+        try "content".write(to: url, atomically: true, encoding: .utf8)+        #expect(FileManager.default.fileExists(atPath: url.path))+    }++    // MARK: - temporaryExportURL++    @Test("temporary export URL for a root URL display name is a direct child of the temp directory")+    func temporaryExportURLHasNoNestedPathComponents() {+        let tempDir = FileManager.default.temporaryDirectory+        let url = InlineNotesShareHelper.temporaryExportURL(for: "https://example.com")+        #expect(url.deletingLastPathComponent().path == tempDir.path)+        #expect(url.pathExtension == "md")+    }++    @Test("writing to the temporary export URL for a root URL display name succeeds")+    func writingToTemporaryExportURLSucceeds() throws {+        let url = InlineNotesShareHelper.temporaryExportURL(for: "https://example.com")+        defer { try? FileManager.default.removeItem(at: url) }+        try "content".write(to: url, atomically: true, encoding: .utf8)+        #expect(FileManager.default.fileExists(atPath: url.path))+    }+}

Things to double-check

macOS share picker path is untested by automation.

The tests cover the pure helpers and the temp-file write end-to-end, but the presentation half of share() (UIActivityViewController / NSSharingServicePicker) is not exercised — unchanged by this PR, but a quick manual share on a root-URL document on either platform would close the loop.

NSSavePanel follow-up ticket.

The deferred finding at DocumentReaderView.swift:532 deserves a Transit ticket so it isn't lost: default export filename is the raw URL for root-URL documents, with an ENAMETOOLONG failure mode for very long URLs. Per project memory, query Transit for an existing ticket before filing.

CI is billing-blocked — red checks are not code failures.

GitHub Actions is blocked at the account level, so PR #362's red checks are zero-job billing failures. Validation for this review was local: InlineNotesShareHelperTests (17/17, macOS destination) and SwiftLint (0 violations in 529 files). The full suite was deliberately not run — sibling agents were loading the machine — so the standard pre-push full-suite pass has not been performed in this session.