From 00d3965266471dd26fefc60fd2514698f8457467 Mon Sep 17 00:00:00 2001 From: algiadev8 <57441686+algiadev8@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:07:26 +0900 Subject: [PATCH 1/5] Block sync for incompatible vault paths --- apps/obsidian-plugin/release-notes/next.md | 2 + .../src/app/plugin-controller.ts | 8 +- .../src/app/sync-controller.test.ts | 7 +- .../src/app/sync-controller.ts | 10 ++- apps/obsidian-plugin/src/i18n/locales/de.ts | 2 + apps/obsidian-plugin/src/i18n/locales/en.ts | 2 + apps/obsidian-plugin/src/i18n/locales/ja.ts | 2 + apps/obsidian-plugin/src/i18n/locales/ko.ts | 2 + .../obsidian-plugin/src/i18n/locales/zh-cn.ts | 2 + .../obsidian-plugin/src/i18n/locales/zh-tw.ts | 2 + apps/obsidian-plugin/src/ui/contracts.ts | 6 +- .../file-size-blocked-decorator.test.ts | 12 ++- .../file-size/file-size-blocked-decorator.ts | 20 +++-- .../__tests__/settings-tab-helpers.ts | 1 + .../src/ui/settings/controller.ts | 3 + .../src/ui/settings/sections/sync-status.ts | 26 +++++-- .../settings/settings-tab-sync-status.test.ts | 16 +++- .../sync-client/src/sync/core/content.test.ts | 10 ++- packages/sync-client/src/sync/core/content.ts | 4 +- .../src/sync/core/portable-path.test.ts | 26 +++++++ .../src/sync/core/portable-path.ts | 75 +++++++++++++++++++ .../src/sync/core/vault-path-policy.test.ts | 10 +++ .../src/sync/core/vault-path-policy.ts | 4 + .../__tests__/push-mutation-preparer.test.ts | 18 +++++ .../src/sync/engine/file-size-blocked.ts | 26 ++++++- .../src/sync/engine/push-mutation-preparer.ts | 19 +++++ .../src/sync/engine/push-mutation-types.ts | 2 +- .../src/sync/engine/push-service.ts | 11 +-- .../src/sync/engine/vault-config-reapply.ts | 2 + .../src/sync/runtime/sync-engine.test.ts | 8 +- .../src/sync/runtime/sync-engine.ts | 14 +++- packages/sync-client/src/sync/store/store.ts | 2 +- 32 files changed, 310 insertions(+), 44 deletions(-) create mode 100644 packages/sync-client/src/sync/core/portable-path.test.ts create mode 100644 packages/sync-client/src/sync/core/portable-path.ts diff --git a/apps/obsidian-plugin/release-notes/next.md b/apps/obsidian-plugin/release-notes/next.md index 9240ab08..80303fbd 100644 --- a/apps/obsidian-plugin/release-notes/next.md +++ b/apps/obsidian-plugin/release-notes/next.md @@ -9,3 +9,5 @@ - Apply downloaded files in related groups and release their buffers promptly. Servers that report file sizes use memory-aware download admission; older servers retain parallel downloads. Oversized groups run on their own. ## Fixed + +- Prevent files with incompatible paths from being uploaded, and identify them in the file explorer and sync settings so they can be renamed safely. diff --git a/apps/obsidian-plugin/src/app/plugin-controller.ts b/apps/obsidian-plugin/src/app/plugin-controller.ts index c3844136..f9436302 100644 --- a/apps/obsidian-plugin/src/app/plugin-controller.ts +++ b/apps/obsidian-plugin/src/app/plugin-controller.ts @@ -43,6 +43,7 @@ import type { SynchDeletedFilesPurgeResult, SynchDeletedFilesRestoreResult, SynchEntryVersionCursor, + SynchBlockedSyncFile, SynchFileSizeBlockedFile, SynchCommunityPluginUpdateStatus, SynchServerCompatibilityStatus, @@ -683,8 +684,13 @@ export class SynchPluginController implements SynchSettingsController { return await this.versionHistoryController.listDeletedFiles(before, limit); } + async listBlockedSyncFiles(): Promise { + return await this.syncController.listBlockedSyncFiles(); + } + + /** @deprecated Use `listBlockedSyncFiles`. */ async listFileSizeBlockedFiles(): Promise { - return await this.syncController.listFileSizeBlockedFiles(); + return await this.listBlockedSyncFiles(); } async previewDeletedFile( diff --git a/apps/obsidian-plugin/src/app/sync-controller.test.ts b/apps/obsidian-plugin/src/app/sync-controller.test.ts index dfce9550..fde99d34 100644 --- a/apps/obsidian-plugin/src/app/sync-controller.test.ts +++ b/apps/obsidian-plugin/src/app/sync-controller.test.ts @@ -396,8 +396,8 @@ describe("SyncController", () => { }); it("returns no file-size blocked files without an active authenticated remote vault session", async () => { - const listFileSizeBlockedFiles = vi - .spyOn(SyncEngine.prototype, "listFileSizeBlockedFiles") + const listBlockedSyncFiles = vi + .spyOn(SyncEngine.prototype, "listBlockedSyncFiles") .mockResolvedValue([ { path: "large.md", @@ -411,8 +411,9 @@ describe("SyncController", () => { }), ); + await expect(controller.listBlockedSyncFiles()).resolves.toEqual([]); await expect(controller.listFileSizeBlockedFiles()).resolves.toEqual([]); - expect(listFileSizeBlockedFiles).not.toHaveBeenCalled(); + expect(listBlockedSyncFiles).not.toHaveBeenCalled(); }); }); diff --git a/apps/obsidian-plugin/src/app/sync-controller.ts b/apps/obsidian-plugin/src/app/sync-controller.ts index 5c71cf2d..723d0786 100644 --- a/apps/obsidian-plugin/src/app/sync-controller.ts +++ b/apps/obsidian-plugin/src/app/sync-controller.ts @@ -40,6 +40,7 @@ import { type SyncDeletedEntriesRestoreResult, type SyncDeletedEntriesPurgeResult, type SyncEngineEntryVersionsPage, + type SyncBlockedSyncFile, type SyncFileSizeBlockedFile, type SyncEntryVersionPreview, getUserVisibleSyncDisplayPercent, @@ -474,12 +475,17 @@ export class SyncController { this.setSyncStatus("attention_needed"); } - async listFileSizeBlockedFiles(): Promise { + async listBlockedSyncFiles(): Promise { if (!this.deps.hasActiveRemoteVaultSession() || !this.deps.hasAuthenticatedSession()) { return []; } - return await this.syncEngine.listFileSizeBlockedFiles(); + return await this.syncEngine.listBlockedSyncFiles(); + } + + /** @deprecated Use `listBlockedSyncFiles`. */ + async listFileSizeBlockedFiles(): Promise { + return await this.listBlockedSyncFiles(); } async listEntryVersionsForPath( diff --git a/apps/obsidian-plugin/src/i18n/locales/de.ts b/apps/obsidian-plugin/src/i18n/locales/de.ts index eb8d891c..de4c9a21 100644 --- a/apps/obsidian-plugin/src/i18n/locales/de.ts +++ b/apps/obsidian-plugin/src/i18n/locales/de.ts @@ -158,6 +158,8 @@ export const de = { "sync.conflictLocalSaved": ({ path }: { path: string }) => `Sync-Konflikt erkannt. Ihre lokalen Änderungen wurden in „${path}“ gespeichert.`, "sync.conflictRemoteKept": ({ path }: { path: string }) => `Sync-Konflikt für „${path}“ erkannt. Die Remote-Version wird behalten.`, "sync.fileSizeBlocked": ({ count }: { count: number }) => `${count === 1 ? "1 Datei überschreitet" : `${count} Dateien überschreiten`} das Sync-Größenlimit.`, + "sync.incompatiblePathBlocked": "Der Pfad dieser Datei ist nicht kompatibel und kann nicht synchronisiert werden. Benennen Sie die Datei oder den Ordner um.", + "sync.incompatiblePathBlockedCount": ({ count }: { count: number }) => `${count === 1 ? "1 Datei hat" : `${count} Dateien haben`} einen inkompatiblen Pfad.`, "sync.label": "Synchronisierung", "sync.now": "Jetzt synchronisieren", "sync.frequency": "Sync-Häufigkeit", diff --git a/apps/obsidian-plugin/src/i18n/locales/en.ts b/apps/obsidian-plugin/src/i18n/locales/en.ts index 872839c4..26b2a617 100644 --- a/apps/obsidian-plugin/src/i18n/locales/en.ts +++ b/apps/obsidian-plugin/src/i18n/locales/en.ts @@ -155,6 +155,8 @@ export const en = { "sync.conflictLocalSaved": ({ path }: { path: string }) => `Sync conflict detected. Your local changes were saved to "${path}".`, "sync.conflictRemoteKept": ({ path }: { path: string }) => `Sync conflict detected for "${path}". The remote version will be kept.`, "sync.fileSizeBlocked": ({ count }: { count: number }) => `${count} ${count === 1 ? "file exceeds" : "files exceed"} the sync size limit.`, + "sync.incompatiblePathBlocked": "Synch cannot sync this file because its path is incompatible. Rename the file or folder to continue.", + "sync.incompatiblePathBlockedCount": ({ count }: { count: number }) => `${count} ${count === 1 ? "file has" : "files have"} an incompatible path.`, "sync.label": "Sync", "sync.now": "Sync now", "sync.frequency": "Sync frequency", diff --git a/apps/obsidian-plugin/src/i18n/locales/ja.ts b/apps/obsidian-plugin/src/i18n/locales/ja.ts index 6e5b43c8..f7e38a0a 100644 --- a/apps/obsidian-plugin/src/i18n/locales/ja.ts +++ b/apps/obsidian-plugin/src/i18n/locales/ja.ts @@ -144,6 +144,8 @@ export const ja = { "sync.connectRemoteVault": "同期を開始するにはリモートvaultに接続してください。", "sync.cursorMismatch": "このデバイスの同期履歴がリモートvaultと一致しないため、同期を停止しました。同期を再開するには、Synch設定でリモートvaultの接続を解除してから再接続してください。", "sync.fileSizeBlocked": ({ count }: { count: number }) => `${count}件のファイルが同期サイズ制限を超えています。`, + "sync.incompatiblePathBlocked": "このファイルのパスには互換性がないため、Synch は同期できません。ファイル名またはフォルダ名を変更してください。", + "sync.incompatiblePathBlockedCount": ({ count }: { count: number }) => `${count}件のファイルに互換性のないパスがあります。`, "sync.label": "同期", "sync.now": "今すぐ同期", "sync.frequency": "同期間隔", diff --git a/apps/obsidian-plugin/src/i18n/locales/ko.ts b/apps/obsidian-plugin/src/i18n/locales/ko.ts index 3fa43037..5f92fe63 100644 --- a/apps/obsidian-plugin/src/i18n/locales/ko.ts +++ b/apps/obsidian-plugin/src/i18n/locales/ko.ts @@ -141,6 +141,8 @@ export const ko = { "sync.connectRemoteVault": "동기화를 시작하려면 원격 vault에 연결하세요.", "sync.cursorMismatch": "이 기기의 동기화 기록이 원격 vault와 일치하지 않아 동기화를 중지했습니다. 다시 동기화하려면 Synch 설정에서 원격 vault의 연결을 해제한 후 다시 연결하세요.", "sync.fileSizeBlocked": ({ count }: { count: number }) => `${count}개 파일이 동기화 크기 제한을 초과했습니다.`, + "sync.incompatiblePathBlocked": "이 파일의 경로가 호환되지 않아 Synch가 동기화할 수 없습니다. 파일 또는 폴더 이름을 변경하세요.", + "sync.incompatiblePathBlockedCount": ({ count }: { count: number }) => `${count}개 파일의 경로가 호환되지 않습니다.`, "sync.label": "동기화", "sync.now": "지금 동기화", "sync.frequency": "동기화 주기", diff --git a/apps/obsidian-plugin/src/i18n/locales/zh-cn.ts b/apps/obsidian-plugin/src/i18n/locales/zh-cn.ts index 516ea0e1..344ac886 100644 --- a/apps/obsidian-plugin/src/i18n/locales/zh-cn.ts +++ b/apps/obsidian-plugin/src/i18n/locales/zh-cn.ts @@ -144,6 +144,8 @@ export const zhCn = { "sync.connectRemoteVault": "连接远程 vault 以开始同步。", "sync.cursorMismatch": "由于此设备的同步记录与远程 vault 不再一致,同步已暂停。要恢复同步,请在 Synch 设置中断开并重新连接远程 vault。", "sync.fileSizeBlocked": ({ count }: { count: number }) => `${count} 个文件超出同步大小限制。`, + "sync.incompatiblePathBlocked": "此文件的路径不兼容,因此 Synch 无法同步。请重命名文件或文件夹。", + "sync.incompatiblePathBlockedCount": ({ count }: { count: number }) => `${count} 个文件的路径不兼容。`, "sync.label": "同步", "sync.now": "立即同步", "sync.frequency": "同步频率", diff --git a/apps/obsidian-plugin/src/i18n/locales/zh-tw.ts b/apps/obsidian-plugin/src/i18n/locales/zh-tw.ts index 58b12c31..ee0e495e 100644 --- a/apps/obsidian-plugin/src/i18n/locales/zh-tw.ts +++ b/apps/obsidian-plugin/src/i18n/locales/zh-tw.ts @@ -144,6 +144,8 @@ export const zhTw = { "sync.connectRemoteVault": "連接遠端 vault 以開始同步。", "sync.cursorMismatch": "由於此裝置的同步記錄與遠端 vault 不再一致,同步已暫停。若要恢復同步,請在 Synch 設定中中斷並重新連接遠端 vault。", "sync.fileSizeBlocked": ({ count }: { count: number }) => `${count} 個檔案超出同步大小限制。`, + "sync.incompatiblePathBlocked": "此檔案的路徑不相容,因此 Synch 無法同步。請重新命名檔案或資料夾。", + "sync.incompatiblePathBlockedCount": ({ count }: { count: number }) => `${count} 個檔案的路徑不相容。`, "sync.label": "同步", "sync.now": "立即同步", "sync.frequency": "同步頻率", diff --git a/apps/obsidian-plugin/src/ui/contracts.ts b/apps/obsidian-plugin/src/ui/contracts.ts index 8c17fe9b..58ee6708 100644 --- a/apps/obsidian-plugin/src/ui/contracts.ts +++ b/apps/obsidian-plugin/src/ui/contracts.ts @@ -38,12 +38,16 @@ export type SynchStorageDisplayState = | "near_limit" | "needs_more_storage"; -export interface SynchFileSizeBlockedFile { +export interface SynchBlockedSyncFile { path: string; + reason?: "file_too_large" | "incompatible_path"; encryptedSizeBytes: number | null; maxFileSizeBytes: number | null; } +/** @deprecated Use `SynchBlockedSyncFile`. */ +export type SynchFileSizeBlockedFile = SynchBlockedSyncFile; + export type SynchCommunityPluginUpdateStatus = | { state: "idle" | "checking"; diff --git a/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.test.ts b/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.test.ts index 75d444d8..a5dea75c 100644 --- a/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.test.ts +++ b/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Plugin } from "obsidian"; +import { t } from "../../i18n"; import { decorateFileExplorerElement, @@ -106,7 +107,7 @@ describe("Synch file-size blocked decorator", () => { registerEvent: () => {}, } as unknown as Plugin, { - async listFileSizeBlockedFiles() { + async listBlockedSyncFiles() { return [ { path: "large.md", @@ -138,6 +139,15 @@ describe("Synch file-size blocked decorator", () => { }).not.toThrow(); expect(tooltip.length).toBeGreaterThan(0); }); + + it("explains when a file path is incompatible", () => { + expect(formatFileSizeBlockedTooltip({ + path: "Notes/a:b.md", + reason: "incompatible_path", + encryptedSizeBytes: null, + maxFileSizeBytes: null, + })).toBe(t("sync.incompatiblePathBlocked")); + }); }); function createFileExplorerRoot(paths: string[]): FakeElement { diff --git a/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.ts b/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.ts index 48cd49a1..67323a67 100644 --- a/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.ts +++ b/apps/obsidian-plugin/src/ui/file-size/file-size-blocked-decorator.ts @@ -1,7 +1,7 @@ import { setIcon, setTooltip, type Plugin } from "obsidian"; -import { getSynchLocale } from "../../i18n"; -import type { SynchFileSizeBlockedFile } from "../contracts"; +import { getSynchLocale, t } from "../../i18n"; +import type { SynchBlockedSyncFile } from "../contracts"; const FILE_EXPLORER_VIEW_TYPE = "file-explorer"; const FILE_TITLE_SELECTOR = ".nav-file-title[data-path]"; @@ -9,13 +9,13 @@ const BLOCKED_CLASS = "synch-file-size-blocked"; const ICON_CLASS = "synch-file-size-blocked-icon"; export interface SynchFileSizeBlockedDecoratorState { - listFileSizeBlockedFiles(): Promise; + listBlockedSyncFiles(): Promise; } export class SynchFileSizeBlockedDecorator { private refreshTimer: number | null = null; private refreshRun = 0; - private blockedFiles: SynchFileSizeBlockedFile[] = []; + private blockedFiles: SynchBlockedSyncFile[] = []; constructor( private readonly plugin: Plugin, @@ -54,7 +54,7 @@ export class SynchFileSizeBlockedDecorator { this.refreshRun = run; try { - const blockedFiles = await this.state.listFileSizeBlockedFiles(); + const blockedFiles = await this.state.listBlockedSyncFiles(); if (run !== this.refreshRun) { return; } @@ -71,7 +71,7 @@ export class SynchFileSizeBlockedDecorator { } } - private decorate(blockedFiles: SynchFileSizeBlockedFile[]): void { + private decorate(blockedFiles: SynchBlockedSyncFile[]): void { const blockedByPath = new Map(blockedFiles.map((file) => [file.path, file])); for (const leaf of this.plugin.app.workspace.getLeavesOfType(FILE_EXPLORER_VIEW_TYPE)) { decorateFileExplorerElement(leaf.view.containerEl, blockedByPath); @@ -81,7 +81,7 @@ export class SynchFileSizeBlockedDecorator { export function decorateFileExplorerElement( root: HTMLElement, - blockedByPath: ReadonlyMap, + blockedByPath: ReadonlyMap, ): void { for (const icon of queryHtmlElements(root, `.${ICON_CLASS}`)) { icon.remove(); @@ -116,7 +116,11 @@ function queryHtmlElements(root: HTMLElement, selector: string): HTMLElement[] { return Array.from(elements); } -export function formatFileSizeBlockedTooltip(file: SynchFileSizeBlockedFile): string { +export function formatFileSizeBlockedTooltip(file: SynchBlockedSyncFile): string { + if (file.reason === "incompatible_path") { + return t("sync.incompatiblePathBlocked"); + } + switch (getSynchLocale()) { case "ko": return [ diff --git a/apps/obsidian-plugin/src/ui/settings/__tests__/settings-tab-helpers.ts b/apps/obsidian-plugin/src/ui/settings/__tests__/settings-tab-helpers.ts index c233752d..0453a818 100644 --- a/apps/obsidian-plugin/src/ui/settings/__tests__/settings-tab-helpers.ts +++ b/apps/obsidian-plugin/src/ui/settings/__tests__/settings-tab-helpers.ts @@ -130,6 +130,7 @@ export function createSettingsTab( }), clearSyncLogs: vi.fn(() => {}), subscribeSyncLogs: () => () => {}, + listBlockedSyncFiles: vi.fn(async () => []), listFileSizeBlockedFiles: vi.fn(async () => []), isSyncEnabled: () => true, setSyncEnabled: vi.fn(async () => {}), diff --git a/apps/obsidian-plugin/src/ui/settings/controller.ts b/apps/obsidian-plugin/src/ui/settings/controller.ts index c4bb1ed6..0c7682af 100644 --- a/apps/obsidian-plugin/src/ui/settings/controller.ts +++ b/apps/obsidian-plugin/src/ui/settings/controller.ts @@ -7,6 +7,7 @@ import type { SynchDeletedFilesPurgeResult, SynchDeletedFile, SynchDeletedFilesRestoreResult, + SynchBlockedSyncFile, SynchFileSizeBlockedFile, SynchCommunityPluginUpdateStatus, SynchServerCompatibilityStatus, @@ -36,6 +37,8 @@ export interface SynchSettingsController { getSyncLogs(): SynchSyncLogs; clearSyncLogs(): void; subscribeSyncLogs(listener: () => void): () => void; + listBlockedSyncFiles(): Promise; + /** @deprecated Use `listBlockedSyncFiles`. */ listFileSizeBlockedFiles(): Promise; isSyncEnabled(): boolean; setSyncEnabled(enabled: boolean): Promise; diff --git a/apps/obsidian-plugin/src/ui/settings/sections/sync-status.ts b/apps/obsidian-plugin/src/ui/settings/sections/sync-status.ts index 6cee6e20..cf66176b 100644 --- a/apps/obsidian-plugin/src/ui/settings/sections/sync-status.ts +++ b/apps/obsidian-plugin/src/ui/settings/sections/sync-status.ts @@ -1,7 +1,7 @@ import { setIcon, setTooltip, Setting } from "obsidian"; import { t } from "../../../i18n"; import type { SynchSettingsController } from "../controller"; -import type { SynchStorageDisplayState } from "../../contracts"; +import type { SynchBlockedSyncFile, SynchStorageDisplayState } from "../../contracts"; import { formatStorageDescription, formatSyncDescription, getStoragePercent, shouldShowSyncSpinner } from "../format"; import { FileSizeBlockedWarningControls, @@ -159,9 +159,9 @@ function createFileSizeBlockedWarningControls( let icon: HTMLElement | null = null; async function refresh(currentRun: number): Promise { - let blockedFileCount = 0; + let blockedFiles: SynchBlockedSyncFile[]; try { - blockedFileCount = (await controller.listFileSizeBlockedFiles()).length; + blockedFiles = await controller.listBlockedSyncFiles(); } catch { return; } @@ -171,7 +171,7 @@ function createFileSizeBlockedWarningControls( icon?.remove(); icon = null; - if (blockedFileCount <= 0) { + if (blockedFiles.length === 0) { return; } @@ -180,7 +180,7 @@ function createFileSizeBlockedWarningControls( }); icon.setAttribute("aria-hidden", "true"); setIcon(icon, "triangle-alert"); - setTooltip(icon, formatFileSizeBlockedTooltip(blockedFileCount), { + setTooltip(icon, formatBlockedSyncTooltip(blockedFiles), { delay: 1, placement: "right", }); @@ -194,6 +194,18 @@ function createFileSizeBlockedWarningControls( }; } -function formatFileSizeBlockedTooltip(blockedFileCount: number): string { - return t("sync.fileSizeBlocked", { count: blockedFileCount }); +function formatBlockedSyncTooltip(blockedFiles: SynchBlockedSyncFile[]): string { + const incompatiblePathCount = blockedFiles.filter( + (file) => file.reason === "incompatible_path", + ).length; + const fileSizeCount = blockedFiles.length - incompatiblePathCount; + + return [ + fileSizeCount > 0 ? t("sync.fileSizeBlocked", { count: fileSizeCount }) : null, + incompatiblePathCount > 0 + ? t("sync.incompatiblePathBlockedCount", { count: incompatiblePathCount }) + : null, + ] + .filter((message): message is string => message !== null) + .join(" "); } diff --git a/apps/obsidian-plugin/src/ui/settings/settings-tab-sync-status.test.ts b/apps/obsidian-plugin/src/ui/settings/settings-tab-sync-status.test.ts index 3327da20..f21c8936 100644 --- a/apps/obsidian-plugin/src/ui/settings/settings-tab-sync-status.test.ts +++ b/apps/obsidian-plugin/src/ui/settings/settings-tab-sync-status.test.ts @@ -393,17 +393,25 @@ describe("SynchSettingTab sync status", () => { completedEntries: 4000, totalEntries: 4001, }), - listFileSizeBlockedFiles: vi.fn(async () => [ + listBlockedSyncFiles: vi.fn(async () => [ { path: "large.bin", + reason: "file_too_large", encryptedSizeBytes: 12_400_000, maxFileSizeBytes: 10_000_000, }, { path: "larger.bin", + reason: "file_too_large", encryptedSizeBytes: 22_400_000, maxFileSizeBytes: 10_000_000, }, + { + path: "notes/a:b.md", + reason: "incompatible_path", + encryptedSizeBytes: null, + maxFileSizeBytes: null, + }, ]), }); @@ -418,7 +426,7 @@ describe("SynchSettingTab sync status", () => { attributes: expect.objectContaining({ "aria-hidden": "true", "data-icon": "triangle-alert", - "data-tooltip": t("sync.fileSizeBlocked", { count: 2 }), + "data-tooltip": `${t("sync.fileSizeBlocked", { count: 2 })} ${t("sync.incompatiblePathBlockedCount", { count: 1 })}`, "data-tooltip-delay": "1", "data-tooltip-placement": "right", }), @@ -427,12 +435,12 @@ describe("SynchSettingTab sync status", () => { }); it("refreshes the file size warning without rerendering the settings tab", async () => { - let blockedFiles: Awaited> = []; + let blockedFiles: Awaited> = []; const tab = createSettingsTab({ hasAuthenticatedSession: () => true, hasConnectedRemoteVault: () => true, getSyncState: () => "up_to_date", - listFileSizeBlockedFiles: vi.fn(async () => blockedFiles), + listBlockedSyncFiles: vi.fn(async () => blockedFiles), }); tab.open(); diff --git a/packages/sync-client/src/sync/core/content.test.ts b/packages/sync-client/src/sync/core/content.test.ts index 254b3f5b..0d590851 100644 --- a/packages/sync-client/src/sync/core/content.test.ts +++ b/packages/sync-client/src/sync/core/content.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { encodeUtf8, hashBytes } from "./content"; +import { encodeUtf8, hashBytes, parseSyncedEntryMetadata } from "./content"; describe("hashBytes", () => { it("returns SHA-256 hex digests", async () => { @@ -9,3 +9,11 @@ describe("hashBytes", () => { ); }); }); + +describe("parseSyncedEntryMetadata", () => { + it("preserves path whitespace for portable-path validation", () => { + expect(parseSyncedEntryMetadata('{"path":"Notes/note.md ","hash":"hash"}')).toMatchObject({ + path: "Notes/note.md ", + }); + }); +}); diff --git a/packages/sync-client/src/sync/core/content.ts b/packages/sync-client/src/sync/core/content.ts index beb42f6f..11562d4f 100644 --- a/packages/sync-client/src/sync/core/content.ts +++ b/packages/sync-client/src/sync/core/content.ts @@ -40,8 +40,8 @@ export function parseSyncedEntryMetadata(value: string): SyncedEntryMetadata { } const record = parsed as Record; - const path = typeof record.path === "string" ? record.path.trim() : ""; - if (!path) { + const path = typeof record.path === "string" ? record.path : ""; + if (!path.trim()) { throw new Error("Sync metadata is missing a file path."); } if (!Object.prototype.hasOwnProperty.call(record, "hash")) { diff --git a/packages/sync-client/src/sync/core/portable-path.test.ts b/packages/sync-client/src/sync/core/portable-path.test.ts new file mode 100644 index 00000000..2eb3b40b --- /dev/null +++ b/packages/sync-client/src/sync/core/portable-path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { validatePortableVaultPath } from "./portable-path"; + +describe("validatePortableVaultPath", () => { + it.each([ + "Notes/2026-09-13.md", + "日本語/😀.md", + ".obsidian/plugins/example/data.json", + ])("accepts a portable path: %s", (path) => { + expect(validatePortableVaultPath(path)).toEqual([]); + }); + + it.each([ + ["Notes/a:b.md", "windows_reserved_character"], + ["Notes/a\\b.md", "windows_reserved_character"], + ["Notes/a\u0001b.md", "windows_control_character"], + ["Notes/note.md ", "windows_trailing_space_or_dot"], + ["Notes/NUL.md", "windows_reserved_name"], + ["COM¹", "windows_reserved_name"], + ["Notes//file.md", "empty_component"], + ["Notes/../file.md", "dot_component"], + ])("rejects %s", (path, code) => { + expect(validatePortableVaultPath(path).map((violation) => violation.code)).toContain(code); + }); +}); diff --git a/packages/sync-client/src/sync/core/portable-path.ts b/packages/sync-client/src/sync/core/portable-path.ts new file mode 100644 index 00000000..27a9fd1f --- /dev/null +++ b/packages/sync-client/src/sync/core/portable-path.ts @@ -0,0 +1,75 @@ +/** + * The subset of vault paths that can be created by Obsidian on every + * supported desktop platform. Keep this independent of a host adapter: the + * server cannot inspect encrypted metadata, so every client must reach the + * same decision before publishing a path. + */ +export type PortablePathViolationCode = + | "empty_path" + | "empty_component" + | "dot_component" + | "windows_reserved_character" + | "windows_control_character" + | "windows_trailing_space_or_dot" + | "windows_reserved_name"; + +export interface PortablePathViolation { + code: PortablePathViolationCode; + component: string | null; + componentIndex: number | null; +} + +const WINDOWS_RESERVED_CHARACTERS = /[<>:"\\|?*]/; +const WINDOWS_CONTROL_CHARACTERS = /[\u0000-\u001f]/; +const WINDOWS_TRAILING_SPACE_OR_DOT = /[ .]$/; +const WINDOWS_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\..*)?$/i; + +export function validatePortableVaultPath(path: string): PortablePathViolation[] { + if (!path) { + return [{ code: "empty_path", component: null, componentIndex: null }]; + } + + const violations: PortablePathViolation[] = []; + for (const [componentIndex, component] of path.split("/").entries()) { + if (!component) { + violations.push({ code: "empty_component", component, componentIndex }); + continue; + } + if (component === "." || component === "..") { + violations.push({ code: "dot_component", component, componentIndex }); + } + if (WINDOWS_RESERVED_CHARACTERS.test(component)) { + violations.push({ + code: "windows_reserved_character", + component, + componentIndex, + }); + } + if (WINDOWS_CONTROL_CHARACTERS.test(component)) { + violations.push({ + code: "windows_control_character", + component, + componentIndex, + }); + } + if (WINDOWS_TRAILING_SPACE_OR_DOT.test(component)) { + violations.push({ + code: "windows_trailing_space_or_dot", + component, + componentIndex, + }); + } + if (WINDOWS_DEVICE_NAME.test(component)) { + violations.push({ + code: "windows_reserved_name", + component, + componentIndex, + }); + } + } + return violations; +} + +export function isPortableVaultPath(path: string): boolean { + return validatePortableVaultPath(path).length === 0; +} diff --git a/packages/sync-client/src/sync/core/vault-path-policy.test.ts b/packages/sync-client/src/sync/core/vault-path-policy.test.ts index 7ca116fa..230ec496 100644 --- a/packages/sync-client/src/sync/core/vault-path-policy.test.ts +++ b/packages/sync-client/src/sync/core/vault-path-policy.test.ts @@ -126,6 +126,16 @@ describe("decideVaultPathSync", () => { }); describe("shouldApplyRemoteVaultPath", () => { + it("does not apply a path that is incompatible with another platform", () => { + expect( + shouldApplyRemoteVaultPath("Notes/a:b.md", { + fileRules: DEFAULT_SYNC_FILE_RULES, + vaultConfigRules: DEFAULT_VAULT_CONFIG_SYNC_RULES, + configDir: ".obsidian", + }), + ).toBe(false); + }); + it("keeps normal remote files eligible while honoring vault config rules", () => { expect( shouldApplyRemoteVaultPath("Notes/daily.md", { diff --git a/packages/sync-client/src/sync/core/vault-path-policy.ts b/packages/sync-client/src/sync/core/vault-path-policy.ts index cffd1ae1..353822fc 100644 --- a/packages/sync-client/src/sync/core/vault-path-policy.ts +++ b/packages/sync-client/src/sync/core/vault-path-policy.ts @@ -10,6 +10,7 @@ import { shouldSyncVaultConfigPath, type VaultConfigSyncRules, } from "./vault-config-rules"; +import { isPortableVaultPath } from "./portable-path"; export type VaultPathPolicyDecision = | { kind: "sync" } @@ -47,6 +48,9 @@ export function shouldApplyRemoteVaultPath( path: string, rules: VaultPathPolicyRules, ): boolean { + if (!isPortableVaultPath(path)) { + return false; + } if (isForbiddenVaultPath(path, rules.configDir)) { return false; } diff --git a/packages/sync-client/src/sync/engine/__tests__/push-mutation-preparer.test.ts b/packages/sync-client/src/sync/engine/__tests__/push-mutation-preparer.test.ts index 892bf6a1..7aec4c95 100644 --- a/packages/sync-client/src/sync/engine/__tests__/push-mutation-preparer.test.ts +++ b/packages/sync-client/src/sync/engine/__tests__/push-mutation-preparer.test.ts @@ -15,6 +15,24 @@ import { } from "./push-service/helpers"; describe("PushMutationPreparer encrypted payload retention", () => { + it("blocks a Windows-incompatible path before reading or uploading content", async () => { + const fixture = await createRetryFixture("Notes/a:b.md"); + try { + await expect(fixture.prepare()).resolves.toEqual({ + skipped: true, + reason: "incompatible_path", + }); + expect(fixture.encrypt).not.toHaveBeenCalled(); + expect(fixture.upload).not.toHaveBeenCalled(); + expect(await fixture.store.getDirtyEntryMutation(fixture.mutation.entryId)).toMatchObject({ + status: "blocked", + blockedReason: "incompatible_path", + }); + } finally { + await fixture.dispose(); + } + }); + it.each([ ["Folder/image.png", false], ["Folder/note.md", true], diff --git a/packages/sync-client/src/sync/engine/file-size-blocked.ts b/packages/sync-client/src/sync/engine/file-size-blocked.ts index e94d53b1..151a3288 100644 --- a/packages/sync-client/src/sync/engine/file-size-blocked.ts +++ b/packages/sync-client/src/sync/engine/file-size-blocked.ts @@ -1,19 +1,28 @@ import { decryptSyncMetadata } from "../core/crypto"; import { metadataContextFromMutation } from "./push-mutation-shared"; import type { SyncStore } from "../store/store"; +import type { PendingMutationBlockedReason } from "../store/store"; +/** @deprecated Use `SyncBlockedSyncFile`. */ export interface SyncFileSizeBlockedFile { path: string; encryptedSizeBytes: number | null; maxFileSizeBytes: number | null; } -export async function listFileSizeBlockedFiles( +export interface SyncBlockedSyncFile extends SyncFileSizeBlockedFile { + reason: PendingMutationBlockedReason; +} + +export async function listBlockedSyncFiles( store: SyncStore, remoteVaultKey: Uint8Array, -): Promise { - const mutations = await store.listBlockedDirtyEntriesByReason("file_too_large"); - const files: SyncFileSizeBlockedFile[] = []; +): Promise { + const mutations = (await Promise.all([ + store.listBlockedDirtyEntriesByReason("file_too_large"), + store.listBlockedDirtyEntriesByReason("incompatible_path"), + ])).flat(); + const files: SyncBlockedSyncFile[] = []; for (const mutation of mutations) { if (mutation.op !== "upsert") { continue; @@ -26,6 +35,7 @@ export async function listFileSizeBlockedFiles( ); files.push({ path: metadata.path, + reason: mutation.blockedReason ?? "file_too_large", encryptedSizeBytes: mutation.blockedEncryptedSizeBytes ?? null, maxFileSizeBytes: mutation.blockedMaxFileSizeBytes ?? null, }); @@ -33,3 +43,11 @@ export async function listFileSizeBlockedFiles( return files; } + +/** @deprecated Use `listBlockedSyncFiles`. */ +export async function listFileSizeBlockedFiles( + store: SyncStore, + remoteVaultKey: Uint8Array, +): Promise { + return await listBlockedSyncFiles(store, remoteVaultKey); +} diff --git a/packages/sync-client/src/sync/engine/push-mutation-preparer.ts b/packages/sync-client/src/sync/engine/push-mutation-preparer.ts index c186557a..8fb26cce 100644 --- a/packages/sync-client/src/sync/engine/push-mutation-preparer.ts +++ b/packages/sync-client/src/sync/engine/push-mutation-preparer.ts @@ -24,6 +24,7 @@ import { toCommitPayload, } from "./push-mutation-shared"; import { isAutoMergeTextPath } from "./text-merge-policy"; +import { isPortableVaultPath } from "../core/portable-path"; export class PushMutationPreparer { private readonly blobClient: Pick; @@ -59,6 +60,11 @@ export class PushMutationPreparer { }; } + if (!isPortableVaultPath(metadata.path)) { + await this.blockIncompatiblePathUpsert(store, mutation); + return { skipped: true, reason: "incompatible_path" }; + } + if (!mutation.blobId) { throw new Error(`Upsert mutation ${mutation.mutationId} is missing a blobId.`); } @@ -191,6 +197,19 @@ export class PushMutationPreparer { }); } + private async blockIncompatiblePathUpsert( + store: PushMutationStore, + mutation: PendingMutationRow, + ): Promise { + await store.updateDirtyEntry({ + ...mutation, + status: "blocked", + blockedReason: "incompatible_path", + blockedEncryptedSizeBytes: null, + blockedMaxFileSizeBytes: null, + }); + } + private async requeueChangedUpsert( store: PushMutationStore, mutation: PendingMutationRow, diff --git a/packages/sync-client/src/sync/engine/push-mutation-types.ts b/packages/sync-client/src/sync/engine/push-mutation-types.ts index 2d341ce1..88fab909 100644 --- a/packages/sync-client/src/sync/engine/push-mutation-types.ts +++ b/packages/sync-client/src/sync/engine/push-mutation-types.ts @@ -77,7 +77,7 @@ export interface PushMutationStore export interface SkippedPushMutation { skipped: true; - reason: "file_too_large" | "storage_quota_exceeded"; + reason: "file_too_large" | "incompatible_path" | "storage_quota_exceeded"; } export type PreparePushMutationResult = PreparedPushMutation | SkippedPushMutation | null; diff --git a/packages/sync-client/src/sync/engine/push-service.ts b/packages/sync-client/src/sync/engine/push-service.ts index d3c0b870..48483258 100644 --- a/packages/sync-client/src/sync/engine/push-service.ts +++ b/packages/sync-client/src/sync/engine/push-service.ts @@ -47,6 +47,7 @@ export interface SyncPushServiceDeps extends SyncContentRuntimeDeps { prepareConcurrency?: number; onProgress?: (progress: SyncOperationProgress) => Promise; onConflict?: (event: PushConflictEvent) => void; + /** @deprecated Name retained for host compatibility; fires for every blocked sync file. */ onFileSizeBlockedFilesChange?: () => void; onFileSyncStarted?: (event: { operation: "upsert" | "delete"; @@ -116,7 +117,7 @@ export class SyncPushService { let filesCreatedOrUpdated = 0; let filesDeleted = 0; let conflictsCreated = 0; - let fileSizeBlocked = 0; + let blockedSyncFiles = 0; let shouldPullAfterPush = false; const acceptedCursors: number[] = []; // Allow one immediate retry after requeueing; repeated churn must use the @@ -169,8 +170,8 @@ export class SyncPushService { path, reason: prepared.reason, }); - if (prepared.reason === "file_too_large") { - fileSizeBlocked += 1; + if (prepared.reason === "file_too_large" || prepared.reason === "incompatible_path") { + blockedSyncFiles += 1; } if (prepared.reason === "storage_quota_exceeded") { stopAfterCurrentBatch = true; @@ -352,8 +353,8 @@ export class SyncPushService { progress.seal(); await onProgress(progress.snapshot()); - // TODO: Refresh file-size-blocked decorations when existing blocked files become syncable. - if (fileSizeBlocked > 0) { + // TODO: Refresh decorations when an existing blocked file becomes syncable. + if (blockedSyncFiles > 0) { this.deps.onFileSizeBlockedFilesChange?.(); } diff --git a/packages/sync-client/src/sync/engine/vault-config-reapply.ts b/packages/sync-client/src/sync/engine/vault-config-reapply.ts index 9712f624..0a883d72 100644 --- a/packages/sync-client/src/sync/engine/vault-config-reapply.ts +++ b/packages/sync-client/src/sync/engine/vault-config-reapply.ts @@ -1,5 +1,6 @@ import type { SyncContentRuntimeDeps } from "../core/content-runtime"; import { decryptSyncBlob } from "../core/crypto"; +import { isPortableVaultPath } from "../core/portable-path"; import { shouldSyncVaultConfigPath, type VaultConfigSyncRules, @@ -37,6 +38,7 @@ export async function reapplyAllowedRemoteVaultConfig( const remotes = (await store.listRemoteStates()).filter( (entry) => entry.path && + isPortableVaultPath(entry.path) && shouldSyncVaultConfigPath(entry.path, rules, deps.configDir), ); if (remotes.length === 0) { diff --git a/packages/sync-client/src/sync/runtime/sync-engine.test.ts b/packages/sync-client/src/sync/runtime/sync-engine.test.ts index e5254716..18248502 100644 --- a/packages/sync-client/src/sync/runtime/sync-engine.test.ts +++ b/packages/sync-client/src/sync/runtime/sync-engine.test.ts @@ -92,13 +92,16 @@ describe("SyncEngine", () => { const { engine } = createTestEngine(vault); engine.setStore(store); - await expect(engine.listFileSizeBlockedFiles()).resolves.toEqual([ + const expected = [ { path: "Folder/large.md", + reason: "file_too_large" as const, encryptedSizeBytes: 12_400_000, maxFileSizeBytes: 10_000_000, }, - ]); + ]; + await expect(engine.listBlockedSyncFiles()).resolves.toEqual(expected); + await expect(engine.listFileSizeBlockedFiles()).resolves.toEqual(expected); await store.close(); }); @@ -107,6 +110,7 @@ describe("SyncEngine", () => { vault.seedText("note.md", "body"); const { engine } = createTestEngine(vault); + await expect(engine.listBlockedSyncFiles()).resolves.toEqual([]); await expect(engine.listFileSizeBlockedFiles()).resolves.toEqual([]); }); diff --git a/packages/sync-client/src/sync/runtime/sync-engine.ts b/packages/sync-client/src/sync/runtime/sync-engine.ts index d9193336..367c2188 100644 --- a/packages/sync-client/src/sync/runtime/sync-engine.ts +++ b/packages/sync-client/src/sync/runtime/sync-engine.ts @@ -64,7 +64,9 @@ import { type SyncEntryVersionsPage, } from "./version-history-service"; import { + listBlockedSyncFiles, listFileSizeBlockedFiles, + type SyncBlockedSyncFile, type SyncFileSizeBlockedFile, } from "../engine/file-size-blocked"; import { @@ -638,6 +640,16 @@ export class SyncEngine { return this.deps.getConfigDir(); } + async listBlockedSyncFiles(): Promise { + const store = this.syncStore; + if (!store) { + return []; + } + + return await listBlockedSyncFiles(store, this.deps.getRemoteVaultKey()); + } + + /** @deprecated Use `listBlockedSyncFiles`. */ async listFileSizeBlockedFiles(): Promise { const store = this.syncStore; if (!store) { @@ -780,5 +792,5 @@ export class SyncEngine { } } -export type { SyncFileSizeBlockedFile } from "../engine/file-size-blocked"; +export type { SyncBlockedSyncFile, SyncFileSizeBlockedFile } from "../engine/file-size-blocked"; export type SyncEngineEntryVersionsPage = SyncEntryVersionsPage; diff --git a/packages/sync-client/src/sync/store/store.ts b/packages/sync-client/src/sync/store/store.ts index 40e393b8..7da606dd 100644 --- a/packages/sync-client/src/sync/store/store.ts +++ b/packages/sync-client/src/sync/store/store.ts @@ -66,7 +66,7 @@ export interface CachedSyncBlobRow { export type SyncBlobRole = "base" | "remote" | "local-cache"; -export type PendingMutationBlockedReason = "file_too_large"; +export type PendingMutationBlockedReason = "file_too_large" | "incompatible_path"; export interface PendingMutationRow { mutationId: string; From d4d8887caa7027607f4f1ca246bcbeab47ab2701 Mon Sep 17 00:00:00 2001 From: algiadev8 <57441686+algiadev8@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:02:33 +0900 Subject: [PATCH 2/5] Allow tombstones for incompatible vault paths --- .../src/sync/core/vault-path-policy.test.ts | 14 +++++ .../src/sync/core/vault-path-policy.ts | 6 +- .../pull-service/path-operations.test.ts | 62 +++++++++++++++++++ .../sync/engine/pull-entry-state-applier.ts | 7 ++- .../src/sync/engine/pull-service.ts | 2 +- .../src/sync/runtime/sync-engine.ts | 4 +- 6 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/sync-client/src/sync/core/vault-path-policy.test.ts b/packages/sync-client/src/sync/core/vault-path-policy.test.ts index 230ec496..2a3b57fc 100644 --- a/packages/sync-client/src/sync/core/vault-path-policy.test.ts +++ b/packages/sync-client/src/sync/core/vault-path-policy.test.ts @@ -136,6 +136,20 @@ describe("shouldApplyRemoteVaultPath", () => { ).toBe(false); }); + it("applies a tombstone for a previously synced incompatible path", () => { + expect( + shouldApplyRemoteVaultPath( + "Notes/a:b.md", + { + fileRules: DEFAULT_SYNC_FILE_RULES, + vaultConfigRules: DEFAULT_VAULT_CONFIG_SYNC_RULES, + configDir: DEFAULT_CONFIG_DIR, + }, + { deleted: true }, + ), + ).toBe(true); + }); + it("keeps normal remote files eligible while honoring vault config rules", () => { expect( shouldApplyRemoteVaultPath("Notes/daily.md", { diff --git a/packages/sync-client/src/sync/core/vault-path-policy.ts b/packages/sync-client/src/sync/core/vault-path-policy.ts index 353822fc..e8a1f3c3 100644 --- a/packages/sync-client/src/sync/core/vault-path-policy.ts +++ b/packages/sync-client/src/sync/core/vault-path-policy.ts @@ -47,8 +47,12 @@ export function decideVaultPathSync( export function shouldApplyRemoteVaultPath( path: string, rules: VaultPathPolicyRules, + options: { deleted?: boolean } = {}, ): boolean { - if (!isPortableVaultPath(path)) { + // A tombstone cannot create an incompatible path. Allow it through so + // clients upgraded from versions that synced such paths can remove the + // already-tracked local file. + if (!options.deleted && !isPortableVaultPath(path)) { return false; } if (isForbiddenVaultPath(path, rules.configDir)) { diff --git a/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts b/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts index 625aeee7..28983b82 100644 --- a/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts +++ b/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts @@ -82,6 +82,68 @@ describe("SyncPullService path operations", () => { await store.close(); }); + it("applies a tombstone when live writes for the path are rejected", async () => { + const store = createTestSyncStore(); + const path = "Notes/a:b.md"; + const adapter = createVaultAdapter({ [path]: "legacy content" }); + await store.upsertEntry({ + entryId: "entry-incompatible", + path, + revision: 1, + blobId: "blob-legacy", + hash: await hashText("legacy content"), + deleted: false, + updatedAt: 1, + }); + + const session = createRealtimeSession({ + pages: [ + { + cursor: 2, + hasMore: false, + commits: [ + createCommit({ + cursor: 2, + entryId: "entry-incompatible", + op: "delete", + revision: 2, + baseRevision: 1, + encryptedMetadata: await encryptRemoteMetadata({ + entryId: "entry-incompatible", + revision: 2, + deleted: true, + blobId: null, + path, + }), + }), + ], + }, + ], + }); + const service = new SyncPullService({ + contentRuntime: createTestContentRuntime(), + getSyncToken: async () => createToken(), + getSyncStore: () => store, + getRemoteVaultKey: () => TEST_VAULT_KEY, + shouldApplyRemotePath: (_path, deleted) => deleted, + vaultAdapter: adapter, + blobClient: createBlobClient({}), + onProgress: ignoreProgress, + }); + + await expect(service.pullOnce(session)).resolves.toMatchObject({ + cursor: 2, + entriesApplied: 1, + filesDeleted: 1, + }); + expect(adapter.files.has(path)).toBe(false); + expect(await store.getRemoteStateById("entry-incompatible")).toMatchObject({ + revision: 2, + deleted: true, + }); + await store.close(); + }); + it("applies remote path changes using a vault rename", async () => { const store = createTestSyncStore(); const adapter = createVaultAdapter({ diff --git a/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts b/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts index b09b5cda..2fad7d5e 100644 --- a/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts +++ b/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts @@ -44,7 +44,7 @@ export interface PullEntryStateApplierDeps extends SyncContentRuntimeDeps { vaultAdapter: PullEntryStateVaultAdapter; eventGate?: SyncEventGateLike; blobClient: Pick; - shouldApplyRemotePath?: (path: string) => boolean; + shouldApplyRemotePath?: (path: string, deleted: boolean) => boolean; shouldUseLatestRemoteVersion?: (path: string) => boolean; prepareConcurrency?: number; onConflict?: (event: PullConflictEvent) => void; @@ -303,7 +303,10 @@ export class PullEntryStateApplier { private shouldApplyPlanToVault(plan: PlannedEntryState): boolean { return ( !plan.metadata.path || - this.deps.shouldApplyRemotePath?.(plan.metadata.path) !== false + this.deps.shouldApplyRemotePath?.( + plan.metadata.path, + plan.state.deleted, + ) !== false ); } diff --git a/packages/sync-client/src/sync/engine/pull-service.ts b/packages/sync-client/src/sync/engine/pull-service.ts index a65d64b6..051292e2 100644 --- a/packages/sync-client/src/sync/engine/pull-service.ts +++ b/packages/sync-client/src/sync/engine/pull-service.ts @@ -25,7 +25,7 @@ export interface SyncPullServiceDeps extends SyncContentRuntimeDeps { getSyncToken: () => Promise; getSyncStore: () => SyncPullStore | null; getRemoteVaultKey: () => Uint8Array; - shouldApplyRemotePath?: (path: string) => boolean; + shouldApplyRemotePath?: (path: string, deleted: boolean) => boolean; shouldUseLatestRemoteVersion?: (path: string) => boolean; vaultAdapter: PullVaultAdapter; eventGate?: SyncEventGateLike; diff --git a/packages/sync-client/src/sync/runtime/sync-engine.ts b/packages/sync-client/src/sync/runtime/sync-engine.ts index 367c2188..19403d5a 100644 --- a/packages/sync-client/src/sync/runtime/sync-engine.ts +++ b/packages/sync-client/src/sync/runtime/sync-engine.ts @@ -321,8 +321,8 @@ export class SyncEngine { getSyncToken: async () => await this.deps.getSyncToken(), getSyncStore: () => this.syncStore, getRemoteVaultKey: () => this.deps.getRemoteVaultKey(), - shouldApplyRemotePath: (path) => - shouldApplyRemoteVaultPath(path, this.vaultPathPolicyRules()), + shouldApplyRemotePath: (path, deleted) => + shouldApplyRemoteVaultPath(path, this.vaultPathPolicyRules(), { deleted }), shouldUseLatestRemoteVersion: (path) => shouldUseLatestRemoteVaultConfig(path, this.vaultPathPolicyRules()), eventGate: this.syncEventGate, From f8c9782cfc67d59e35e059e36f46ea1dc6e7671a Mon Sep 17 00:00:00 2001 From: algiadev8 <57441686+algiadev8@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:10:10 +0900 Subject: [PATCH 3/5] Address incompatible path review feedback --- .../src/sync/core/portable-path.test.ts | 2 ++ .../src/sync/core/portable-path.ts | 3 +- .../src/sync/engine/file-size-blocked.ts | 4 ++- .../src/sync/runtime/sync-engine.test.ts | 28 ++++++++++++++++--- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/sync-client/src/sync/core/portable-path.test.ts b/packages/sync-client/src/sync/core/portable-path.test.ts index 2eb3b40b..f12e56d2 100644 --- a/packages/sync-client/src/sync/core/portable-path.test.ts +++ b/packages/sync-client/src/sync/core/portable-path.test.ts @@ -18,6 +18,8 @@ describe("validatePortableVaultPath", () => { ["Notes/note.md ", "windows_trailing_space_or_dot"], ["Notes/NUL.md", "windows_reserved_name"], ["COM¹", "windows_reserved_name"], + ["Notes/CONIN$", "windows_reserved_name"], + ["Notes/CONOUT$.log", "windows_reserved_name"], ["Notes//file.md", "empty_component"], ["Notes/../file.md", "dot_component"], ])("rejects %s", (path, code) => { diff --git a/packages/sync-client/src/sync/core/portable-path.ts b/packages/sync-client/src/sync/core/portable-path.ts index 27a9fd1f..a670f06a 100644 --- a/packages/sync-client/src/sync/core/portable-path.ts +++ b/packages/sync-client/src/sync/core/portable-path.ts @@ -22,7 +22,8 @@ export interface PortablePathViolation { const WINDOWS_RESERVED_CHARACTERS = /[<>:"\\|?*]/; const WINDOWS_CONTROL_CHARACTERS = /[\u0000-\u001f]/; const WINDOWS_TRAILING_SPACE_OR_DOT = /[ .]$/; -const WINDOWS_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\..*)?$/i; +const WINDOWS_DEVICE_NAME = + /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[\d¹²³]|LPT[\d¹²³]) *(?:\..*)?$/i; export function validatePortableVaultPath(path: string): PortablePathViolation[] { if (!path) { diff --git a/packages/sync-client/src/sync/engine/file-size-blocked.ts b/packages/sync-client/src/sync/engine/file-size-blocked.ts index 151a3288..a966a737 100644 --- a/packages/sync-client/src/sync/engine/file-size-blocked.ts +++ b/packages/sync-client/src/sync/engine/file-size-blocked.ts @@ -49,5 +49,7 @@ export async function listFileSizeBlockedFiles( store: SyncStore, remoteVaultKey: Uint8Array, ): Promise { - return await listBlockedSyncFiles(store, remoteVaultKey); + return (await listBlockedSyncFiles(store, remoteVaultKey)).filter( + (file) => file.reason === "file_too_large", + ); } diff --git a/packages/sync-client/src/sync/runtime/sync-engine.test.ts b/packages/sync-client/src/sync/runtime/sync-engine.test.ts index 18248502..07c7e8cc 100644 --- a/packages/sync-client/src/sync/runtime/sync-engine.test.ts +++ b/packages/sync-client/src/sync/runtime/sync-engine.test.ts @@ -71,7 +71,7 @@ describe("SyncEngine", () => { await store.close(); }); - it("lists file-size blocked files with decrypted paths and size metadata", async () => { + it("keeps the deprecated file-size list limited to oversized files", async () => { const vault = new InMemoryVaultAdapter(); vault.seedText("note.md", "body"); const store = createTestSyncStore(); @@ -89,10 +89,22 @@ describe("SyncEngine", () => { blockedEncryptedSizeBytes: 12_400_000, blockedMaxFileSizeBytes: 10_000_000, }); + const incompatiblePath = await queueLocalUpsertMutation(store, { + remoteVaultKey: TEST_VAULT_KEY, + path: "Folder/bad:name.md", + entryId: "entry-incompatible-path", + base: null, + hash: "hash-incompatible-path", + }); + await store.updateDirtyEntry({ + ...incompatiblePath.mutation, + status: "blocked", + blockedReason: "incompatible_path", + }); const { engine } = createTestEngine(vault); engine.setStore(store); - const expected = [ + const fileSizeBlockedExpected = [ { path: "Folder/large.md", reason: "file_too_large" as const, @@ -100,8 +112,16 @@ describe("SyncEngine", () => { maxFileSizeBytes: 10_000_000, }, ]; - await expect(engine.listBlockedSyncFiles()).resolves.toEqual(expected); - await expect(engine.listFileSizeBlockedFiles()).resolves.toEqual(expected); + await expect(engine.listBlockedSyncFiles()).resolves.toEqual([ + ...fileSizeBlockedExpected, + { + path: "Folder/bad:name.md", + reason: "incompatible_path", + encryptedSizeBytes: null, + maxFileSizeBytes: null, + }, + ]); + await expect(engine.listFileSizeBlockedFiles()).resolves.toEqual(fileSizeBlockedExpected); await store.close(); }); From 5418f36fe568b204ec60a4853f1144f842222927 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Fri, 18 Sep 2026 11:05:18 +0900 Subject: [PATCH 4/5] fix(sync): preserve paths owned by blocked remote renames --- apps/obsidian-plugin/release-notes/next.md | 2 +- .../pull-service/path-operations.test.ts | 76 +++++++++++++++++++ .../sync/engine/pull-entry-state-applier.ts | 33 ++------ .../src/sync/engine/pull-manifest-planner.ts | 28 ++++++- 4 files changed, 109 insertions(+), 30 deletions(-) diff --git a/apps/obsidian-plugin/release-notes/next.md b/apps/obsidian-plugin/release-notes/next.md index 80303fbd..7f5b6f8f 100644 --- a/apps/obsidian-plugin/release-notes/next.md +++ b/apps/obsidian-plugin/release-notes/next.md @@ -10,4 +10,4 @@ ## Fixed -- Prevent files with incompatible paths from being uploaded, and identify them in the file explorer and sync settings so they can be renamed safely. +- Prevent files with incompatible paths from being uploaded, and identify them in the file explorer and sync settings so they can be renamed safely. Preserve existing local files when a blocked remote rename would otherwise let another file overwrite them. diff --git a/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts b/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts index 28983b82..4365c7e9 100644 --- a/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts +++ b/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts @@ -2,6 +2,7 @@ import { createTestContentRuntime } from "../../../../test-support/content-runti import { describe, expect, it } from "vitest"; import { SyncPullService } from "../../pull-service"; +import { isPortableVaultPath } from "../../../core/portable-path"; import { createTestSyncStore } from "../../../../test-support/in-memory-sync-store"; import { createCommit, @@ -19,6 +20,81 @@ import { } from "./helpers"; describe("SyncPullService path operations", () => { + it.each([false, true])( + "preserves a blocked rename's local path (paginated: %s)", + async (paginated) => { + const store = createTestSyncStore(); + const adapter = createVaultAdapter({ "a.md": "content A", "b.md": "content B" }); + for (const id of ["a", "b"]) { + await store.upsertEntry({ + entryId: id, + path: `${id}.md`, + revision: 1, + blobId: `blob-${id}`, + hash: await hashText(`content ${id.toUpperCase()}`), + deleted: false, + updatedAt: 1, + }); + } + const commits = []; + for (const [id, path, cursor] of [ + ["a", "bad:name.md", 2], + ["b", "a.md", 3], + ] as const) { + commits.push(createCommit({ + cursor, + entryId: id, + revision: 2, + baseRevision: 1, + blobId: `blob-${id}`, + encryptedMetadata: await encryptRemoteMetadata({ + entryId: id, + revision: 2, + blobId: `blob-${id}`, + path, + hash: await hashText(`content ${id.toUpperCase()}`), + }), + })); + } + const conflicts: PullConflictSummary[] = []; + const service = new SyncPullService({ + contentRuntime: createTestContentRuntime(), + getSyncToken: async () => createToken(), + getSyncStore: () => store, + getRemoteVaultKey: () => TEST_VAULT_KEY, + shouldApplyRemotePath: (path, deleted) => deleted || isPortableVaultPath(path), + vaultAdapter: adapter, + blobClient: createBlobClient({ + blobs: { + "blob-b": await encryptTestBlob("blob-b", new TextEncoder().encode("content B")), + }, + }), + applyWindowSize: paginated ? 1 : undefined, + onConflict: (event) => conflicts.push(event), + onProgress: ignoreProgress, + }); + const pages = paginated + ? commits.map((commit, index) => ({ + cursor: commit.cursor, + hasMore: index === 0, + commits: [commit], + })) + : [{ cursor: 3, hasMore: false, commits }]; + await service.pullOnce(createRealtimeSession({ pages })); + + expect(adapter.text("a.md")).toBe("content A"); + expect(adapter.files.has("bad:name.md")).toBe(false); + expect(conflicts).toHaveLength(1); + expect(adapter.text(conflicts[0]!.conflictPath!)).toBe("content B"); + expect(await store.getLocalStateById("a")).toMatchObject({ path: "a.md" }); + expect(await store.getRemoteStateById("a")).toMatchObject({ + path: "bad:name.md", revision: 2, + }); + expect(await store.getCursor()).toBe(3); + await store.close(); + }, + ); + it("skips remote vault config writes when the current rules reject the path", async () => { const store = createTestSyncStore(); const adapter = createVaultAdapter({ diff --git a/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts b/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts index 2fad7d5e..2e4955c2 100644 --- a/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts +++ b/packages/sync-client/src/sync/engine/pull-entry-state-applier.ts @@ -196,11 +196,10 @@ export class PullEntryStateApplier { }; } - const { plans: allPlans, deferred, superseded } = await this.manifestPlanner.planManifest( + const { plans, deferred, superseded, skipped } = await this.manifestPlanner.planManifest( store, manifest, { deferExternalPathOwners: !options.finalWindow }, ); - const plans = allPlans.filter((plan) => this.shouldApplyPlanToVault(plan)); - await this.applySkippedRemoteStates(store, allPlans, plans); + await this.applySkippedRemoteStates(store, skipped); await this.markAlreadyCurrentVaultWrites(store, plans); const supersededWithPaths = await Promise.all(superseded.map(async (item) => ({ item, existingPath: (await store.getEntryById(item.state.entryId))?.path ?? null, @@ -268,7 +267,7 @@ export class PullEntryStateApplier { conflictsCreated: plans.reduce((count, plan) => count + (plan.pathConflict?.conflictPath ? 1 : 0) + (plan.pendingConflict?.conflictPath ? 1 : 0), 0), deferred, - completedStates: [...allPlans, ...superseded].map(({ state }) => ({ + completedStates: [...plans, ...superseded, ...skipped].map(({ state }) => ({ entryId: state.entryId, revision: state.revision, })), }; @@ -300,37 +299,17 @@ export class PullEntryStateApplier { } } - private shouldApplyPlanToVault(plan: PlannedEntryState): boolean { - return ( - !plan.metadata.path || - this.deps.shouldApplyRemotePath?.( - plan.metadata.path, - plan.state.deleted, - ) !== false - ); - } - private async applySkippedRemoteStates( store: PullEntryStateStore, - allPlans: PlannedEntryState[], - appliedPlans: PlannedEntryState[], + skipped: PullEntryStateManifestItem[], ): Promise { - if (allPlans.length === appliedPlans.length) { - return; - } - - const applied = new Set(appliedPlans); - for (const plan of allPlans) { - if (applied.has(plan)) { - continue; - } - + for (const plan of skipped) { await store.applyRemoteState({ entryId: plan.state.entryId, path: plan.metadata.path, revision: plan.state.revision, blobId: plan.state.deleted ? null : plan.state.blobId, - hash: plan.hash, + hash: plan.metadata.hash, deleted: plan.state.deleted, updatedAt: plan.state.updatedAt, }); diff --git a/packages/sync-client/src/sync/engine/pull-manifest-planner.ts b/packages/sync-client/src/sync/engine/pull-manifest-planner.ts index f484374e..8632d50a 100644 --- a/packages/sync-client/src/sync/engine/pull-manifest-planner.ts +++ b/packages/sync-client/src/sync/engine/pull-manifest-planner.ts @@ -33,6 +33,7 @@ interface PullManifestPlannerDeps { onConflict?: (event: PullConflictEvent) => void; onRollbackDetected?: (event: PullRollbackEvent) => void; shouldUseLatestRemoteVersion?: (path: string) => boolean; + shouldApplyRemotePath?: (path: string, deleted: boolean) => boolean; now?: () => number; } @@ -47,8 +48,31 @@ export class PullManifestPlanner { plans: PlannedEntryState[]; deferred: PullEntryStateManifestItem[]; superseded: PullEntryStateManifestItem[]; + skipped: PullEntryStateManifestItem[]; }> { - const validatedManifest = manifest.map((item) => this.validateManifestItem(item)); + const validatedManifest: ValidatedManifestItem[] = []; + const skipped: PullEntryStateManifestItem[] = []; + for (const input of manifest) { + const item = this.validateManifestItem(input); + if (this.deps.shouldApplyRemotePath?.(item.metadata.path, item.state.deleted) !== false) { + validatedManifest.push(item); + continue; + } + + const existing = await store.getEntryById(item.state.entryId); + if (existing && existing.revision > 0 && item.state.revision < existing.revision) { + this.deps.onRollbackDetected?.({ + entryId: item.state.entryId, + path: item.metadata.path, + localRevision: existing.revision, + remoteRevision: item.state.revision, + }); + continue; + } + skipped.push(item); + } + // Rejected entries retain their local paths. They must not count as moving + // owners or reserve destinations while planning the accepted entries. const latestManagedEntryByPath = this.findLatestManagedEntryByPath(validatedManifest); const activeManifest: ValidatedManifestItem[] = []; const superseded: PullEntryStateManifestItem[] = []; @@ -228,7 +252,7 @@ export class PullManifestPlanner { }); } - return { plans, deferred, superseded }; + return { plans, deferred, superseded, skipped }; } private validateManifestItem(item: PullEntryStateManifestItem): ValidatedManifestItem { From 0a54f0deaaa3f108b8eafa8d066eb01219375b0b Mon Sep 17 00:00:00 2001 From: hhhjin Date: Fri, 18 Sep 2026 11:08:14 +0900 Subject: [PATCH 5/5] fix(sync): report incompatible remote paths in blocked warnings --- apps/obsidian-plugin/release-notes/next.md | 2 +- .../pull-service/path-operations.test.ts | 84 ++++++++++++++++++- .../src/sync/engine/file-size-blocked.ts | 17 +++- .../src/sync/engine/pull-service.ts | 6 ++ .../src/sync/runtime/sync-engine.test.ts | 25 ++++++ .../src/sync/runtime/sync-engine.ts | 1 + 6 files changed, 132 insertions(+), 3 deletions(-) diff --git a/apps/obsidian-plugin/release-notes/next.md b/apps/obsidian-plugin/release-notes/next.md index 7f5b6f8f..aee82809 100644 --- a/apps/obsidian-plugin/release-notes/next.md +++ b/apps/obsidian-plugin/release-notes/next.md @@ -10,4 +10,4 @@ ## Fixed -- Prevent files with incompatible paths from being uploaded, and identify them in the file explorer and sync settings so they can be renamed safely. Preserve existing local files when a blocked remote rename would otherwise let another file overwrite them. +- Prevent files with incompatible paths from being uploaded, and identify blocked local files in the file explorer and blocked local or remote files in sync settings so they can be renamed safely. Preserve existing local files when a blocked remote rename would otherwise let another file overwrite them. diff --git a/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts b/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts index 4365c7e9..4a3fcd01 100644 --- a/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts +++ b/packages/sync-client/src/sync/engine/__tests__/pull-service/path-operations.test.ts @@ -1,7 +1,8 @@ import { createTestContentRuntime } from "../../../../test-support/content-runtime"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { SyncPullService } from "../../pull-service"; +import { listBlockedSyncFiles, listFileSizeBlockedFiles } from "../../file-size-blocked"; import { isPortableVaultPath } from "../../../core/portable-path"; import { createTestSyncStore } from "../../../../test-support/in-memory-sync-store"; import { @@ -95,6 +96,87 @@ describe("SyncPullService path operations", () => { }, ); + it.each(["rename", "delete"] as const)( + "reports a skipped remote file and clears its warning after a remote %s", + async (operation) => { + const store = createTestSyncStore(); + const adapter = createVaultAdapter({}); + const path = "bad:name.md"; + const hash = await hashText("remote content"); + const onRemoteStatesChange = vi.fn(); + const service = new SyncPullService({ + contentRuntime: createTestContentRuntime(), + getSyncToken: async () => createToken(), + getSyncStore: () => store, + getRemoteVaultKey: () => TEST_VAULT_KEY, + shouldApplyRemotePath: (path, deleted) => deleted || isPortableVaultPath(path), + vaultAdapter: adapter, + blobClient: createBlobClient({ + blobs: { + "blob-remote": await encryptTestBlob( + "blob-remote", new TextEncoder().encode("remote content"), + ), + }, + }), + onRemoteStatesChange, + onProgress: ignoreProgress, + }); + await service.pullOnce(createRealtimeSession({ pages: [{ + cursor: 1, + hasMore: false, + commits: [createCommit({ + entryId: "remote", + blobId: "blob-remote", + encryptedMetadata: await encryptRemoteMetadata({ + entryId: "remote", revision: 1, blobId: "blob-remote", path, hash, + }), + })], + }] })); + + expect(adapter.files.size).toBe(0); + expect(await store.listDirtyEntries()).toEqual([]); + await expect(listBlockedSyncFiles(store, TEST_VAULT_KEY)).resolves.toEqual([{ + path, + reason: "incompatible_path", + encryptedSizeBytes: null, + maxFileSizeBytes: null, + }]); + await expect(listFileSizeBlockedFiles(store, TEST_VAULT_KEY)).resolves.toEqual([]); + expect(onRemoteStatesChange).toHaveBeenCalledTimes(1); + + const deleted = operation === "delete"; + await service.pullOnce(createRealtimeSession({ pages: [{ + cursor: 2, + hasMore: false, + commits: [createCommit({ + cursor: 2, + entryId: "remote", + revision: 2, + baseRevision: 1, + op: deleted ? "delete" : "upsert", + blobId: deleted ? null : "blob-remote", + encryptedMetadata: await encryptRemoteMetadata({ + entryId: "remote", + revision: 2, + deleted, + blobId: deleted ? null : "blob-remote", + path: deleted ? path : "safe.md", + hash, + }), + })], + }] })); + await expect(listBlockedSyncFiles(store, TEST_VAULT_KEY)).resolves.toEqual([]); + expect(onRemoteStatesChange).toHaveBeenCalledTimes(2); + if (!deleted) expect(adapter.text("safe.md")).toBe("remote content"); + + await service.pullOnce(createRealtimeSession({ + pages: [{ cursor: 2, hasMore: false, commits: [] }], + })); + expect(onRemoteStatesChange).toHaveBeenCalledTimes(2); + await store.close(); + }, + ); + it("skips remote vault config writes when the current rules reject the path", async () => { const store = createTestSyncStore(); const adapter = createVaultAdapter({ diff --git a/packages/sync-client/src/sync/engine/file-size-blocked.ts b/packages/sync-client/src/sync/engine/file-size-blocked.ts index a966a737..fa8fc22e 100644 --- a/packages/sync-client/src/sync/engine/file-size-blocked.ts +++ b/packages/sync-client/src/sync/engine/file-size-blocked.ts @@ -1,3 +1,4 @@ +import { isPortableVaultPath } from "../core/portable-path"; import { decryptSyncMetadata } from "../core/crypto"; import { metadataContextFromMutation } from "./push-mutation-shared"; import type { SyncStore } from "../store/store"; @@ -41,7 +42,21 @@ export async function listBlockedSyncFiles( }); } - return files; + // Pull already persists decrypted remote metadata even when applying the + // file was rejected. Derive remote warnings from that state so they survive + // restarts and disappear when the remote path is renamed or deleted. + const byPath = new Map(files.map((file) => [file.path, file])); + for (const remote of await store.listRemoteStates()) { + if (!remote.deleted && remote.path && !isPortableVaultPath(remote.path)) { + byPath.set(remote.path, { + path: remote.path, + reason: "incompatible_path", + encryptedSizeBytes: null, + maxFileSizeBytes: null, + }); + } + } + return [...byPath.values()]; } /** @deprecated Use `listBlockedSyncFiles`. */ diff --git a/packages/sync-client/src/sync/engine/pull-service.ts b/packages/sync-client/src/sync/engine/pull-service.ts index 051292e2..2a2c5bb4 100644 --- a/packages/sync-client/src/sync/engine/pull-service.ts +++ b/packages/sync-client/src/sync/engine/pull-service.ts @@ -35,6 +35,7 @@ export interface SyncPullServiceDeps extends SyncContentRuntimeDeps { onProgress?: (progress: SyncOperationProgress) => Promise; onConflict?: (event: PullConflictEvent) => void; onRollbackDetected?: (event: PullRollbackEvent) => void; + onRemoteStatesChange?: () => void; onFileSyncStarted?: (event: { operation: "upsert" | "delete"; path: string; @@ -142,6 +143,7 @@ export class SyncPullService { (error: unknown) => ({ ok: false as const, error }), ); let pendingPage = startPage(null, null); + let remoteStatesMayHaveChanged = false; try { while (hasMore) { @@ -160,6 +162,7 @@ export class SyncPullService { if (window.length >= applyWindowSize || !hasMore) { const appliedWindow = window; + remoteStatesMayHaveChanged ||= window.length > 0; const applied = await this.entryStateApplier.applyManifestWindow( store, token, @@ -187,6 +190,9 @@ export class SyncPullService { } finally { // Do not let background work outlive pullOnce (or its crypto/session). await pendingPage; + // Refresh host warnings once per pull, including skipped remote files and + // partial progress before a later failure. Empty polls need no refresh. + if (remoteStatesMayHaveChanged) this.deps.onRemoteStatesChange?.(); } cursor = targetCursor ?? cursor; diff --git a/packages/sync-client/src/sync/runtime/sync-engine.test.ts b/packages/sync-client/src/sync/runtime/sync-engine.test.ts index 07c7e8cc..f767eb91 100644 --- a/packages/sync-client/src/sync/runtime/sync-engine.test.ts +++ b/packages/sync-client/src/sync/runtime/sync-engine.test.ts @@ -101,6 +101,25 @@ describe("SyncEngine", () => { status: "blocked", blockedReason: "incompatible_path", }); + // A remote copy and a blocked local mutation at the same path are one warning. + await store.applyRemoteState({ + entryId: "entry-incompatible-path", + path: "Folder/bad:name.md", + revision: 1, + blobId: "blob-remote", + hash: "remote-hash", + deleted: false, + updatedAt: 1, + }); + await store.applyRemoteState({ + entryId: "remote-only", + path: "remote:only.md", + revision: 1, + blobId: "blob-remote-only", + hash: "remote-only-hash", + deleted: false, + updatedAt: 1, + }); const { engine } = createTestEngine(vault); engine.setStore(store); @@ -120,6 +139,12 @@ describe("SyncEngine", () => { encryptedSizeBytes: null, maxFileSizeBytes: null, }, + { + path: "remote:only.md", + reason: "incompatible_path", + encryptedSizeBytes: null, + maxFileSizeBytes: null, + }, ]); await expect(engine.listFileSizeBlockedFiles()).resolves.toEqual(fileSizeBlockedExpected); await store.close(); diff --git a/packages/sync-client/src/sync/runtime/sync-engine.ts b/packages/sync-client/src/sync/runtime/sync-engine.ts index 19403d5a..21ce217e 100644 --- a/packages/sync-client/src/sync/runtime/sync-engine.ts +++ b/packages/sync-client/src/sync/runtime/sync-engine.ts @@ -318,6 +318,7 @@ export class SyncEngine { }, }); this.syncPullService = new SyncPullService({ + onRemoteStatesChange: () => this.deps.onFileSizeBlockedFilesChange?.(), getSyncToken: async () => await this.deps.getSyncToken(), getSyncStore: () => this.syncStore, getRemoteVaultKey: () => this.deps.getRemoteVaultKey(),