Compare commits

...

9 Commits

Author SHA1 Message Date
vorotamoroz 9265a74578 Merge pull request #1007 from apple-ouyang/codex/cache-cli-ignore-matchers
Compile CLI ignore patterns once
2026-07-13 13:26:21 +09:00
Ouyang Xingyuan f28d886603 perf(cli): compile ignore patterns once
Long-running CLI processes rebuilt the same minimatch ASTs for every checked path.

Compile patterns while loading ignore rules and reuse them until the rules are reloaded.

Refs #1006
2026-07-13 10:47:35 +08:00
vorotamoroz 9c375bd6fd Deploy QR aggregator with GitHub Pages 2026-07-12 21:06:56 +09:00
vorotamoroz 42a333a571 Record P2P validation infrastructure 2026-07-12 20:40:38 +09:00
vorotamoroz a6a5f7af53 Use merged storage capability contracts 2026-07-12 20:40:38 +09:00
vorotamoroz 4df87cc96a Explain portable storage path policy 2026-07-12 20:40:38 +09:00
vorotamoroz d16e66c67a Track unreleased storage contract changes 2026-07-12 20:40:38 +09:00
vorotamoroz 57e26f1079 Enforce rooted storage path contracts 2026-07-12 20:40:38 +09:00
vorotamoroz dbb8d2be22 test: establish storage adapter contracts 2026-07-12 20:40:38 +09:00
12 changed files with 396 additions and 30 deletions
+67
View File
@@ -0,0 +1,67 @@
name: Deploy GitHub Pages
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'aggregator.html'
- '.github/workflows/deploy-pages.yml'
pull_request:
paths:
- 'aggregator.html'
- '.github/workflows/deploy-pages.yml'
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Validate and package Pages site
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate aggregator
run: |
test -s aggregator.html
grep -Fq '<!DOCTYPE html>' aggregator.html
grep -Fq 'obsidian://setuplivesync?settingsQR=' aggregator.html
sed -n '/<script>/,/<\/script>/p' aggregator.html | sed '1d;$d' > aggregator.js
node --check aggregator.js
rm aggregator.js
- name: Prepare Pages site
run: |
mkdir -p _site
cp aggregator.html _site/aggregator.html
touch _site/.nojekyll
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: _site
deploy:
name: Deploy GitHub Pages
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+6
View File
@@ -47,6 +47,12 @@ npm test # Run Harness based vitest tests (requires Docker services)
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness. Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
### Unreleased change notes
Keep changes that may belong in a future release under `## Unreleased` at the top of `updates.md` when they do not justify an immediate release. Do not add a date to this virtual version. Move relevant entries under the real version and ordinal release date when preparing that release, then leave an empty `## Unreleased` section for subsequent work.
Use this section for durable release-note candidates, including compatibility-relevant internal maintenance, rather than tasks, local diagnostics, or implementation journals. Categorise user-visible behaviour separately from internal changes and testing.
### Auto-copy to test vaults ### Auto-copy to test vaults
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
+108
View File
@@ -0,0 +1,108 @@
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
/** One platform-neutral storage adapter contract case. */
export interface StorageAdapterContractCase {
readonly name: string;
run(adapter: IStorageAdapter): Promise<void>;
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`);
}
}
async function assertRejects(operation: () => Promise<unknown>, message: string): Promise<void> {
try {
await operation();
} catch {
return;
}
throw new Error(message);
}
/** Passing baseline shared by Node, FSAPI, and future storage adapters. */
export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [
{
name: "reports missing paths consistently",
async run(adapter) {
assertEqual(await adapter.exists("missing.txt"), false, "missing path should not exist");
assertEqual(await adapter.stat("missing.txt"), null, "missing stat should be null");
assertEqual(await adapter.trystat("missing.txt"), null, "missing trystat should be null");
},
},
{
name: "creates parent directories for nested text writes",
async run(adapter) {
await adapter.write("notes/nested/note.md", "hello");
assertEqual(await adapter.read("notes/nested/note.md"), "hello", "text should round-trip");
assert(await adapter.exists("notes/nested/note.md"), "written text path should exist");
assertEqual((await adapter.stat("notes/nested/note.md"))?.type, "file", "written path should be a file");
},
},
{
name: "round-trips exact binary bytes",
async run(adapter) {
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
await adapter.writeBinary("binary/blob.bin", expected.buffer.slice(0));
const result = await adapter.readBinary("binary/blob.bin");
assertEqual([...new Uint8Array(result)], [...expected], "binary data should round-trip exactly");
assertEqual(result.byteLength, expected.byteLength, "binary result should have the exact visible length");
},
},
{
name: "creates and extends text through append",
async run(adapter) {
await adapter.append("logs/events.log", "first");
await adapter.append("logs/events.log", ":second");
assertEqual(await adapter.read("logs/events.log"), "first:second", "append should create then extend text");
},
},
{
name: "lists direct files and folders",
async run(adapter) {
await adapter.mkdir("listing/folder");
await adapter.write("listing/file.txt", "content");
const listed = await adapter.list("listing");
assertEqual([...listed.files].sort(), ["listing/file.txt"], "list should contain the direct file");
assertEqual([...listed.folders].sort(), ["listing/folder"], "list should contain the direct folder");
},
},
{
name: "removes files and directory trees",
async run(adapter) {
await adapter.write("remove/file.txt", "content");
await adapter.write("remove/folder/nested.txt", "content");
await adapter.remove("remove/file.txt");
assertEqual(await adapter.exists("remove/file.txt"), false, "file should be removed");
await adapter.remove("remove/folder");
assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed");
},
},
{
name: "keeps operations inside the configured root",
async run(adapter) {
await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected");
await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected");
await assertRejects(() => adapter.read("/absolute"), "absolute paths should be rejected");
await assertRejects(() => adapter.read("C:\\absolute"), "drive-qualified paths should be rejected");
await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected");
await assertRejects(() => adapter.remove(""), "removing the configured root should be rejected");
},
},
{
name: "uses the empty path only for root-safe operations",
async run(adapter) {
await adapter.mkdir("");
assertEqual(await adapter.exists(""), true, "the configured root should exist");
assertEqual((await adapter.stat(""))?.type, "folder", "the configured root should be a folder");
assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable");
await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected");
await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected");
},
},
];
+14 -11
View File
@@ -2,20 +2,22 @@ import type { UXDataWriteOptions } from "@lib/common/types";
import type { IStorageAdapter } from "@lib/serviceModules/adapters"; import type { IStorageAdapter } from "@lib/serviceModules/adapters";
import type { NodeStat } from "./NodeTypes"; import type { NodeStat } from "./NodeTypes";
import { fsPromises as fs, path } from "@/apps/cli/node-compat"; import { fsPromises as fs, path } from "@/apps/cli/node-compat";
import { validateStoragePath } from "@/apps/storagePath";
/** /**
* Storage adapter implementation for Node.js * Storage adapter implementation for Node.js
*/ */
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> { export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
constructor(private basePath: string) {} constructor(private readonly basePath: string) {}
private resolvePath(p: string): string { private resolvePath(p: string, allowRoot: boolean = true): string {
return path.join(this.basePath, p); return path.join(this.basePath, validateStoragePath(p, allowRoot));
} }
async exists(p: string): Promise<boolean> { async exists(p: string): Promise<boolean> {
const fullPath = this.resolvePath(p);
try { try {
await fs.access(this.resolvePath(p)); await fs.access(fullPath);
return true; return true;
} catch { } catch {
return false; return false;
@@ -23,8 +25,9 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
} }
async trystat(p: string): Promise<NodeStat | null> { async trystat(p: string): Promise<NodeStat | null> {
const fullPath = this.resolvePath(p);
try { try {
const stat = await fs.stat(this.resolvePath(p)); const stat = await fs.stat(fullPath);
return { return {
size: stat.size, size: stat.size,
mtime: Math.floor(stat.mtimeMs), mtime: Math.floor(stat.mtimeMs),
@@ -45,7 +48,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
} }
async remove(p: string): Promise<void> { async remove(p: string): Promise<void> {
const fullPath = this.resolvePath(p); const fullPath = this.resolvePath(p, false);
const stat = await fs.stat(fullPath); const stat = await fs.stat(fullPath);
if (stat.isDirectory()) { if (stat.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force: true }); await fs.rm(fullPath, { recursive: true, force: true });
@@ -55,17 +58,17 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
} }
async read(p: string): Promise<string> { async read(p: string): Promise<string> {
return await fs.readFile(this.resolvePath(p), "utf-8"); return await fs.readFile(this.resolvePath(p, false), "utf-8");
} }
async readBinary(p: string): Promise<ArrayBuffer> { async readBinary(p: string): Promise<ArrayBuffer> {
const buffer = await fs.readFile(this.resolvePath(p)); const buffer = await fs.readFile(this.resolvePath(p, false));
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
} }
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> { async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p); const fullPath = this.resolvePath(p, false);
await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, data, "utf-8"); await fs.writeFile(fullPath, data, "utf-8");
@@ -77,7 +80,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
} }
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> { async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p); const fullPath = this.resolvePath(p, false);
await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, new Uint8Array(data)); await fs.writeFile(fullPath, new Uint8Array(data));
@@ -89,7 +92,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
} }
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> { async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p); const fullPath = this.resolvePath(p, false);
await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.appendFile(fullPath, data, "utf-8"); await fs.appendFile(fullPath, data, "utf-8");
@@ -2,9 +2,10 @@ import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
import { NodeStorageAdapter } from "./NodeStorageAdapter"; import { NodeStorageAdapter } from "./NodeStorageAdapter";
describe("NodeStorageAdapter binary I/O", () => { describe("NodeStorageAdapter", () => {
const tempDirs: string[] = []; const tempDirs: string[] = [];
async function createAdapter() { async function createAdapter() {
@@ -17,6 +18,12 @@ describe("NodeStorageAdapter binary I/O", () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
}); });
for (const contractCase of storageAdapterContractCases) {
it(contractCase.name, async () => {
await contractCase.run(await createAdapter());
});
}
it("writes and reads binary data without corruption", async () => { it("writes and reads binary data without corruption", async () => {
const adapter = await createAdapter(); const adapter = await createAdapter();
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]); const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
+4 -4
View File
@@ -1,4 +1,4 @@
import { minimatch } from "minimatch"; import { Minimatch } from "minimatch";
import { fsPromises as fs, path } from "@/apps/cli/node-compat"; import { fsPromises as fs, path } from "@/apps/cli/node-compat";
/** /**
@@ -17,7 +17,7 @@ import { fsPromises as fs, path } from "@/apps/cli/node-compat";
* Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped. * Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped.
*/ */
export class IgnoreRules { export class IgnoreRules {
private patterns: string[] = []; private patterns: Minimatch[] = [];
constructor(private vaultPath: string) {} constructor(private vaultPath: string) {}
@@ -108,7 +108,7 @@ export class IgnoreRules {
`Remove it from .livesync/ignore or use a separate include/exclude file.` `Remove it from .livesync/ignore or use a separate include/exclude file.`
); );
} }
this.patterns.push(this._normalisePattern(raw)); this.patterns.push(new Minimatch(this._normalisePattern(raw), { dot: true }));
} }
/** /**
@@ -124,6 +124,6 @@ export class IgnoreRules {
} }
// Normalise to forward slashes for minimatch. // Normalise to forward slashes for minimatch.
const normalised = relativePath.replace(/\\/g, "/"); const normalised = relativePath.replace(/\\/g, "/");
return this.patterns.some((p) => minimatch(normalised, p, { dot: true })); return this.patterns.some((pattern) => pattern.match(normalised));
} }
} }
@@ -1,7 +1,28 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const minimatchStats = vi.hoisted(() => ({ constructions: 0 }));
vi.mock("minimatch", async (importOriginal) => {
const actual = await importOriginal<typeof import("minimatch")>();
class CountingMinimatch extends actual.Minimatch {
constructor(pattern: string, options?: import("minimatch").MinimatchOptions) {
super(pattern, options);
minimatchStats.constructions++;
}
}
return {
...actual,
Minimatch: CountingMinimatch,
minimatch: (path: string, pattern: string, options?: import("minimatch").MinimatchOptions) =>
new CountingMinimatch(pattern, options).match(path),
};
});
import { IgnoreRules } from "./IgnoreRules"; import { IgnoreRules } from "./IgnoreRules";
describe("IgnoreRules", () => { describe("IgnoreRules", () => {
@@ -19,6 +40,10 @@ describe("IgnoreRules", () => {
await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8"); await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8");
} }
beforeEach(() => {
minimatchStats.constructions = 0;
});
afterEach(async () => { afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
}); });
@@ -55,6 +80,20 @@ describe("IgnoreRules", () => {
}); });
describe("shouldIgnore", () => { describe("shouldIgnore", () => {
it("compiles loaded patterns once", async () => {
const vaultPath = await createVault();
await writeIgnoreFile(vaultPath, "*.tmp\nbuild/\n");
const rules = new IgnoreRules(vaultPath);
await rules.load();
expect(rules.shouldIgnore("notes/readme.md")).toBe(false);
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
expect(rules.shouldIgnore("build/output.js")).toBe(true);
expect(rules.shouldIgnore("other.md")).toBe(false);
expect(minimatchStats.constructions).toBe(2);
});
it("matches **/*.tmp against notes/scratch.tmp", async () => { it("matches **/*.tmp against notes/scratch.tmp", async () => {
const vaultPath = await createVault(); const vaultPath = await createVault();
await writeIgnoreFile(vaultPath, "*.tmp\n"); await writeIgnoreFile(vaultPath, "*.tmp\n");
+28
View File
@@ -0,0 +1,28 @@
/**
* Validate the platform-neutral path vocabulary used by rooted storage adapters.
*
* Paths are slash-separated and relative to the root bound to the adapter. Root
* selection and authorisation happen before the adapter is constructed.
*/
export function validateStoragePath(storagePath: string, allowRoot: boolean = true): string {
if (storagePath === "") {
if (allowRoot) return storagePath;
throw new Error("The storage root is not a valid entry path");
}
// LiveSync normally rejects ':' in portable filenames before paths reach storage. A leading letter and colon
// therefore represents drive-qualified input or a leaked non-storage namespace, not a supported physical path.
if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) {
throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`);
}
if (storagePath.includes("\\")) {
throw new Error(`Storage paths must use forward slashes: ${storagePath}`);
}
const segments = storagePath.split("/");
if (segments.some((segment) => segment === "." || segment === "..")) {
throw new Error(`Storage paths must not contain traversal segments: ${storagePath}`);
}
return storagePath;
}
+23 -12
View File
@@ -1,12 +1,13 @@
import type { UXDataWriteOptions } from "@lib/common/types"; import type { UXDataWriteOptions } from "@lib/common/types";
import type { IStorageAdapter } from "@lib/serviceModules/adapters"; import type { IStorageAdapter } from "@lib/serviceModules/adapters";
import type { FSAPIStat } from "./FSAPITypes"; import type { FSAPIStat } from "./FSAPITypes";
import { validateStoragePath } from "@/apps/storagePath";
/** /**
* Storage adapter implementation for FileSystem API * Storage adapter implementation for FileSystem API
*/ */
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> { export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
constructor(private rootHandle: FileSystemDirectoryHandle) {} constructor(private readonly rootHandle: FileSystemDirectoryHandle) {}
/** /**
* Resolve a path to directory and file handles * Resolve a path to directory and file handles
@@ -15,11 +16,9 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
dirHandle: FileSystemDirectoryHandle; dirHandle: FileSystemDirectoryHandle;
fileName: string; fileName: string;
} | null> { } | null> {
validateStoragePath(p, false);
try { try {
const parts = p.split("/").filter((part) => part !== ""); const parts = p.split("/").filter((part) => part !== "");
if (parts.length === 0) {
return null;
}
let currentHandle = this.rootHandle; let currentHandle = this.rootHandle;
const fileName = parts[parts.length - 1]; const fileName = parts[parts.length - 1];
@@ -39,6 +38,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
* Get file handle for a given path * Get file handle for a given path
*/ */
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> { private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
validateStoragePath(p);
if (p === "") return null;
const resolved = await this.resolvePath(p); const resolved = await this.resolvePath(p);
if (!resolved) return null; if (!resolved) return null;
@@ -53,6 +54,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
* Get directory handle for a given path * Get directory handle for a given path
*/ */
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> { private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
validateStoragePath(p);
try { try {
const parts = p.split("/").filter((part) => part !== ""); const parts = p.split("/").filter((part) => part !== "");
if (parts.length === 0) { if (parts.length === 0) {
@@ -70,6 +72,20 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
} }
} }
/** Resolve a writable file path after creating its parent directories. */
private async resolveWritablePath(p: string): Promise<{
dirHandle: FileSystemDirectoryHandle;
fileName: string;
} | null> {
validateStoragePath(p, false);
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts.pop()!;
const parentPath = parts.join("/");
await this.mkdir(parentPath);
const dirHandle = await this.getDirectoryHandle(parentPath);
return dirHandle ? { dirHandle, fileName } : null;
}
async exists(p: string): Promise<boolean> { async exists(p: string): Promise<boolean> {
const fileHandle = await this.getFileHandle(p); const fileHandle = await this.getFileHandle(p);
if (fileHandle) return true; if (fileHandle) return true;
@@ -110,6 +126,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
} }
async mkdir(p: string): Promise<void> { async mkdir(p: string): Promise<void> {
validateStoragePath(p);
const parts = p.split("/").filter((part) => part !== ""); const parts = p.split("/").filter((part) => part !== "");
let currentHandle = this.rootHandle; let currentHandle = this.rootHandle;
@@ -146,14 +163,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
} }
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> { async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const resolved = await this.resolvePath(p); const resolved = await this.resolveWritablePath(p);
if (!resolved) { if (!resolved) {
throw new Error(`Invalid path: ${p}`); throw new Error(`Invalid path: ${p}`);
} }
// Ensure parent directory exists
await this.mkdir(p.split("/").slice(0, -1).join("/"));
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
const writable = await fileHandle.createWritable(); const writable = await fileHandle.createWritable();
await writable.write(data); await writable.write(data);
@@ -161,14 +175,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
} }
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> { async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const resolved = await this.resolvePath(p); const resolved = await this.resolveWritablePath(p);
if (!resolved) { if (!resolved) {
throw new Error(`Invalid path: ${p}`); throw new Error(`Invalid path: ${p}`);
} }
// Ensure parent directory exists
await this.mkdir(p.split("/").slice(0, -1).join("/"));
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
const writable = await fileHandle.createWritable(); const writable = await fileHandle.createWritable();
await writable.write(data); await writable.write(data);
@@ -0,0 +1,81 @@
import { describe, it } from "vitest";
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
class MemoryFileHandle {
readonly kind = "file";
private data = new Uint8Array();
constructor(readonly name: string) {}
async getFile(): Promise<File> {
return new File([this.data], this.name, { lastModified: 1 });
}
async createWritable(): Promise<FileSystemWritableFileStream> {
const handle = this;
return {
async write(data: FileSystemWriteChunkType) {
if (typeof data === "string") {
handle.data = new TextEncoder().encode(data);
} else if (data instanceof ArrayBuffer) {
handle.data = new Uint8Array(data.slice(0));
} else if (ArrayBuffer.isView(data)) {
handle.data = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
} else {
throw new TypeError("Unsupported in-memory write type");
}
},
async close() {},
} as FileSystemWritableFileStream;
}
}
class MemoryDirectoryHandle {
readonly kind = "directory";
private readonly children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
constructor(readonly name: string) {}
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
const existing = this.children.get(name);
if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle;
if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError");
const directory = new MemoryDirectoryHandle(name);
this.children.set(name, directory);
return directory as unknown as FileSystemDirectoryHandle;
}
async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle> {
const existing = this.children.get(name);
if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle;
if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError");
const file = new MemoryFileHandle(name);
this.children.set(name, file);
return file as unknown as FileSystemFileHandle;
}
async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<void> {
const existing = this.children.get(name);
if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError");
if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) {
throw new DOMException("Directory is not empty", "InvalidModificationError");
}
this.children.delete(name);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
for (const [name, entry] of this.children) {
yield [name, entry as unknown as FileSystemHandle];
}
}
}
describe("FSAPIStorageAdapter", () => {
for (const contractCase of storageAdapterContractCases) {
it(contractCase.name, async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
await contractCase.run(new FSAPIStorageAdapter(root));
});
}
});
+1 -1
Submodule src/lib updated: a0efb7274e...1cb156d463
+16
View File
@@ -3,6 +3,22 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## Unreleased
### Improved (CLI and Webapp)
- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself.
- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour.
### Testing
- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling.
- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations.
### Miscellaneous
- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour.
## 0.25.80 ## 0.25.80
7th July, 2026 7th July, 2026