feat(core): implement advanced path filtering allowlist and denylist logic

This commit is contained in:
winters27 2026-04-17 18:30:21 -07:00
parent 9e69f0793f
commit ca74eb2165
6 changed files with 96 additions and 46 deletions

View file

@ -37,6 +37,7 @@
"@types/mocha": "^10.0.7",
"@types/mustache": "^4.2.5",
"@types/node": "^20.14.12",
"@types/picomatch": "^4.0.3",
"@types/qrcode": "^1.5.5",
"builtin-modules": "^4.0.0",
"cross-env": "^7.0.3",
@ -52,6 +53,7 @@
"tslib": "^2.6.3",
"tsx": "^4.16.2",
"typescript": "^5.5.4",
"vitest": "^4.1.4",
"webdav-server": "^2.6.2",
"webpack": "^5.93.0",
"webpack-cli": "^5.1.4",
@ -94,6 +96,7 @@
"p-queue": "^8.0.1",
"path-browserify": "^1.0.1",
"pcloud-sdk-js": "^2.0.0",
"picomatch": "^4.0.4",
"process": "^0.11.10",
"qrcode": "^1.5.3",
"rfc4648": "^1.5.3",

View file

@ -1,4 +1,4 @@
import { SVG_DROPBOX, SVG_ONEDRIVE, SVG_PCLOUD, SVG_S3, SVG_WEBDAV, SVG_WEBDIS } from './icons';
import { SVG_DROPBOX, SVG_ONEDRIVE, SVG_PCLOUD, SVG_S3, SVG_WEBDAV, SVG_WEBDIS } from './icons';
import { Eye, EyeOff, createElement } from "lucide";
import {
type App,
@ -1785,6 +1785,7 @@ export class BYOCSettingTab extends PluginSettingTab {
.setDesc(t("settings_autorun_desc"))
.addDropdown((dropdown) => {
dropdown.addOption("-1", t("settings_autorun_notset"));
dropdown.addOption(`${1000 * 30}`, "Every 30 seconds");
dropdown.addOption(`${1000 * 60 * 1}`, t("settings_autorun_1min"));
dropdown.addOption(`${1000 * 60 * 5}`, t("settings_autorun_5min"));
dropdown.addOption(`${1000 * 60 * 10}`, t("settings_autorun_10min"));
@ -1849,14 +1850,13 @@ export class BYOCSettingTab extends PluginSettingTab {
.setDesc(t("settings_synconsave_desc"))
.addDropdown((dropdown) => {
dropdown.addOption("-1", t("settings_synconsave_disable"));
dropdown.addOption("1000", t("settings_synconsave_enable"));
// for backward compatibility, we need to use a number representing seconds
let syncOnSaveEnabled = false;
if ((this.plugin.settings.syncOnSaveAfterMilliseconds ?? -1) > 0) {
syncOnSaveEnabled = true;
}
dropdown.addOption("3000", "3 seconds");
dropdown.addOption("5000", "5 seconds (Recommended)");
dropdown.addOption("10000", "10 seconds");
dropdown.addOption("30000", "30 seconds");
dropdown
.setValue(`${syncOnSaveEnabled ? "1000" : "-1"}`)
.setValue(`${(this.plugin.settings.syncOnSaveAfterMilliseconds ?? -1) > 0 ? this.plugin.settings.syncOnSaveAfterMilliseconds : "-1"}`)
.onChange(async (val: string) => {
this.plugin.settings.syncOnSaveAfterMilliseconds =
Number.parseInt(val);
@ -1940,6 +1940,7 @@ export class BYOCSettingTab extends PluginSettingTab {
textArea.inputEl.cols = 30;
textArea.inputEl.addClass("ignorepaths-textarea");
textArea.setPlaceholder("*.pdf\n.obsidian/plugins/foo/**\nsecret-folder/");
});
new Setting(basicDiv)
@ -1961,6 +1962,7 @@ export class BYOCSettingTab extends PluginSettingTab {
textArea.inputEl.cols = 30;
textArea.inputEl.addClass("onlyallowpaths-textarea");
textArea.setPlaceholder("notes/**\npublic/**/*.md");
});
//////////////////////////////////////////////////
@ -2224,41 +2226,7 @@ export class BYOCSettingTab extends PluginSettingTab {
});
});
if (Platform.isMobile) {
new Setting(advDiv)
.setName(t("settings_enablemobilestatusbar"))
.setDesc(t("settings_enablemobilestatusbar_desc"))
.addDropdown(async (dropdown) => {
dropdown
.addOption("enable", t("enable"))
.addOption("disable", t("disable"));
dropdown
.setValue(
`${
this.plugin.settings.enableMobileStatusBar
? "enable"
: "disable"
}`
)
.onChange(async (val) => {
if (val === "enable") {
this.plugin.settings.enableMobileStatusBar = true;
this.plugin.appContainerObserver =
changeMobileStatusBar("enable");
} else {
this.plugin.settings.enableMobileStatusBar = false;
changeMobileStatusBar(
"disable",
this.plugin.appContainerObserver
);
this.plugin.appContainerObserver?.disconnect();
this.plugin.appContainerObserver = undefined;
}
await this.plugin.saveSettings();
});
});
}
//////////////////////////////////////////////////
// below for import and export functions

21
src/sync/pathFilter.ts Normal file
View file

@ -0,0 +1,21 @@
import picomatch from "picomatch";
export function shouldSyncPath(
keyRaw: string,
ignorePaths: string[],
onlyAllowPaths: string[]
): boolean {
// If onlyAllowPaths is non-empty, the file must match at least one pattern
if (onlyAllowPaths.length > 0) {
const allowMatcher = picomatch(onlyAllowPaths, { dot: true });
if (!allowMatcher(keyRaw)) return false;
}
// If ignorePaths is non-empty, the file must NOT match any pattern
if (ignorePaths.length > 0) {
const ignoreMatcher = picomatch(ignorePaths, { dot: true });
if (ignoreMatcher(keyRaw)) return false;
}
return true;
}

View file

@ -7,6 +7,7 @@ import type { BYOCPluginSettings, SyncTriggerSourceType, Entity, MixedEntity } f
import { determineSyncDecision } from "./planner";
import { generateConflictFileName } from "./conflict";
import { copyFileOrFolder } from "../copyLogic";
import { shouldSyncPath } from "./pathFilter";
// ─── Folder-aware sorter ───────────────────────────────────────────────────────
// Correct execution order to prevent parent-before-child violations:
@ -134,6 +135,17 @@ export async function syncer(
}
}
// Phase 1.5: Path Filtering
const ignorePaths = settings.ignorePaths ?? [];
const onlyAllowPaths = settings.onlyAllowPaths ?? [];
if (ignorePaths.length > 0 || onlyAllowPaths.length > 0) {
for (const [key] of nodes) {
if (!shouldSyncPath(key, ignorePaths, onlyAllowPaths)) {
nodes.delete(key);
}
}
}
// Phase 2: Planner
const unsortedActions = Array.from(nodes.values()).map(node => {
node.decision = determineSyncDecision(node, settings.conflictAction || "smart_conflict");
@ -143,16 +155,18 @@ export async function syncer(
// M1: Enforce folder-before-file creation order, file-before-folder delete order.
const syncActions = sortSyncActions(unsortedActions);
// M2: Skip protection check on empty vault (avoids division by zero/NaN).
if (localWalk.length > 0) {
// M2: Skip protection check if no valid local files are being tracked
const validLocalCount = Array.from(nodes.values()).filter(n => n.local !== undefined).length;
if (validLocalCount > 0) {
let deleteModifyCount = 0;
for (const action of syncActions) {
if (action.decision?.includes("delete") || action.decision?.includes("pull")) {
if (action.decision?.includes("delete")) {
deleteModifyCount++;
}
}
const protectErr = getProtectError(settings.protectModifyPercentage || 50, deleteModifyCount, localWalk.length);
const protectErr = getProtectError(settings.protectModifyPercentage || 50, deleteModifyCount, validLocalCount);
if (protectErr !== "") {
throw Error(`Protection Triggered: ${protectErr}`);
}

36
tests/pathFilter.test.ts Normal file
View file

@ -0,0 +1,36 @@
import { describe, it, assert } from "vitest";
import { shouldSyncPath } from "../src/sync/pathFilter";
describe("shouldSyncPath", () => {
it("allows everything when both lists are empty", () => {
assert.isTrue(shouldSyncPath("notes/foo.md", [], []));
});
it("blocks a file matching ignorePaths", () => {
assert.isFalse(shouldSyncPath("big.pdf", ["*.pdf"], []));
});
it("allows a file not matching ignorePaths", () => {
assert.isTrue(shouldSyncPath("notes/foo.md", ["*.pdf"], []));
});
it("allows a file matching onlyAllowPaths", () => {
assert.isTrue(shouldSyncPath("notes/foo.md", [], ["notes/**"]));
});
it("blocks a file not matching onlyAllowPaths", () => {
assert.isFalse(shouldSyncPath("images/bar.png", [], ["notes/**"]));
});
it("ignorePaths takes precedence over onlyAllowPaths", () => {
assert.isFalse(shouldSyncPath("notes/secret.md", ["**/secret.*"], ["notes/**"]));
});
it("supports dot-files in globs", () => {
assert.isTrue(shouldSyncPath(".obsidian/snippets/custom.css", [], [".obsidian/snippets/**"]));
});
it("blocks dot-files when ignored", () => {
assert.isFalse(shouldSyncPath(".obsidian/plugins/foo/data.json", [".obsidian/plugins/*/data.json"], []));
});
});

8
vitest.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['tests/**/*.test.ts'],
},
});