mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-22 09:03:21 +00:00
Merge main into metadata deletion reflection
This commit is contained in:
commit
3b22fd4c46
15 files changed
+410
-30
No files matched your search
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
|
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
|
||||||
|
|
||||||
|
## 1.0.32
|
||||||
|
|
||||||
|
27th September, 2026
|
||||||
|
|
||||||
|
The 1.0.31 pre-release was not promoted after validation found that a receiving device could reject encrypted CouchDB changes when Path Obfuscation was enabled. This release includes its changes and corrects that issue.
|
||||||
|
|
||||||
|
### Synchronisation and storage
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- The receiving device now accepts encrypted file information when both end-to-end encryption and Path Obfuscation are enabled. The 1.0.31 pre-release could reject this information, leaving files from another device absent from the Vault.
|
||||||
|
- Files with colons in their names now retain their full paths in synchronisation data instead of appearing as incorrectly named copies at the Vault root. (#1206)
|
||||||
|
- Obsidian may refuse to create a missing file with such a name. LiveSync also treats these names as invalid on Windows and Android, so the file may not appear in those devices' Vaults. Existing misplaced copies are left for you to review; this change does not remove them automatically.
|
||||||
|
- Received changes within the configured modification-time limit are applied to the Vault again while remediation mode is active. Changes newer than the limit remain blocked; changes arriving while a fetch makes the local database unavailable are kept for a later attempt.
|
||||||
|
- A scheduled fetch no longer offers Simple Fetch while remediation mode is active. This prevents the fetch from bypassing the modification-time limit; the detailed flow explains the restriction and offers to clear it first (#1202). Thank you to @kimjansheden for both fixes and the regression tests in PR #1208!
|
||||||
|
- On start-up, an unchanged file with a missing local revision record can be recognised before newer content arrives, avoiding an unnecessary conflict. Files with actual local edits still require conflict review. (#1207)
|
||||||
|
|
||||||
## 1.0.31
|
## 1.0.31
|
||||||
|
|
||||||
26th September, 2026
|
26th September, 2026
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-livesync",
|
"id": "obsidian-livesync",
|
||||||
"name": "Self-hosted LiveSync",
|
"name": "Self-hosted LiveSync",
|
||||||
"version": "1.0.36",
|
"version": "1.0.37",
|
||||||
"minAppVersion": "1.7.2",
|
"minAppVersion": "1.7.2",
|
||||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "1.0.36",
|
"version": "1.0.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "1.0.36",
|
"version": "1.0.37",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/apps/cli",
|
"src/apps/cli",
|
||||||
@@ -11862,7 +11862,7 @@
|
|||||||
},
|
},
|
||||||
"src/apps/cli": {
|
"src/apps/cli": {
|
||||||
"name": "self-hosted-livesync-cli",
|
"name": "self-hosted-livesync-cli",
|
||||||
"version": "1.0.36-cli",
|
"version": "1.0.37-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"minimatch": "^10.2.5",
|
"minimatch": "^10.2.5",
|
||||||
@@ -11887,7 +11887,7 @@
|
|||||||
},
|
},
|
||||||
"src/apps/webapp": {
|
"src/apps/webapp": {
|
||||||
"name": "livesync-webapp",
|
"name": "livesync-webapp",
|
||||||
"version": "1.0.36-webapp",
|
"version": "1.0.37-webapp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.55"
|
"octagonal-wheels": "^0.1.55"
|
||||||
},
|
},
|
||||||
@@ -11899,7 +11899,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/apps/webpeer": {
|
"src/apps/webpeer": {
|
||||||
"version": "1.0.36-webpeer",
|
"version": "1.0.37-webpeer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"octagonal-wheels": "^0.1.55"
|
"octagonal-wheels": "^0.1.55"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "1.0.36",
|
"version": "1.0.37",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "self-hosted-livesync-cli",
|
"name": "self-hosted-livesync-cli",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.36-cli",
|
"version": "1.0.37-cli",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "livesync-webapp",
|
"name": "livesync-webapp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.36-webapp",
|
"version": "1.0.37-webapp",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "webpeer",
|
"name": "webpeer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.36-webpeer",
|
"version": "1.0.37-webpeer",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceM
|
|||||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||||
import type { Stat, App } from "obsidian";
|
import type { Stat, App } from "obsidian";
|
||||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||||
|
import { createPathAliasGuard, type PathAliasGuard } from "./pathAliasGuard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for Obsidian
|
* Storage adapter implementation for Obsidian
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
||||||
constructor(private app: App) {}
|
constructor(
|
||||||
|
private app: App,
|
||||||
|
private readonly aliasGuard: PathAliasGuard = createPathAliasGuard(app)
|
||||||
|
) {}
|
||||||
|
|
||||||
async exists(path: string): Promise<boolean> {
|
async exists(path: string): Promise<boolean> {
|
||||||
return await this.app.vault.adapter.exists(path);
|
return await this.app.vault.adapter.exists(path);
|
||||||
@@ -25,10 +29,12 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mkdir(path: string): Promise<void> {
|
async mkdir(path: string): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(path);
|
||||||
await this.app.vault.adapter.mkdir(path);
|
await this.app.vault.adapter.mkdir(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(path: string): Promise<void> {
|
async remove(path: string): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(path);
|
||||||
await this.app.vault.adapter.remove(path);
|
await this.app.vault.adapter.remove(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +47,17 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(path);
|
||||||
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
|
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(path);
|
||||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(path);
|
||||||
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
|
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ describe("ObsidianStorageAdapter", () => {
|
|||||||
const append = vi.fn().mockResolvedValue(undefined);
|
const append = vi.fn().mockResolvedValue(undefined);
|
||||||
const app = {
|
const app = {
|
||||||
vault: {
|
vault: {
|
||||||
|
getAbstractFileByPath: () => ({}),
|
||||||
adapter: {
|
adapter: {
|
||||||
write,
|
write,
|
||||||
writeBinary,
|
writeBinary,
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceMod
|
|||||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||||
import type { TFile, App, TFolder } from "obsidian";
|
import type { TFile, App, TFolder } from "obsidian";
|
||||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||||
|
import { createPathAliasGuard, type PathAliasGuard } from "./pathAliasGuard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vault adapter implementation for Obsidian
|
* Vault adapter implementation for Obsidian
|
||||||
*/
|
*/
|
||||||
export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
|
export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
|
||||||
constructor(private app: App) {}
|
constructor(
|
||||||
|
private app: App,
|
||||||
|
private readonly aliasGuard: PathAliasGuard = createPathAliasGuard(app)
|
||||||
|
) {}
|
||||||
|
|
||||||
async read(file: TFile): Promise<string> {
|
async read(file: TFile): Promise<string> {
|
||||||
// Vault.read strips a leading UTF-8 BOM, leaving the content size inconsistent with TFile.stat.
|
// Vault.read strips a leading UTF-8 BOM, leaving the content size inconsistent with TFile.stat.
|
||||||
@@ -24,30 +28,38 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(file.path);
|
||||||
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
|
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(file.path);
|
||||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
|
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
|
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
|
||||||
|
await this.aliasGuard.assertNoAlias(path);
|
||||||
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
|
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
|
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
|
||||||
|
await this.aliasGuard.assertNoAlias(path);
|
||||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async rename(file: TFile, newPath: string): Promise<void> {
|
async rename(file: TFile, newPath: string): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(file.path);
|
||||||
|
await this.aliasGuard.assertNoAlias(newPath);
|
||||||
return await this.app.vault.rename(file, newPath);
|
return await this.app.vault.rename(file, newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(file: TFile | TFolder): Promise<void> {
|
async delete(file: TFile | TFolder): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(file.path);
|
||||||
return await this.app.fileManager.trashFile(file);
|
return await this.app.fileManager.trashFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
async trash(file: TFile | TFolder): Promise<void> {
|
async trash(file: TFile | TFolder): Promise<void> {
|
||||||
|
await this.aliasGuard.assertNoAlias(file.path);
|
||||||
return await this.app.fileManager.trashFile(file);
|
return await this.app.fileManager.trashFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe("ObsidianVaultAdapter", () => {
|
|||||||
const createBinary = vi.fn().mockResolvedValue({});
|
const createBinary = vi.fn().mockResolvedValue({});
|
||||||
const app = {
|
const app = {
|
||||||
vault: {
|
vault: {
|
||||||
|
getAbstractFileByPath: () => ({}),
|
||||||
modify,
|
modify,
|
||||||
modifyBinary,
|
modifyBinary,
|
||||||
create,
|
create,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Keeps storage from being changed through a name which the file system resolves but no directory lists.
|
||||||
|
*
|
||||||
|
* A file system may reach one entry under several names: a short name beside the long one, a name with trailing
|
||||||
|
* dots or spaces, a name with code points it ignores. Every rule which decides by the spelling of a path, such as
|
||||||
|
* the one which keeps synchronised documents out of hidden folders, would then be passed by another spelling. So
|
||||||
|
* no segment of a path which exists on disk may be addressed by a name which its parent directory does not list.
|
||||||
|
* Names are compared without regard to case and Unicode normalisation, which are the differences the supported
|
||||||
|
* file systems fold by design. A symbolic link or junction is listed under its own name and is not an alias.
|
||||||
|
*/
|
||||||
|
export interface PathAliasGuardHost {
|
||||||
|
/** Whether the vault index holds an entry under exactly this path. Such an entry was listed under that name. */
|
||||||
|
isIndexed(path: string): boolean;
|
||||||
|
/** Whether the file system resolves this path to an entry, under whatever name. */
|
||||||
|
exists(path: string): Promise<boolean>;
|
||||||
|
/** The names, or paths, of the entries which a directory lists. The vault root is the empty path. */
|
||||||
|
list(directory: string): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the names which a directory listed are taken as still listed. A name which is missed is listed afresh. */
|
||||||
|
const LISTING_LIFETIME_MS = 1_000;
|
||||||
|
|
||||||
|
const foldName = (name: string) => name.normalize("NFC").toLowerCase();
|
||||||
|
|
||||||
|
const nameOf = (entry: string) => {
|
||||||
|
const segments = entry.split("/").filter((segment) => segment !== "");
|
||||||
|
return segments[segments.length - 1] ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PathAliasGuard {
|
||||||
|
private readonly listings = new Map<string, { names: Set<string>; listedAt: number }>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly host: PathAliasGuardHost,
|
||||||
|
private readonly now: () => number = Date.now
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private recentListing(directory: string): Set<string> | undefined {
|
||||||
|
const listing = this.listings.get(directory);
|
||||||
|
if (!listing) return undefined;
|
||||||
|
if (this.now() - listing.listedAt <= LISTING_LIFETIME_MS) return listing.names;
|
||||||
|
this.listings.delete(directory);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listAfresh(directory: string): Promise<Set<string>> {
|
||||||
|
const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry))));
|
||||||
|
this.listings.set(directory, { names, listedAt: this.now() });
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse `path` when one of its segments exists on disk under a name which its parent directory does not list.
|
||||||
|
*
|
||||||
|
* Nothing is listed for a segment which the vault index holds, and nothing below the first segment which does
|
||||||
|
* not exist yet: an entry which is about to be created has no other name. Only a segment which exists outside
|
||||||
|
* the index, such as a hidden folder or one created a moment ago, has its parent listed.
|
||||||
|
*/
|
||||||
|
async assertNoAlias(path: string): Promise<void> {
|
||||||
|
const segments = path.split("/").filter((segment) => segment !== "");
|
||||||
|
let listed = 0;
|
||||||
|
for (let length = segments.length; length > 0; length--) {
|
||||||
|
if (this.host.isIndexed(segments.slice(0, length).join("/"))) {
|
||||||
|
listed = length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let index = listed; index < segments.length; index++) {
|
||||||
|
const parent = segments.slice(0, index).join("/");
|
||||||
|
const name = foldName(segments[index]);
|
||||||
|
if (this.recentListing(parent)?.has(name)) continue;
|
||||||
|
if (!(await this.host.exists(segments.slice(0, index + 1).join("/")))) return;
|
||||||
|
if (!(await this.listAfresh(parent)).has(name)) {
|
||||||
|
throw new Error("Refusing a path which reaches an existing entry by a name its folder does not list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of the Obsidian app which the guard consults. */
|
||||||
|
export interface PathAliasGuardApp {
|
||||||
|
vault: {
|
||||||
|
getAbstractFileByPath(path: string): unknown;
|
||||||
|
adapter: {
|
||||||
|
exists(path: string): Promise<boolean>;
|
||||||
|
list(path: string): Promise<{ files: string[]; folders: string[] }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPathAliasGuard(app: PathAliasGuardApp): PathAliasGuard {
|
||||||
|
return new PathAliasGuard({
|
||||||
|
isIndexed: (path) => app.vault.getAbstractFileByPath(path) != null,
|
||||||
|
exists: (path) => app.vault.adapter.exists(path),
|
||||||
|
list: async (directory) => {
|
||||||
|
const listing = await app.vault.adapter.list(directory === "" ? "/" : directory);
|
||||||
|
return [...listing.files, ...listing.folders];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmdirSync,
|
||||||
|
statSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { App, TFile } from "obsidian";
|
||||||
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||||
|
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||||
|
import { createPathAliasGuard } from "./pathAliasGuard";
|
||||||
|
|
||||||
|
const REFUSAL = "Refusing a path which reaches an existing entry by a name its folder does not list.";
|
||||||
|
const CONFIG_DIRECTORY = ".obsidian";
|
||||||
|
const PLUGIN_DIRECTORY = `${CONFIG_DIRECTORY}/plugins/obsidian-livesync`;
|
||||||
|
const PLUGIN_ENTRY = `${PLUGIN_DIRECTORY}/main.js`;
|
||||||
|
const DIRECTORY_ALIAS_ENTRY = "OBSIDI~1/plugins/obsidian-livesync/main.js";
|
||||||
|
const FILE_ALIAS_ENTRY = `${PLUGIN_DIRECTORY}/MAIN~1.JS`;
|
||||||
|
|
||||||
|
const fold = (value: string) => value.normalize("NFC").toLowerCase();
|
||||||
|
|
||||||
|
function fakeApp() {
|
||||||
|
const indexed = new Set(["notes", "notes/existing.md"].map(fold));
|
||||||
|
const existing = new Set(
|
||||||
|
[
|
||||||
|
CONFIG_DIRECTORY,
|
||||||
|
`${CONFIG_DIRECTORY}/plugins`,
|
||||||
|
PLUGIN_DIRECTORY,
|
||||||
|
PLUGIN_ENTRY,
|
||||||
|
"notes",
|
||||||
|
"notes/existing.md",
|
||||||
|
].map(fold)
|
||||||
|
);
|
||||||
|
const aliases = new Map([
|
||||||
|
[fold("OBSIDI~1"), CONFIG_DIRECTORY],
|
||||||
|
[fold(`${PLUGIN_DIRECTORY}/MAIN~1.JS`), PLUGIN_ENTRY],
|
||||||
|
]);
|
||||||
|
const resolveAlias = (path: string) => {
|
||||||
|
const direct = aliases.get(fold(path));
|
||||||
|
if (direct) return direct;
|
||||||
|
if (fold(path).startsWith("obsidi~1/")) return `${CONFIG_DIRECTORY}/${path.slice("OBSIDI~1/".length)}`;
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
const listings = new Map<string, { files: string[]; folders: string[] }>([
|
||||||
|
["/", { files: [], folders: [CONFIG_DIRECTORY, "notes"] }],
|
||||||
|
[fold(CONFIG_DIRECTORY), { files: [], folders: [`${CONFIG_DIRECTORY}/plugins`] }],
|
||||||
|
[fold(`${CONFIG_DIRECTORY}/plugins`), { files: [], folders: [PLUGIN_DIRECTORY] }],
|
||||||
|
[fold(PLUGIN_DIRECTORY), { files: [PLUGIN_ENTRY], folders: [] }],
|
||||||
|
[fold("notes"), { files: ["notes/existing.md"], folders: [] }],
|
||||||
|
]);
|
||||||
|
const calls = {
|
||||||
|
write: vi.fn().mockResolvedValue(undefined),
|
||||||
|
writeBinary: vi.fn().mockResolvedValue(undefined),
|
||||||
|
append: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||||
|
remove: vi.fn().mockResolvedValue(undefined),
|
||||||
|
modify: vi.fn().mockResolvedValue(undefined),
|
||||||
|
modifyBinary: vi.fn().mockResolvedValue(undefined),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
createBinary: vi.fn().mockResolvedValue({}),
|
||||||
|
rename: vi.fn().mockResolvedValue(undefined),
|
||||||
|
trashFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const app = {
|
||||||
|
vault: {
|
||||||
|
getAbstractFileByPath: (path: string) => (indexed.has(fold(path)) ? {} : null),
|
||||||
|
adapter: {
|
||||||
|
exists: async (path: string) => existing.has(fold(resolveAlias(path))),
|
||||||
|
list: async (path: string) =>
|
||||||
|
listings.get(path === "/" ? "/" : fold(path)) ?? { files: [], folders: [] },
|
||||||
|
read: vi.fn(),
|
||||||
|
readBinary: vi.fn(),
|
||||||
|
stat: vi.fn(),
|
||||||
|
write: calls.write,
|
||||||
|
writeBinary: calls.writeBinary,
|
||||||
|
append: calls.append,
|
||||||
|
mkdir: calls.mkdir,
|
||||||
|
remove: calls.remove,
|
||||||
|
},
|
||||||
|
cachedRead: vi.fn(),
|
||||||
|
readBinary: vi.fn(),
|
||||||
|
modify: calls.modify,
|
||||||
|
modifyBinary: calls.modifyBinary,
|
||||||
|
create: calls.create,
|
||||||
|
createBinary: calls.createBinary,
|
||||||
|
rename: calls.rename,
|
||||||
|
trigger: vi.fn(),
|
||||||
|
},
|
||||||
|
fileManager: {
|
||||||
|
trashFile: calls.trashFile,
|
||||||
|
},
|
||||||
|
} as unknown as App;
|
||||||
|
return { app, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("paths which resolve to an existing entry under an unlisted name", () => {
|
||||||
|
it("refuses every mutating storage-adapter operation", async () => {
|
||||||
|
const { app, calls } = fakeApp();
|
||||||
|
const adapter = new ObsidianStorageAdapter(app);
|
||||||
|
|
||||||
|
await expect(adapter.write(DIRECTORY_ALIAS_ENTRY, "replacement")).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.writeBinary(FILE_ALIAS_ENTRY, new ArrayBuffer(1))).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.append(FILE_ALIAS_ENTRY, "replacement")).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.mkdir("OBSIDI~1/new-directory")).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.remove(DIRECTORY_ALIAS_ENTRY)).rejects.toThrow(REFUSAL);
|
||||||
|
|
||||||
|
expect(calls.write).not.toHaveBeenCalled();
|
||||||
|
expect(calls.writeBinary).not.toHaveBeenCalled();
|
||||||
|
expect(calls.append).not.toHaveBeenCalled();
|
||||||
|
expect(calls.mkdir).not.toHaveBeenCalled();
|
||||||
|
expect(calls.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses every mutating vault-adapter operation", async () => {
|
||||||
|
const { app, calls } = fakeApp();
|
||||||
|
const adapter = new ObsidianVaultAdapter(app);
|
||||||
|
const aliasFile = { path: DIRECTORY_ALIAS_ENTRY } as TFile;
|
||||||
|
|
||||||
|
await expect(adapter.modify(aliasFile, "replacement")).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.modifyBinary(aliasFile, new ArrayBuffer(1))).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.create(DIRECTORY_ALIAS_ENTRY, "replacement")).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.createBinary(FILE_ALIAS_ENTRY, new ArrayBuffer(1))).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.rename({ path: "notes/existing.md" } as TFile, DIRECTORY_ALIAS_ENTRY)).rejects.toThrow(
|
||||||
|
REFUSAL
|
||||||
|
);
|
||||||
|
await expect(adapter.delete(aliasFile)).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.trash(aliasFile)).rejects.toThrow(REFUSAL);
|
||||||
|
|
||||||
|
expect(calls.modify).not.toHaveBeenCalled();
|
||||||
|
expect(calls.modifyBinary).not.toHaveBeenCalled();
|
||||||
|
expect(calls.create).not.toHaveBeenCalled();
|
||||||
|
expect(calls.createBinary).not.toHaveBeenCalled();
|
||||||
|
expect(calls.rename).not.toHaveBeenCalled();
|
||||||
|
expect(calls.trashFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows listed names, case-only differences, indexed paths, and new files", async () => {
|
||||||
|
const { app, calls } = fakeApp();
|
||||||
|
const adapter = new ObsidianStorageAdapter(app);
|
||||||
|
|
||||||
|
await adapter.write(`${PLUGIN_DIRECTORY}/data.json`, "settings");
|
||||||
|
await adapter.write(".OBSIDIAN/PLUGINS/OBSIDIAN-LIVESYNC/new.json", "settings");
|
||||||
|
await adapter.write("notes/new.md", "note");
|
||||||
|
await adapter.write("notes/existing.md", "updated");
|
||||||
|
|
||||||
|
expect(calls.write).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const realRoot = mkdtempSync(join(tmpdir(), "path-alias-guard-"));
|
||||||
|
const REAL_FOLDER = "configuration-folder";
|
||||||
|
const REAL_FILE = "trusted-release-keys.json";
|
||||||
|
const realFolder = join(realRoot, REAL_FOLDER);
|
||||||
|
const realFile = join(realFolder, REAL_FILE);
|
||||||
|
mkdirSync(realFolder);
|
||||||
|
writeFileSync(realFile, "synthetic trusted keys");
|
||||||
|
|
||||||
|
function removeRealFixture(): void {
|
||||||
|
const optionalPaths = [join(realFolder, "other.json"), join(realFolder, "new-directory")];
|
||||||
|
if (existsSync(optionalPaths[0])) unlinkSync(optionalPaths[0]);
|
||||||
|
if (existsSync(optionalPaths[1])) rmdirSync(optionalPaths[1]);
|
||||||
|
if (existsSync(realFile)) unlinkSync(realFile);
|
||||||
|
if (existsSync(realFolder)) rmdirSync(realFolder);
|
||||||
|
if (existsSync(realRoot)) rmdirSync(realRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(removeRealFixture);
|
||||||
|
|
||||||
|
function aliasCandidates(name: string): string[] {
|
||||||
|
const [base, extension = ""] = name.split(/\.(?=[^.]*$)/u);
|
||||||
|
const shortName = `${base.slice(0, 6).toUpperCase()}~1${extension ? `.${extension.slice(0, 3).toUpperCase()}` : ""}`;
|
||||||
|
return [shortName, `${name}.`, `${name} `];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlias(folder: string, name: string): boolean {
|
||||||
|
const directory = join(realRoot, folder);
|
||||||
|
return existsSync(join(directory, name)) && !readdirSync(directory).some((listed) => fold(listed) === fold(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const realFolderAliases = aliasCandidates(REAL_FOLDER).filter((candidate) => isAlias("", candidate));
|
||||||
|
const realAliases = [
|
||||||
|
...realFolderAliases.map((candidate) => `${candidate}/${REAL_FILE}`),
|
||||||
|
...aliasCandidates(REAL_FILE)
|
||||||
|
.filter((candidate) => isAlias(REAL_FOLDER, candidate))
|
||||||
|
.map((candidate) => `${REAL_FOLDER}/${candidate}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
function realApp() {
|
||||||
|
const full = (path: string) => join(realRoot, path === "/" ? "" : path);
|
||||||
|
return {
|
||||||
|
vault: {
|
||||||
|
getAbstractFileByPath: () => null,
|
||||||
|
adapter: {
|
||||||
|
exists: async (path: string) => existsSync(full(path)),
|
||||||
|
list: async (path: string) => {
|
||||||
|
const prefix = path === "/" || path === "" ? "" : `${path}/`;
|
||||||
|
const names = readdirSync(full(path));
|
||||||
|
const isFile = (name: string) => statSync(join(full(path), name)).isFile();
|
||||||
|
return {
|
||||||
|
files: names.filter(isFile).map((name) => `${prefix}${name}`),
|
||||||
|
folders: names.filter((name) => !isFile(name)).map((name) => `${prefix}${name}`),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
write: async (path: string, data: string) => writeFileSync(full(path), data),
|
||||||
|
remove: async (path: string) => unlinkSync(full(path)),
|
||||||
|
mkdir: async (path: string) => mkdirSync(full(path)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the host file system", () => {
|
||||||
|
it.skipIf(realAliases.length === 0)(
|
||||||
|
"refuses names which resolve but are not listed by their directory",
|
||||||
|
async () => {
|
||||||
|
const app = realApp();
|
||||||
|
const adapter = new ObsidianStorageAdapter(app as unknown as App, createPathAliasGuard(app));
|
||||||
|
|
||||||
|
for (const aliasPath of realAliases) {
|
||||||
|
expect(readFileSync(join(realRoot, aliasPath), "utf8")).toBe("synthetic trusted keys");
|
||||||
|
await expect(adapter.write(aliasPath, "replacement")).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.remove(aliasPath)).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.mkdir(`${aliasPath}/new-directory`)).rejects.toThrow(REFUSAL);
|
||||||
|
}
|
||||||
|
for (const folderAlias of realFolderAliases) {
|
||||||
|
await expect(adapter.write(`${folderAlias}/new.json`, "replacement")).rejects.toThrow(REFUSAL);
|
||||||
|
await expect(adapter.mkdir(`${folderAlias}/new-directory`)).rejects.toThrow(REFUSAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(readFileSync(realFile, "utf8")).toBe("synthetic trusted keys");
|
||||||
|
expect(existsSync(join(realFolder, "new.json"))).toBe(false);
|
||||||
|
expect(existsSync(join(realFolder, "new-directory"))).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
+14
-17
@@ -19,6 +19,14 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
- We can synchronise file deletions and renames when **Do not keep metadata of deleted files.** is enabled.
|
- We can synchronise file deletions and renames when **Do not keep metadata of deleted files.** is enabled.
|
||||||
- A file restored during the final deletion check remains in the receiving Vault.
|
- A file restored during the final deletion check remains in the receiving Vault.
|
||||||
|
|
||||||
|
## 1.0.37
|
||||||
|
|
||||||
|
16th October, 2026
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fixed an issue where synchronised changes could reach files outside the intended synchronisation scope under certain conditions. Updating is recommended.
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
@@ -30,6 +38,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
- We can select CLI-only Docker patches by their full release tag, such as `1.0.36-patch1-cli`, while keeping the Obsidian plug-in version.
|
- We can select CLI-only Docker patches by their full release tag, such as `1.0.36-patch1-cli`, while keeping the Obsidian plug-in version.
|
||||||
- Patch and RC images receive version and SHA-qualified tags. The `latest` and major-minor tags remain reserved for ordinary stable releases.
|
- Patch and RC images receive version and SHA-qualified tags. The `latest` and major-minor tags remain reserved for ordinary stable releases.
|
||||||
|
|
||||||
|
### Acknowledgements
|
||||||
|
|
||||||
|
Thank you for your contributions!
|
||||||
|
|
||||||
|
- @kimjansheden, for reporting the issue and contributing the fix and regression tests.
|
||||||
|
|
||||||
## 1.0.36
|
## 1.0.36
|
||||||
|
|
||||||
13th October, 2026
|
13th October, 2026
|
||||||
@@ -180,20 +194,3 @@ Thank you for your contributions!
|
|||||||
### Issue replies
|
### Issue replies
|
||||||
|
|
||||||
I am a little behind on replying to issues, but I am reading them and will respond as I work through them. I have had little uninterrupted time recently, and that should improve soon.
|
I am a little behind on replying to issues, but I am reading them and will respond as I work through them. I have had little uninterrupted time recently, and that should improve soon.
|
||||||
|
|
||||||
## 1.0.32
|
|
||||||
|
|
||||||
27th September, 2026
|
|
||||||
|
|
||||||
The 1.0.31 pre-release was not promoted after validation found that a receiving device could reject encrypted CouchDB changes when Path Obfuscation was enabled. This release includes its changes and corrects that issue.
|
|
||||||
|
|
||||||
### Synchronisation and storage
|
|
||||||
|
|
||||||
#### Fixed
|
|
||||||
|
|
||||||
- The receiving device now accepts encrypted file information when both end-to-end encryption and Path Obfuscation are enabled. The 1.0.31 pre-release could reject this information, leaving files from another device absent from the Vault.
|
|
||||||
- Files with colons in their names now retain their full paths in synchronisation data instead of appearing as incorrectly named copies at the Vault root. (#1206)
|
|
||||||
- Obsidian may refuse to create a missing file with such a name. LiveSync also treats these names as invalid on Windows and Android, so the file may not appear in those devices' Vaults. Existing misplaced copies are left for you to review; this change does not remove them automatically.
|
|
||||||
- Received changes within the configured modification-time limit are applied to the Vault again while remediation mode is active. Changes newer than the limit remain blocked; changes arriving while a fetch makes the local database unavailable are kept for a later attempt.
|
|
||||||
- A scheduled fetch no longer offers Simple Fetch while remediation mode is active. This prevents the fetch from bypassing the modification-time limit; the detailed flow explains the restriction and offers to clear it first (#1202). Thank you to @kimjansheden for both fixes and the regression tests in PR #1208!
|
|
||||||
- On start-up, an unchanged file with a missing local revision record can be recognised before newer content arrives, avoiding an unnecessary conflict. Files with actual local edits still require conflict review. (#1207)
|
|
||||||
+2
-1
@@ -47,5 +47,6 @@
|
|||||||
"1.0.33": "1.7.2",
|
"1.0.33": "1.7.2",
|
||||||
"1.0.34": "1.7.2",
|
"1.0.34": "1.7.2",
|
||||||
"1.0.35": "1.7.2",
|
"1.0.35": "1.7.2",
|
||||||
"1.0.36": "1.7.2"
|
"1.0.36": "1.7.2",
|
||||||
|
"1.0.37": "1.7.2"
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user