sotashimozono_obsidian-remo.../plugin/tests/SftpDataAdapter.test.ts
Souta 49e48a992e fix(adapter,shadow): stale reads on same-second edits; uninstall now propagates
Two product defects the new E2E matrix pinned red. Both fixed; both tests go
green off the product, not off a weakened assertion.

1. STALE READ (cache-pressure.spec). `readBuffer` revalidated a cached read on
   `s.mtime === cached.mtime` ALONE and never compared the size — though the
   very same `stat` returns it, and uses it two lines later to size the
   transfer. SFTP reports mtime at 1-SECOND resolution
   (`SftpClient`: `mtime: stats.mtime * 1000`), so two edits inside one
   wall-clock second collapse onto the same value: the user keeps reading a
   version that no longer exists on the server, then saves it back over the
   real one. Not theoretical — `reflect.spec.ts` sleeps 1.1 s between remote
   edits to stay green, which is this bug wearing a workaround.

   Now revalidates on mtime AND size. A same-second edit that preserves the
   byte length is still invisible (that needs a content hash or a server-side
   change counter); this closes the common case for free. Verified the guard is
   load-bearing: disabling the size check makes the new unit test fail.

2. UNINSTALL NEVER PROPAGATED (plugin-code-roundtrip.spec). Both
   pullCommunityPlugins and pushCommunityPlugins used a monotonic UNION, so
   `community-plugins.json` could only ever GROW: a local uninstall never
   reached the remote, and the next connect's pull RESURRECTED it. The user
   could not uninstall anything, ever.

   A union cannot tell "I never had it" from "I removed it", so `mergePluginIds`
   is now a 3-WAY merge against a per-device BASE — the list as it stood at the
   end of the last successful sync on this device. Base lives at
   `~/.obsidian-remote/state/<profile-id>/community-plugins.base.json`,
   deliberately OUTSIDE every vault so writeThroughConfig never mirrors it and
   PathMapper never redirects it.

   - No base yet -> degrades exactly to the old union (first-run fallback:
     removals can't be inferred, but nothing is lost).
   - Only the PUSH commits a base, and only once both sides hold the converged
     list — a base written by preSpawnPull's pull would make the later connect
     read `base \ remote` as a remote removal of this device's own additions.
     Never committing on pull also makes pre-spawn + connect idempotent.
   - An unreadable or ABSENT remote contributes NO removals — "absent" must
     never be read as "everything was uninstalled elsewhere".
   - remote-ssh is never removable from either side.
   - Tie-break is ADD WINS: re-uninstalling is one click; silently losing a
     plugin you just installed is invisible data loss.
   - A first bootstrap DISCARDS a stale base: deleting the shadow vault and
     reconnecting is the documented recovery step, and without this the first
     push would have stripped the user's entire enabled-plugin list off the
     remote.

Full suite: 1238 passed (was 1227), no regressions.
2026-07-14 21:41:21 +09:00

1703 lines
72 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SftpDataAdapter } from '../src/adapter/SftpDataAdapter';
import { ReadCache } from '../src/cache/ReadCache';
import { DirCache } from '../src/cache/DirCache';
import { PathMapper } from '../src/path/PathMapper';
import { AncestorTracker } from '../src/conflict/AncestorTracker';
import { ConflictResolver } from '../src/conflict/ConflictResolver';
import { OfflineQueue } from '../src/offline/OfflineQueue';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { RemoteEntry, RemoteStat } from '../src/types';
/** Minimal stand-in for SftpClient covering both read- and write-side calls. */
function makeFakeClient(initial: {
files?: Record<string, { data: Buffer; mtime: number }>;
dirs?: Record<string, RemoteEntry[]>;
} = {}) {
const files: Record<string, { data: Buffer; mtime: number }> = { ...(initial.files ?? {}) };
const dirs: Record<string, RemoteEntry[]> = { ...(initial.dirs ?? {}) };
let clock = 1000;
const tick = () => ++clock;
const dirEntry = (name: string, isDirectory: boolean): RemoteEntry => ({
name, isDirectory,
isFile: !isDirectory,
isSymbolicLink: false,
mtime: 0, size: 0,
});
/** Walk parent dirs and add `name` to each parent's dir listing. */
const linkIntoParent = (fullPath: string, isDirectory: boolean): void => {
const i = fullPath.lastIndexOf('/');
if (i <= 0) return;
const parent = fullPath.slice(0, i) || '/';
const name = fullPath.slice(i + 1);
if (!(parent in dirs)) dirs[parent] = [];
const existingIdx = dirs[parent].findIndex(e => e.name === name);
if (existingIdx >= 0) dirs[parent][existingIdx] = dirEntry(name, isDirectory);
else dirs[parent].push(dirEntry(name, isDirectory));
};
const unlinkFromParent = (fullPath: string): void => {
const i = fullPath.lastIndexOf('/');
if (i <= 0) return;
const parent = fullPath.slice(0, i) || '/';
const name = fullPath.slice(i + 1);
if (!(parent in dirs)) return;
dirs[parent] = dirs[parent].filter(e => e.name !== name);
};
const stat = vi.fn(async (p: string): Promise<RemoteStat> => {
if (files[p]) {
const f = files[p];
return {
isDirectory: false, isFile: true, isSymbolicLink: false,
mtime: f.mtime, size: f.data.byteLength, mode: 0o100644,
};
}
if (dirs[p]) {
return {
isDirectory: true, isFile: false, isSymbolicLink: false,
mtime: 0, size: 0, mode: 0o040755,
};
}
throw new Error('No such file');
});
const exists = vi.fn(async (p: string): Promise<boolean> => {
return p in files || p in dirs;
});
const list = vi.fn(async (p: string): Promise<RemoteEntry[]> => {
if (!(p in dirs)) throw new Error(`No such dir: ${p}`);
return dirs[p];
});
const readBinary = vi.fn(async (p: string): Promise<Buffer> => {
if (!(p in files)) throw new Error(`No such file: ${p}`);
return files[p].data;
});
const writeBinary = vi.fn(async (p: string, data: Buffer): Promise<void> => {
files[p] = { data: Buffer.from(data), mtime: tick() };
linkIntoParent(p, false);
});
const mkdirp = vi.fn(async (p: string): Promise<void> => {
if (!p) return;
const parts = p.split('/').filter(Boolean);
const isAbs = p.startsWith('/');
let current = '';
for (const part of parts) {
current = isAbs ? current + '/' + part : (current ? current + '/' + part : part);
if (!(current in dirs)) {
dirs[current] = [];
linkIntoParent(current, true);
}
}
});
const rename = vi.fn(async (oldPath: string, newPath: string): Promise<void> => {
if (oldPath in files) {
files[newPath] = files[oldPath];
delete files[oldPath];
unlinkFromParent(oldPath);
linkIntoParent(newPath, false);
} else if (oldPath in dirs) {
dirs[newPath] = dirs[oldPath];
delete dirs[oldPath];
unlinkFromParent(oldPath);
linkIntoParent(newPath, true);
} else {
throw new Error(`rename: no such path "${oldPath}"`);
}
});
const copy = vi.fn(async (src: string, dst: string): Promise<void> => {
if (!(src in files)) throw new Error(`copy: no such file "${src}"`);
files[dst] = { data: Buffer.from(files[src].data), mtime: tick() };
linkIntoParent(dst, false);
});
const remove = vi.fn(async (p: string): Promise<void> => {
if (!(p in files)) throw new Error(`remove: no such file "${p}"`);
delete files[p];
unlinkFromParent(p);
});
const rmdir = vi.fn(async (p: string, recursive = false): Promise<void> => {
if (!(p in dirs)) throw new Error(`rmdir: no such dir "${p}"`);
if (recursive) {
const prefix = p.endsWith('/') ? p : p + '/';
for (const f of Object.keys(files)) {
if (f === p || f.startsWith(prefix)) { delete files[f]; }
}
for (const d of Object.keys(dirs)) {
if (d !== p && (d === p || d.startsWith(prefix))) { delete dirs[d]; }
}
} else if (dirs[p].length > 0) {
throw new Error('rmdir: not empty');
}
delete dirs[p];
unlinkFromParent(p);
});
return {
files, dirs,
client: {
stat, exists, list, readBinary,
writeBinary, mkdirp, rename, copy, remove, rmdir,
} as unknown as import('../src/ssh/SftpClient').SftpClient,
spies: { stat, exists, list, readBinary, writeBinary, mkdirp, rename, copy, remove, rmdir },
};
}
const e = (name: string, isDirectory = false): RemoteEntry => ({
name, isDirectory,
isFile: !isDirectory,
isSymbolicLink: false,
mtime: 1000,
size: 0,
});
describe('SftpDataAdapter (read-side)', () => {
let readCache: ReadCache;
let dirCache: DirCache;
beforeEach(() => {
readCache = new ReadCache({ maxBytes: 1024 });
dirCache = new DirCache({ ttlMs: 10000 });
});
describe('getName / toRemote', () => {
it('returns the configured vault name', () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(fake.client, '/srv/vault', readCache, dirCache, 'TestVault');
expect(adapter.getName()).toBe('TestVault');
});
it('exposes the shadow base path via the basePath getter and getBasePath()', () => {
// #170: when patched onto FileSystemAdapter, both the property
// and the method form must return the shadow vault's local
// root so plugins (Templater / Kanban / Importer / Copilot)
// join their fs.* calls against a real local mirror.
const fake = makeFakeClient();
const shadow = '/Users/me/.obsidian-remote/vaults/abc/';
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
null, null, null, null, null,
shadow,
);
expect(adapter.basePath).toBe(shadow);
expect(adapter.getBasePath()).toBe(shadow);
});
it('basePath / getBasePath default to "" when not provided (test-friendly fallback)', () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(fake.client, '/srv/vault', readCache, dirCache, 'v');
expect(adapter.basePath).toBe('');
expect(adapter.getBasePath()).toBe('');
});
it('joins normalized path under the remote base', () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(fake.client, '/srv/vault', readCache, dirCache, 'v');
expect(adapter.toRemote('')).toBe('/srv/vault');
expect(adapter.toRemote('/')).toBe('/srv/vault');
expect(adapter.toRemote('foo.md')).toBe('/srv/vault/foo.md');
expect(adapter.toRemote('dir/sub/note.md')).toBe('/srv/vault/dir/sub/note.md');
});
it('handles a home-relative base ("work/VaultDev")', () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(fake.client, 'work/VaultDev', readCache, dirCache, 'v');
expect(adapter.toRemote('foo.md')).toBe('work/VaultDev/foo.md');
expect(adapter.toRemote('')).toBe('work/VaultDev');
});
it('handles a root base ("/")', () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(fake.client, '/', readCache, dirCache, 'v');
expect(adapter.toRemote('foo.md')).toBe('/foo.md');
expect(adapter.toRemote('')).toBe('/');
});
it('handles an empty base ("") — used for RPC mode where the daemon already knows the vault root', () => {
// In RPC mode the Go daemon's `--vault-root` provides the
// absolute prefix; the client must send vault-relative paths to
// avoid a double-prefix at the daemon's `Resolve` step.
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(fake.client, '', readCache, dirCache, 'v');
expect(adapter.toRemote('foo.md')).toBe('foo.md');
expect(adapter.toRemote('dir/sub/note.md')).toBe('dir/sub/note.md');
// Vault root maps to the empty string so the daemon's Resolve()
// returns its absRoot unchanged.
expect(adapter.toRemote('')).toBe('');
expect(adapter.toRemote('/')).toBe('');
});
});
describe('exists', () => {
it('returns true for a file or folder that exists remotely', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
dirs: { '/v': [e('note.md')] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await expect(adapter.exists('note.md')).resolves.toBe(true);
await expect(adapter.exists('')).resolves.toBe(true);
});
it('returns false when the remote path is missing', async () => {
const fake = makeFakeClient({});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await expect(adapter.exists('missing.md')).resolves.toBe(false);
});
});
describe('stat', () => {
it('returns a Stat with mtime/size for a file', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hello'), mtime: 1234 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const s = await adapter.stat('note.md');
expect(s).toEqual({ type: 'file', ctime: 1234, mtime: 1234, size: 5 });
});
it('returns "folder" type for a directory', async () => {
const fake = makeFakeClient({ dirs: { '/v/sub': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const s = await adapter.stat('sub');
expect(s?.type).toBe('folder');
});
it('returns null when the path does not exist', async () => {
const fake = makeFakeClient({});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await expect(adapter.stat('missing.md')).resolves.toBeNull();
});
});
describe('list', () => {
it('separates files and folders and returns vault-relative paths', async () => {
const fake = makeFakeClient({
dirs: { '/v/docs': [e('a.md'), e('b.md'), e('sub', true)] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const out = await adapter.list('docs');
expect(out.files.sort()).toEqual(['docs/a.md', 'docs/b.md']);
expect(out.folders).toEqual(['docs/sub']);
});
it('serves a vault-root listing without a leading slash', async () => {
const fake = makeFakeClient({
dirs: { '/v': [e('a.md'), e('docs', true)] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const out = await adapter.list('');
expect(out.files).toEqual(['a.md']);
expect(out.folders).toEqual(['docs']);
});
it('caches the second call within DirCache TTL', async () => {
const fake = makeFakeClient({
dirs: { '/v/docs': [e('a.md')] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.list('docs');
await adapter.list('docs');
expect(fake.spies.list).toHaveBeenCalledTimes(1);
});
});
describe('read', () => {
it('returns utf8 string content', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('# Hello', 'utf8'), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await expect(adapter.read('note.md')).resolves.toBe('# Hello');
});
it('serves from ReadCache when mtime is unchanged (1 stat, 0 reads on the second call)', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('note.md'); // miss: 1 read + 1 stat (post-read)
const before = fake.spies.readBinary.mock.calls.length;
await adapter.read('note.md'); // hit: 1 stat, 0 reads
expect(fake.spies.readBinary.mock.calls.length - before).toBe(0);
});
it('refetches when mtime has advanced on the remote', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('v1'), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('note.md'); // populates cache with mtime=1
// Simulate an out-of-band modification.
fake.files['/v/note.md'] = { data: Buffer.from('v2'), mtime: 2 };
await expect(adapter.read('note.md')).resolves.toBe('v2');
});
});
describe('readBinary', () => {
it('returns an ArrayBuffer copy (independent of the cached Buffer)', async () => {
const fake = makeFakeClient({
files: { '/v/img.png': { data: Buffer.from([1, 2, 3, 4]), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const ab = await adapter.readBinary('img.png');
expect(ab).toBeInstanceOf(ArrayBuffer);
const view = new Uint8Array(ab);
expect([...view]).toEqual([1, 2, 3, 4]);
view[0] = 99;
const cached = readCache.peek('/v/img.png');
expect(cached?.data[0]).toBe(1);
});
});
describe('write', () => {
it('persists text content and refreshes ReadCache with the new mtime', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.write('note.md', 'hello');
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('hello');
const cached = readCache.peek('/v/note.md');
expect(cached?.data.toString('utf8')).toBe('hello');
expect(cached?.mtime).toBe(fake.files['/v/note.md'].mtime);
});
it('writeBinary round-trips an ArrayBuffer faithfully', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const ab = new ArrayBuffer(4);
new Uint8Array(ab).set([10, 20, 30, 40]);
await adapter.writeBinary('blob.bin', ab);
expect([...fake.files['/v/blob.bin'].data]).toEqual([10, 20, 30, 40]);
});
it('creates parent directories on the way to the file', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.write('docs/sub/a.md', 'x');
expect(fake.dirs['/v/docs']).toBeDefined();
expect(fake.dirs['/v/docs/sub']).toBeDefined();
expect(fake.files['/v/docs/sub/a.md'].data.toString('utf8')).toBe('x');
});
it('invalidates the parent dir entry in DirCache after a write', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
// Prime DirCache with an empty listing so we can observe invalidation.
await adapter.list('');
expect(dirCache.get('/v')).not.toBeNull();
await adapter.write('note.md', 'hi');
expect(dirCache.get('/v')).toBeNull();
});
});
describe('append', () => {
it('appends text to an existing file', async () => {
const fake = makeFakeClient({
files: { '/v/log.md': { data: Buffer.from('first\n'), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.append('log.md', 'second\n');
expect(fake.files['/v/log.md'].data.toString('utf8')).toBe('first\nsecond\n');
});
it('creates the file when it does not exist yet', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.append('new.md', 'hello');
expect(fake.files['/v/new.md'].data.toString('utf8')).toBe('hello');
});
it('appendBinary concatenates bytes', async () => {
const fake = makeFakeClient({
files: { '/v/blob.bin': { data: Buffer.from([1, 2]), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const more = new ArrayBuffer(2);
new Uint8Array(more).set([3, 4]);
await adapter.appendBinary('blob.bin', more);
expect([...fake.files['/v/blob.bin'].data]).toEqual([1, 2, 3, 4]);
});
});
describe('process', () => {
it('reads, transforms, writes back, and returns the new content', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hello'), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const result = await adapter.process('note.md', s => s.toUpperCase());
expect(result).toBe('HELLO');
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('HELLO');
});
});
describe('mkdir / remove / rmdir', () => {
it('mkdir creates the directory chain', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.mkdir('a/b/c');
expect(fake.dirs['/v/a/b/c']).toBeDefined();
});
it('remove deletes the file and invalidates the entry in ReadCache', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('note.md'); // populate the cache
expect(readCache.peek('/v/note.md')).not.toBeNull();
await adapter.remove('note.md');
expect('/v/note.md' in fake.files).toBe(false);
expect(readCache.peek('/v/note.md')).toBeNull();
});
it('rmdir(recursive) removes the dir and invalidates the prefix in both caches', async () => {
const fake = makeFakeClient({
files: { '/v/dir/a.md': { data: Buffer.from('a'), mtime: 1 } },
dirs: { '/v': [], '/v/dir': [] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('dir/a.md'); // populate ReadCache
await adapter.list('dir'); // populate DirCache
await adapter.rmdir('dir', true);
expect('/v/dir' in fake.dirs).toBe(false);
expect(readCache.peek('/v/dir/a.md')).toBeNull();
expect(dirCache.get('/v/dir')).toBeNull();
});
});
describe('rename / copy', () => {
it('rename moves a file and invalidates the old prefix in caches', async () => {
const fake = makeFakeClient({
files: { '/v/old.md': { data: Buffer.from('content'), mtime: 1 } },
dirs: { '/v': [] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('old.md'); // cache it
await adapter.rename('old.md', 'new.md');
expect(fake.files['/v/new.md'].data.toString('utf8')).toBe('content');
expect('/v/old.md' in fake.files).toBe(false);
expect(readCache.peek('/v/old.md')).toBeNull();
});
it('rename creates the destination parent dirs', async () => {
const fake = makeFakeClient({
files: { '/v/a.md': { data: Buffer.from('x'), mtime: 1 } },
dirs: { '/v': [] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.rename('a.md', 'archived/2026/a.md');
expect(fake.dirs['/v/archived/2026']).toBeDefined();
expect(fake.files['/v/archived/2026/a.md'].data.toString('utf8')).toBe('x');
});
it('copy duplicates the file and invalidates the new path in ReadCache', async () => {
const fake = makeFakeClient({
files: { '/v/src.md': { data: Buffer.from('src'), mtime: 1 } },
dirs: { '/v': [] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.copy('src.md', 'dst.md');
expect(fake.files['/v/dst.md'].data.toString('utf8')).toBe('src');
expect(fake.files['/v/src.md'].data.toString('utf8')).toBe('src'); // src untouched
});
});
describe('trash', () => {
it('trashSystem returns false unconditionally so Obsidian falls back to local trash', async () => {
const fake = makeFakeClient({
files: { '/v/a.md': { data: Buffer.from('x'), mtime: 1 } },
dirs: { '/v': [] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await expect(adapter.trashSystem('a.md')).resolves.toBe(false);
// Must not be moved or deleted.
expect(fake.files['/v/a.md'].data.toString('utf8')).toBe('x');
});
it('trashLocal moves the path under <vault>/.trash/', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('bye'), mtime: 1 } },
dirs: { '/v': [] },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.trashLocal('note.md');
expect('/v/note.md' in fake.files).toBe(false);
expect(fake.files['/v/.trash/note.md'].data.toString('utf8')).toBe('bye');
});
});
// ─── PathMapper integration ───────────────────────────────────────────────
//
// These tests confirm that when an adapter is constructed with a
// PathMapper, vault-relative reads/writes/lists for client-private
// paths land in the per-client subtree on the remote, while ordinary
// vault content (and shared `.obsidian/*` files) go to their nominal
// locations unchanged.
describe('with PathMapper', () => {
it('redirects writes for private vault paths into .obsidian/user/<id>/', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
);
await adapter.write('.obsidian/workspace.json', '{"open":[]}');
// The remote sees the per-client subtree, not the bare path.
expect('/v/.obsidian/workspace.json' in fake.files).toBe(false);
expect(fake.files['/v/.obsidian/user/host-a/workspace.json'].data.toString('utf8'))
.toBe('{"open":[]}');
});
it('reads private paths from the per-client subtree', async () => {
const fake = makeFakeClient({
files: {
'/v/.obsidian/user/host-a/workspace.json': { data: Buffer.from('{"a":1}'), mtime: 1 },
},
dirs: { '/v': [], '/v/.obsidian': [], '/v/.obsidian/user': [], '/v/.obsidian/user/host-a': [] },
});
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
);
await expect(adapter.read('.obsidian/workspace.json')).resolves.toBe('{"a":1}');
});
it('passes non-private paths through to their nominal remote locations', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
);
await adapter.write('Notes/foo.md', '# Hello');
expect(fake.files['/v/Notes/foo.md'].data.toString('utf8')).toBe('# Hello');
// Definitely not redirected.
expect(Object.keys(fake.files).some(k => k.includes('user/host-a/Notes'))).toBe(false);
});
it('list(".obsidian") merges shared and per-client entries, hiding the user/ dir', async () => {
const fake = makeFakeClient({
files: {
'/v/.obsidian/hotkeys.json': { data: Buffer.from('{}'), mtime: 1 },
'/v/.obsidian/user/host-a/workspace.json': { data: Buffer.from('{}'), mtime: 1 },
},
dirs: {
'/v': [],
// Shared .obsidian (hotkeys.json + a plugins dir + the user-subtree dir)
'/v/.obsidian': [
{ name: 'hotkeys.json', isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 1, size: 2 },
{ name: 'plugins', isFile: false, isDirectory: true, isSymbolicLink: false, mtime: 1, size: 0 },
{ name: 'user', isFile: false, isDirectory: true, isSymbolicLink: false, mtime: 1, size: 0 },
],
'/v/.obsidian/plugins': [],
'/v/.obsidian/user': [
{ name: 'host-a', isFile: false, isDirectory: true, isSymbolicLink: false, mtime: 1, size: 0 },
],
'/v/.obsidian/user/host-a': [
{ name: 'workspace.json', isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 1, size: 2 },
],
},
});
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
);
const out = await adapter.list('.obsidian');
// The `user/` directory is hidden; both private and shared
// children appear under their nominal `.obsidian/...` paths.
expect(out.folders.sort()).toEqual(['.obsidian/plugins']);
expect(out.files.sort()).toEqual(['.obsidian/hotkeys.json', '.obsidian/workspace.json']);
});
it('list of a fully-private directory walks the per-client subtree', async () => {
const fake = makeFakeClient({
dirs: {
'/v': [],
'/v/.obsidian/user/host-a/cache': [
{ name: 'index.bin', isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 1, size: 0 },
],
},
});
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
);
const out = await adapter.list('.obsidian/cache');
expect(out.files).toEqual(['.obsidian/cache/index.bin']);
expect(out.folders).toEqual([]);
});
it('toRemote on a private path returns the per-client absolute path', () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
);
expect(adapter.toRemote('.obsidian/workspace.json'))
.toBe('/v/.obsidian/user/host-a/workspace.json');
expect(adapter.toRemote('Notes/x.md')).toBe('/v/Notes/x.md');
});
});
describe('swapClient', () => {
it('routes subsequent reads through the new client', async () => {
const oldClient = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('OLD'), mtime: 1 } },
});
const newClient = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('NEW'), mtime: 2 } },
});
const adapter = new SftpDataAdapter(oldClient.client, '/v', readCache, dirCache, 'v');
// Sanity: the adapter sees the old client's data first.
expect(await adapter.read('note.md')).toBe('OLD');
adapter.swapClient(newClient.client);
// After swap, mtime mismatch invalidates the cache and the
// newer client's data flows through.
expect(await adapter.read('note.md')).toBe('NEW');
});
it('preserves cached entries across swaps when mtimes still match', async () => {
const oldClient = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('SAME'), mtime: 7 } },
});
const newClient = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('SAME'), mtime: 7 } },
});
const adapter = new SftpDataAdapter(oldClient.client, '/v', readCache, dirCache, 'v');
await adapter.read('note.md'); // primes cache
adapter.swapClient(newClient.client);
// Same mtime → cache hit, the new client only sees a stat call.
const out = await adapter.read('note.md');
expect(out).toBe('SAME');
expect(newClient.client.readBinary).not.toHaveBeenCalled();
expect(newClient.client.stat).toHaveBeenCalled();
});
});
describe('setReconnecting (gate)', () => {
it('serves reads from the cache while reconnecting', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('CACHED'), mtime: 1 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('note.md'); // prime cache
fake.client.stat.mockClear();
fake.client.readBinary.mockClear();
adapter.setReconnecting(true);
// Cached read is served without touching the (dead) client.
expect(await adapter.read('note.md')).toBe('CACHED');
expect(fake.client.stat).not.toHaveBeenCalled();
expect(fake.client.readBinary).not.toHaveBeenCalled();
});
it('throws on a cache miss while reconnecting', async () => {
const fake = makeFakeClient({});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
adapter.setReconnecting(true);
await expect(adapter.read('absent.md')).rejects.toThrow(/reconnecting/i);
});
it('throws on every write-side method while reconnecting', async () => {
const fake = makeFakeClient({});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
adapter.setReconnecting(true);
const ab = new ArrayBuffer(0);
await expect(adapter.write('a', 'x')).rejects.toThrow(/reconnecting/i);
await expect(adapter.writeBinary('a', ab)).rejects.toThrow(/reconnecting/i);
await expect(adapter.append('a', 'x')).rejects.toThrow(/reconnecting/i);
await expect(adapter.appendBinary('a', ab)).rejects.toThrow(/reconnecting/i);
await expect(adapter.process('a', x => x)).rejects.toThrow(/reconnecting/i);
await expect(adapter.mkdir('d')).rejects.toThrow(/reconnecting/i);
await expect(adapter.remove('a')).rejects.toThrow(/reconnecting/i);
await expect(adapter.rmdir('d', false)).rejects.toThrow(/reconnecting/i);
await expect(adapter.rename('a', 'b')).rejects.toThrow(/reconnecting/i);
await expect(adapter.copy('a', 'b')).rejects.toThrow(/reconnecting/i);
await expect(adapter.trashLocal('a')).rejects.toThrow(/reconnecting/i);
});
it('throws on stat / list / exists while reconnecting', async () => {
const fake = makeFakeClient({});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
adapter.setReconnecting(true);
await expect(adapter.exists('x')).rejects.toThrow(/reconnecting/i);
await expect(adapter.stat('x')).rejects.toThrow(/reconnecting/i);
await expect(adapter.list('')).rejects.toThrow(/reconnecting/i);
});
it('returns to normal once the gate is cleared', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 9 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
adapter.setReconnecting(true);
await expect(adapter.write('note.md', 'data')).rejects.toThrow(/reconnecting/i);
adapter.setReconnecting(false);
await expect(adapter.read('note.md')).resolves.toBe('hi');
});
});
describe('write conflict (expectedMtime + onWriteConflict)', () => {
/**
* Build a client whose `writeBinary` rejects with
* PreconditionFailed whenever `expectedMtime` is supplied, and
* succeeds (writing into the in-memory store) when it isn't. Lets
* tests drive the "conflict, retry, success" path deterministically.
*/
function makeConflictingClient() {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('initial'), mtime: 100 } },
});
const writeCalls: Array<{ path: string; expected?: number }> = [];
fake.spies.writeBinary.mockImplementation(
async (p: string, data: Buffer, expectedMtime?: number) => {
writeCalls.push({ path: p, expected: expectedMtime });
if (expectedMtime !== undefined) {
const err = new Error('precondition failed') as Error & { code: number };
err.code = -32020;
throw err;
}
// Success path: write into the underlying in-memory store
// directly so we don't recurse through the spy.
fake.files[p] = { data: Buffer.from(data), mtime: 200 };
},
);
return { fake, writeCalls };
}
it('passes the cached mtime as expectedMtime when the path was recently read', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 7 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('note.md'); // primes cache with mtime=7
fake.spies.writeBinary.mockClear();
await adapter.write('note.md', 'updated');
expect(fake.spies.writeBinary).toHaveBeenCalledWith('/v/note.md', expect.any(Buffer), 7);
});
it('omits expectedMtime when the cache has no entry for the path', async () => {
const fake = makeFakeClient({});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.write('new.md', 'hello'); // first-touch write, no cache hit
expect(fake.spies.writeBinary).toHaveBeenCalledWith('/v/new.md', expect.any(Buffer), undefined);
});
it('asks onWriteConflict + retries without expectedMtime when user picks overwrite', async () => {
const { fake, writeCalls } = makeConflictingClient();
const onConflict = vi.fn(async () => true);
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, new ConflictResolver(fake.client, readCache, null, null, onConflict),
);
await adapter.read('note.md'); // primes mtime
await adapter.write('note.md', 'force');
expect(onConflict).toHaveBeenCalledWith('note.md');
expect(writeCalls).toHaveLength(2);
expect(writeCalls[0].expected).toBeDefined();
expect(writeCalls[1].expected).toBeUndefined();
});
it('rethrows the precondition error when user cancels the conflict', async () => {
const { fake } = makeConflictingClient();
const onConflict = vi.fn(async () => false);
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, new ConflictResolver(fake.client, readCache, null, null, onConflict),
);
await adapter.read('note.md');
await expect(adapter.write('note.md', 'no')).rejects.toMatchObject({ code: -32020 });
expect(onConflict).toHaveBeenCalledWith('note.md');
});
it('rethrows when there is no onWriteConflict callback', async () => {
const { fake } = makeConflictingClient();
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.read('note.md');
await expect(adapter.write('note.md', 'no')).rejects.toMatchObject({ code: -32020 });
});
it('rethrows non-precondition errors without consulting the callback', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
});
fake.spies.writeBinary.mockImplementation(async () => {
throw new Error('disk full');
});
const onConflict = vi.fn(async () => true);
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, new ConflictResolver(fake.client, readCache, null, null, onConflict),
);
await adapter.read('note.md');
await expect(adapter.write('note.md', 'no')).rejects.toThrow(/disk full/);
expect(onConflict).not.toHaveBeenCalled();
});
});
describe('text 3-way merge conflict (AncestorTracker + onTextConflict)', () => {
/**
* Local copy of the parent describe's `makeConflictingClient`
* helper — JS scoping keeps the original out of reach. Same
* contract: any write with `expectedMtime` rejects with
* PreconditionFailed; writes without it succeed.
*/
function makeConflictingClient() {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('initial'), mtime: 100 } },
});
const writeCalls: Array<{ path: string; expected?: number }> = [];
fake.spies.writeBinary.mockImplementation(
async (p: string, data: Buffer, expectedMtime?: number) => {
writeCalls.push({ path: p, expected: expectedMtime });
if (expectedMtime !== undefined) {
const err = new Error('precondition failed') as Error & { code: number };
err.code = -32020;
throw err;
}
fake.files[p] = { data: Buffer.from(data), mtime: 200 };
},
);
return { fake, writeCalls };
}
/**
* Same shape as `makeConflictingClient` above, but the underlying
* file's mtime advances on a successful (precondition-less) write
* — the 3-way path does both the failed precondition write AND a
* follow-up write, so we need the second write to land cleanly.
*/
function makeTextConflictingClient(opts: {
initialContent: string;
initialMtime: number;
theirsContent: string;
theirsMtime: number;
}) {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from(opts.initialContent), mtime: opts.initialMtime } },
});
// The "theirs" content is what the remote returns when the
// adapter re-reads after the precondition failure. We stash it
// straight into the fake's store so the conflict path's
// `client.readBinary(remote)` picks it up.
fake.files['/v/note.md'] = { data: Buffer.from(opts.theirsContent), mtime: opts.theirsMtime };
const writeCalls: Array<{ path: string; expected?: number; data: string }> = [];
fake.spies.writeBinary.mockImplementation(
async (p: string, data: Buffer, expectedMtime?: number) => {
writeCalls.push({ path: p, expected: expectedMtime, data: data.toString('utf8') });
// Only the FIRST attempt (with expectedMtime) gets the
// precondition error — retries land cleanly so the test can
// observe what we ended up writing.
if (expectedMtime !== undefined && writeCalls.length === 1) {
const err = new Error('precondition failed') as Error & { code: number };
err.code = -32020;
throw err;
}
fake.files[p] = { data: Buffer.from(data), mtime: opts.theirsMtime + 1 };
},
);
return { fake, writeCalls };
}
/** Re-prime the ancestor by reading first, then re-stash "theirs" into the fake store. */
async function primeAndConflict(opts: {
ancestorContent: string;
mineContent: string;
theirsContent: string;
decision:
| { decision: 'keep-mine' }
| { decision: 'keep-theirs' }
| { decision: 'merged'; content: string }
| { decision: 'cancel' };
}) {
const tracker = new AncestorTracker();
const onText = vi.fn(async () => opts.decision);
const onLegacy = vi.fn(async () => false);
const { fake, writeCalls } = makeTextConflictingClient({
initialContent: opts.ancestorContent,
initialMtime: 100,
theirsContent: opts.theirsContent,
theirsMtime: 200,
});
// Reset the file to the ANCESTOR state for the priming read,
// then swap it to THEIRS afterwards so the precondition fires.
fake.files['/v/note.md'] = { data: Buffer.from(opts.ancestorContent), mtime: 100 };
const resolver = new ConflictResolver(fake.client, readCache, tracker, onText, onLegacy);
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, resolver, tracker,
);
await adapter.read('note.md'); // ancestor primed: "ancestorContent" @ mtime 100
// Now flip the file under the adapter's nose to simulate
// another client having written "theirsContent" at mtime 200.
fake.files['/v/note.md'] = { data: Buffer.from(opts.theirsContent), mtime: 200 };
return { adapter, onText, onLegacy, writeCalls, fake };
}
it('routes a text PreconditionFailed through onTextConflict with the right panes', async () => {
const { adapter, onText } = await primeAndConflict({
ancestorContent: 'v0',
mineContent: 'mine',
theirsContent: 'theirs',
decision: { decision: 'keep-mine' },
});
await adapter.write('note.md', 'mine');
expect(onText).toHaveBeenCalledTimes(1);
const [path, panes] = onText.mock.calls[0];
expect(path).toBe('note.md');
expect(panes).toEqual({ ancestor: 'v0', mine: 'mine', theirs: 'theirs' });
});
it('keep-mine retries the write without expectedMtime and persists "mine"', async () => {
const { adapter, writeCalls, fake } = await primeAndConflict({
ancestorContent: 'v0',
mineContent: 'mine',
theirsContent: 'theirs',
decision: { decision: 'keep-mine' },
});
await adapter.write('note.md', 'mine');
// First write: with expectedMtime (rejected). Second: without.
expect(writeCalls).toHaveLength(2);
expect(writeCalls[0].expected).toBeDefined();
expect(writeCalls[1].expected).toBeUndefined();
expect(writeCalls[1].data).toBe('mine');
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('mine');
});
it('merged writes the user-supplied merged content', async () => {
const { adapter, writeCalls, fake } = await primeAndConflict({
ancestorContent: 'v0',
mineContent: 'mine',
theirsContent: 'theirs',
decision: { decision: 'merged', content: 'mine + theirs handled' },
});
await adapter.write('note.md', 'mine');
expect(writeCalls[1].data).toBe('mine + theirs handled');
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('mine + theirs handled');
});
it('keep-theirs rethrows + leaves the remote alone, refreshes cache to theirs', async () => {
const { adapter, writeCalls, fake } = await primeAndConflict({
ancestorContent: 'v0',
mineContent: 'mine',
theirsContent: 'theirs',
decision: { decision: 'keep-theirs' },
});
await expect(adapter.write('note.md', 'mine')).rejects.toMatchObject({ code: -32020 });
// Only the failed first attempt happened.
expect(writeCalls).toHaveLength(1);
// Remote still has theirs.
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('theirs');
// Cache was refreshed: a follow-up read serves theirs without
// another network round-trip — but we can verify by reading
// through the adapter and confirming the value.
expect(await adapter.read('note.md')).toBe('theirs');
});
it('cancel rethrows + leaves the remote alone', async () => {
const { adapter, writeCalls, fake } = await primeAndConflict({
ancestorContent: 'v0',
mineContent: 'mine',
theirsContent: 'theirs',
decision: { decision: 'cancel' },
});
await expect(adapter.write('note.md', 'mine')).rejects.toMatchObject({ code: -32020 });
expect(writeCalls).toHaveLength(1);
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('theirs');
});
it('falls back to the legacy two-choice modal when no ancestor is recorded', async () => {
const tracker = new AncestorTracker();
const onLegacy = vi.fn(async () => true /* overwrite */);
const onText = vi.fn();
const { fake, writeCalls } = makeConflictingClient();
const resolver = new ConflictResolver(fake.client, readCache, tracker, onText, onLegacy);
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, resolver, tracker,
);
// Prime the readCache (so the write sends expectedMtime) but do
// NOT populate the ancestor tracker — `readBinary` is the path
// that touches readCache without touching the text-only tracker.
await adapter.readBinary('note.md');
await adapter.write('note.md', 'force');
expect(onText).not.toHaveBeenCalled();
expect(onLegacy).toHaveBeenCalledWith('note.md');
expect(writeCalls[1].expected).toBeUndefined();
});
it('binary writeBinary uses the legacy two-choice modal even when ancestor + onTextConflict are wired', async () => {
const tracker = new AncestorTracker();
const onLegacy = vi.fn(async () => true);
const onText = vi.fn();
const { fake, writeCalls } = makeConflictingClient();
// Prime the ancestor so a TEXT write would route through 3-way;
// assert that the BINARY write does NOT.
fake.files['/v/note.md'] = { data: Buffer.from('initial'), mtime: 100 };
const resolver = new ConflictResolver(fake.client, readCache, tracker, onText, onLegacy);
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, resolver, tracker,
);
await adapter.read('note.md');
await adapter.writeBinary('note.md', new Uint8Array([1, 2, 3]).buffer);
expect(onText).not.toHaveBeenCalled();
expect(onLegacy).toHaveBeenCalledWith('note.md');
expect(writeCalls).toHaveLength(2);
});
});
});
describe('SftpDataAdapter (offline queue while reconnecting)', () => {
let readCache: ReadCache;
let dirCache: DirCache;
let queue: OfflineQueue;
let tmpDir: string;
beforeEach(async () => {
readCache = new ReadCache();
dirCache = new DirCache();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-ssh-adapter-queue-'));
queue = await OfflineQueue.open(tmpDir);
});
function makeAdapterWithQueue(opts: {
client: ReturnType<typeof makeFakeClient>['client'];
tracker?: AncestorTracker;
}) {
const tracker = opts.tracker ?? new AncestorTracker();
return new SftpDataAdapter(
opts.client, '/v', readCache, dirCache, 'v',
null, null, null, tracker,
queue,
);
}
it('queues a text write instead of throwing while reconnecting', async () => {
const fake = makeFakeClient({});
const adapter = makeAdapterWithQueue({ client: fake.client });
adapter.setReconnecting(true);
await adapter.write('a.md', 'hello');
expect(fake.spies.writeBinary).not.toHaveBeenCalled();
const pending = queue.pending();
expect(pending).toHaveLength(1);
expect(pending[0].op).toMatchObject({
kind: 'write',
path: 'a.md',
contentBase64: Buffer.from('hello', 'utf8').toString('base64'),
});
});
it('post-queue read returns the just-written content from local cache', async () => {
const fake = makeFakeClient({});
const adapter = makeAdapterWithQueue({ client: fake.client });
adapter.setReconnecting(true);
await adapter.write('a.md', 'queued content');
expect(await adapter.read('a.md')).toBe('queued content');
// No remote call happened — the read served from the cache the
// queue path populated.
expect(fake.client.readBinary).not.toHaveBeenCalled();
});
it('captures expectedMtime from the existing read cache entry', async () => {
const fake = makeFakeClient({
files: { '/v/a.md': { data: Buffer.from('v0'), mtime: 100 } },
});
const adapter = makeAdapterWithQueue({ client: fake.client });
await adapter.read('a.md'); // primes cache with mtime=100
adapter.setReconnecting(true);
await adapter.write('a.md', 'v1');
const op = queue.pending()[0].op as { expectedMtime?: number };
expect(op.expectedMtime).toBe(100);
});
it('queues writeBinary as kind=writeBinary', async () => {
const fake = makeFakeClient({});
const adapter = makeAdapterWithQueue({ client: fake.client });
adapter.setReconnecting(true);
await adapter.writeBinary('img.png', new Uint8Array([1, 2, 3]).buffer);
expect(queue.pending()[0].op.kind).toBe('writeBinary');
});
it('queues append as a single full-content write (no separate read+write entries)', async () => {
const fake = makeFakeClient({
files: { '/v/log.md': { data: Buffer.from('one\n'), mtime: 1 } },
});
const adapter = makeAdapterWithQueue({ client: fake.client });
await adapter.read('log.md'); // prime cache
adapter.setReconnecting(true);
await adapter.append('log.md', 'two\n');
const pending = queue.pending();
expect(pending).toHaveLength(1);
const op = pending[0].op as { kind: string; contentBase64: string };
expect(op.kind).toBe('write');
expect(Buffer.from(op.contentBase64, 'base64').toString('utf8')).toBe('one\ntwo\n');
});
it('queues mkdir / remove / rmdir / rename / copy with their right shapes', async () => {
const fake = makeFakeClient({});
const adapter = makeAdapterWithQueue({ client: fake.client });
adapter.setReconnecting(true);
await adapter.mkdir('newdir');
await adapter.remove('a.md');
await adapter.rmdir('newdir', true);
await adapter.rename('old.md', 'new.md');
await adapter.copy('src.md', 'dst.md');
const ops = queue.pending().map(e => e.op);
expect(ops).toEqual([
{ kind: 'mkdir', path: 'newdir' },
{ kind: 'remove', path: 'a.md' },
{ kind: 'rmdir', path: 'newdir', recursive: true },
{ kind: 'rename', oldPath: 'old.md', newPath: 'new.md' },
{ kind: 'copy', srcPath: 'src.md', dstPath: 'dst.md' },
]);
});
it('still throws when no offline queue is wired', async () => {
const fake = makeFakeClient({});
// Adapter built WITHOUT the queue arg — the legacy reconnecting
// behaviour kicks in.
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
adapter.setReconnecting(true);
await expect(adapter.write('a.md', 'x')).rejects.toThrow(/reconnecting/i);
});
it('queued ops persist across re-opens of the queue (round-trip via disk)', async () => {
const fake = makeFakeClient({});
const adapter = makeAdapterWithQueue({ client: fake.client });
adapter.setReconnecting(true);
await adapter.write('a.md', 'hello');
await adapter.mkdir('sub');
// Re-open the queue from the same dir to confirm the on-disk log
// round-trips the adapter's enqueues.
const reopened = await OfflineQueue.open(tmpDir);
const ops = reopened.pending().map(e => e.op.kind);
expect(ops).toEqual(['write', 'mkdir']);
});
// ─── replayQueuedOp (E2-β.3 entry point used by QueueReplayer) ─────────
it('replayQueuedOp(write) honours the queued expectedMtime, not the cached one', async () => {
const fake = makeFakeClient({
files: { '/v/a.md': { data: Buffer.from('initial'), mtime: 100 } },
});
const adapter = makeAdapterWithQueue({ client: fake.client });
// Replay scenario: caller passes the mtime captured at enqueue
// time. We assert the spy received it as expectedMtime.
const result = await adapter.replayQueuedOp({
kind: 'write',
path: 'a.md',
contentBase64: Buffer.from('replayed', 'utf8').toString('base64'),
expectedMtime: 42,
});
expect(result).toEqual({ result: 'ok' });
expect(fake.spies.writeBinary).toHaveBeenCalledWith('/v/a.md', expect.any(Buffer), 42);
});
it('replayQueuedOp returns conflict on PreconditionFailed without an ancestor', async () => {
const fake = makeFakeClient({
files: { '/v/a.md': { data: Buffer.from('initial'), mtime: 100 } },
});
fake.spies.writeBinary.mockImplementation(async (_p: string, _data: Buffer, expectedMtime?: number) => {
if (expectedMtime !== undefined) {
const err = new Error('precondition failed') as Error & { code: number };
err.code = -32020;
throw err;
}
});
const adapter = makeAdapterWithQueue({ client: fake.client });
// No prior read → no ancestor → adapter falls through to the
// legacy two-choice path. Without an `onWriteConflict` callback,
// the precondition error rethrows; replayQueuedOp surfaces it as
// 'conflict' (= a user-decided outcome, queue moves on).
const result = await adapter.replayQueuedOp({
kind: 'write',
path: 'a.md',
contentBase64: Buffer.from('replayed', 'utf8').toString('base64'),
expectedMtime: 42,
});
expect(result).toEqual({ result: 'conflict' });
});
it('replayQueuedOp dispatches mkdir / remove / rename / copy to the underlying client', async () => {
// Pre-populate source files so the fake's rename/copy don't bail
// with "no such file" — the adapter just relays the call.
const fake = makeFakeClient({
files: {
'/v/old.md': { data: Buffer.from('x'), mtime: 1 },
'/v/a': { data: Buffer.from('a'), mtime: 1 },
'/v/c': { data: Buffer.from('c'), mtime: 1 },
},
});
const adapter = makeAdapterWithQueue({ client: fake.client });
expect((await adapter.replayQueuedOp({ kind: 'mkdir', path: 'newdir' })).result).toBe('ok');
expect(fake.spies.mkdirp).toHaveBeenCalledWith('/v/newdir');
expect((await adapter.replayQueuedOp({ kind: 'remove', path: 'old.md' })).result).toBe('ok');
expect(fake.spies.remove).toHaveBeenCalledWith('/v/old.md');
expect((await adapter.replayQueuedOp({ kind: 'rename', oldPath: 'a', newPath: 'b' })).result).toBe('ok');
expect(fake.spies.rename).toHaveBeenCalledWith('/v/a', '/v/b');
expect((await adapter.replayQueuedOp({ kind: 'copy', srcPath: 'c', dstPath: 'd' })).result).toBe('ok');
expect(fake.spies.copy).toHaveBeenCalledWith('/v/c', '/v/d');
});
it('replayQueuedOp returns error when the underlying client throws non-precondition', async () => {
const fake = makeFakeClient({});
fake.spies.writeBinary.mockImplementation(async () => { throw new Error('disk full'); });
const adapter = makeAdapterWithQueue({ client: fake.client });
const result = await adapter.replayQueuedOp({
kind: 'write',
path: 'a.md',
contentBase64: 'YQ==',
});
expect(result).toEqual({ result: 'error', message: expect.stringMatching(/disk full/) });
});
it('replayQueuedOp refuses to run while reconnecting', async () => {
const fake = makeFakeClient({});
const adapter = makeAdapterWithQueue({ client: fake.client });
adapter.setReconnecting(true);
const result = await adapter.replayQueuedOp({ kind: 'mkdir', path: 'x' });
expect(result).toMatchObject({ result: 'error' });
});
});
describe('SftpDataAdapter: getResourcePath', () => {
let readCache: ReadCache;
let dirCache: DirCache;
beforeEach(() => {
readCache = new ReadCache({ maxBytes: 1024 });
dirCache = new DirCache({ ttlMs: 10000 });
});
it('returns a data: fallback URL when no resourceBridge is wired', () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
expect(adapter.getResourcePath('img.png')).toBe('data:application/octet-stream;base64,');
});
it('returns the bridge URL when the bridge is running', () => {
const fake = makeFakeClient();
const fakeBridge = {
isRunning: () => true,
urlFor: (p: string, _opts?: unknown) => `http://127.0.0.1:4567/r/token?p=${encodeURIComponent(p)}`,
};
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, fakeBridge as unknown as import('../src/adapter/ResourceBridge').ResourceBridge,
);
const url = adapter.getResourcePath('folder/photo.jpg');
expect(url).toContain('127.0.0.1:4567');
expect(url).toContain('photo.jpg');
});
it('returns the data: fallback when the bridge exists but is not running', () => {
const fake = makeFakeClient();
const fakeBridge = {
isRunning: () => false,
urlFor: (_p: string) => { throw new Error('should not be called'); },
};
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, fakeBridge as unknown as import('../src/adapter/ResourceBridge').ResourceBridge,
);
expect(adapter.getResourcePath('img.png')).toBe('data:application/octet-stream;base64,');
});
});
describe('SftpDataAdapter: readBuffer stat-after-read failure', () => {
let readCache: ReadCache;
let dirCache: DirCache;
beforeEach(() => {
readCache = new ReadCache({ maxBytes: 1024 });
dirCache = new DirCache({ ttlMs: 10000 });
});
it('still returns data and caches with mtime=0 when stat-after-read throws', async () => {
const fake = makeFakeClient({
files: { '/v/note.md': { data: Buffer.from('content'), mtime: 42 } },
});
// Make stat always reject so the post-read stat path throws.
fake.spies.stat.mockRejectedValue(new Error('stat unavailable'));
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const result = await adapter.read('note.md');
expect(result).toBe('content');
// Cache should be populated with mtime=0 because the stat failed.
const cached = readCache.peek('/v/note.md');
expect(cached).not.toBeNull();
expect(cached?.mtime).toBe(0);
});
});
// ─── writer reflect wiring (#341) ────────────────────────────────────────────
describe('SftpDataAdapter — writer reflect (#341)', () => {
let readCache: ReadCache;
let dirCache: DirCache;
beforeEach(() => {
readCache = new ReadCache();
dirCache = new DirCache();
});
function makeReflector() {
return {
reflectWrite: vi.fn(),
reflectRename: vi.fn(),
reflectRemove: vi.fn(),
reflectMkdir: vi.fn(),
};
}
it('write / writeBinary / appendBinary call reflectWrite with the vault path', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const r = makeReflector();
adapter.setWriterReflector(r);
await adapter.write('note.md', 'hello');
await adapter.writeBinary('blob.bin', new ArrayBuffer(2));
await adapter.appendBinary('log.bin', new ArrayBuffer(1));
expect(r.reflectWrite.mock.calls.map(c => c[0]))
.toEqual(['note.md', 'blob.bin', 'log.bin']);
});
it('mkdir → reflectMkdir, remove → reflectRemove, rmdir → reflectRemove', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const r = makeReflector();
adapter.setWriterReflector(r);
await adapter.mkdir('dir');
await adapter.write('dir/f.md', 'x');
await adapter.remove('dir/f.md');
await adapter.rmdir('dir', true);
expect(r.reflectMkdir).toHaveBeenCalledWith('dir');
expect(r.reflectRemove.mock.calls.map(c => c[0])).toEqual(['dir/f.md', 'dir']);
});
it('rename calls reflectRename(old, new); copy reflects nothing', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
const r = makeReflector();
adapter.setWriterReflector(r);
await adapter.write('a.md', 'x');
await adapter.rename('a.md', 'b.md');
await adapter.copy('b.md', 'c.md');
expect(r.reflectRename).toHaveBeenCalledWith('a.md', 'b.md');
// copy has no writer-reflect by design — none of the hooks fire for it.
expect(r.reflectRename).toHaveBeenCalledTimes(1);
expect(r.reflectWrite).toHaveBeenCalledTimes(1); // only the seed write
});
it('a throwing reflector does not surface as a write failure', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
adapter.setWriterReflector({
reflectWrite: () => { throw new Error('listener blew up'); },
reflectRename: vi.fn(),
reflectRemove: vi.fn(),
reflectMkdir: vi.fn(),
});
await expect(adapter.write('note.md', 'hello')).resolves.toBeUndefined();
// The remote write still happened despite the reflect throw.
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('hello');
});
it('a null reflector is a safe no-op', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
adapter.setWriterReflector(null);
await expect(adapter.write('note.md', 'hi')).resolves.toBeUndefined();
});
it('does NOT reflect a remove that was only queued while reconnecting (#C1)', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-ssh-reflect-q-'));
const queue = await OfflineQueue.open(tmpDir);
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, null, new AncestorTracker(), queue,
);
const r = makeReflector();
adapter.setWriterReflector(r);
adapter.setReconnecting(true);
await adapter.remove('note.md'); // queued, NOT applied to remote
expect(r.reflectRemove).not.toHaveBeenCalled();
expect(queue.pending().map(e => e.op.kind)).toContain('remove');
});
it('does NOT reflect rmdir / rename that were only queued while reconnecting (#C1)', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-ssh-reflect-q2-'));
const queue = await OfflineQueue.open(tmpDir);
const fake = makeFakeClient({ dirs: { '/v': [], '/v/d': [] } });
const adapter = new SftpDataAdapter(
fake.client, '/v', readCache, dirCache, 'v',
null, null, null, new AncestorTracker(), queue,
);
const r = makeReflector();
adapter.setWriterReflector(r);
adapter.setReconnecting(true);
await adapter.rmdir('d', true); // queued, not applied
await adapter.rename('a.md', 'b.md'); // queued, not applied
expect(r.reflectRemove).not.toHaveBeenCalled();
expect(r.reflectRename).not.toHaveBeenCalled();
const kinds = queue.pending().map(e => e.op.kind);
expect(kinds).toContain('rmdir');
expect(kinds).toContain('rename');
});
it('a throwing reflector on rename does not surface as a rename failure', async () => {
const fake = makeFakeClient({ dirs: { '/v': [] } });
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
await adapter.write('a.md', 'x');
adapter.setWriterReflector({
reflectWrite: vi.fn(),
reflectRename: () => { throw new Error('listener blew up'); },
reflectRemove: vi.fn(),
reflectMkdir: vi.fn(),
});
await expect(adapter.rename('a.md', 'b.md')).resolves.toBeUndefined();
// The remote rename still happened despite the reflect throw.
expect(fake.files['/v/b.md']).toBeDefined();
expect(fake.files['/v/a.md']).toBeUndefined();
});
});
// ─── cache revalidation: mtime alone is not enough ──────────────────────────
//
// SFTP reports mtime at 1-SECOND resolution (`SftpClient`: `stats.mtime * 1000`),
// so two edits inside the same wall-clock second land on the SAME mtime. A cache
// that revalidates on mtime alone then serves a copy of a file that no longer
// exists on the server — and the user saves it back over the real one. `stat`
// already returns the size, so comparing it closes the common case for free.
// The E2E form of this lives in `e2e/cache-pressure.spec.ts`.
describe('SftpDataAdapter — cache revalidation compares size, not just mtime', () => {
let readCache: ReadCache;
let dirCache: DirCache;
beforeEach(() => {
readCache = new ReadCache({ maxBytes: 4096 });
dirCache = new DirCache({ ttlMs: 10_000 });
});
it('refetches when the remote size changed but the mtime did NOT', async () => {
const fake = makeFakeClient({
files: { '/v/a.md': { data: Buffer.from('old'), mtime: 5000 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
expect(await adapter.read('a.md')).toBe('old'); // populates the cache
// A same-second edit: different bytes, DIFFERENT length, mtime unchanged.
fake.files['/v/a.md'] = { data: Buffer.from('a-much-longer-body'), mtime: 5000 };
expect(
await adapter.read('a.md'),
'served the STALE cached copy: the mtime matched, but the size did not',
).toBe('a-much-longer-body');
});
it('still serves from cache — no refetch — when mtime AND size both match', async () => {
const fake = makeFakeClient({
files: { '/v/a.md': { data: Buffer.from('same'), mtime: 5000 } },
});
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
expect(await adapter.read('a.md')).toBe('same');
const fetches = fake.spies.readBinary.mock.calls.length;
expect(await adapter.read('a.md')).toBe('same');
expect(
fake.spies.readBinary.mock.calls.length,
'a matching mtime+size must still be a cache hit — the size check must not defeat caching',
).toBe(fetches);
});
});
// ─── config write-through (#342 / #429) ─────────────────────────────────────
//
// Obsidian loads community plugins at startup and each `onload()` calls
// `Plugin.loadData()` — reading `.obsidian/plugins/<id>/data.json` off the
// LOCAL shadow disk — BEFORE remote-ssh has connected over SSH and patched the
// adapter. Nothing used to write that local copy (saveData() went straight to
// the remote), so every restart booted plugins on DEFAULTS, which they then
// saved back, destroying the real settings on the remote too.
//
// The adapter must therefore mirror `<configDir>/**` writes to local disk.
describe('SftpDataAdapter — config write-through to the local shadow disk (#342/#429)', () => {
let readCache: ReadCache;
let dirCache: DirCache;
let shadow: string;
beforeEach(async () => {
readCache = new ReadCache({ maxBytes: 4096 });
dirCache = new DirCache({ ttlMs: 10_000 });
shadow = await fs.mkdtemp(path.join(os.tmpdir(), 'shadow-wt-'));
});
it('mirrors a plugin data.json write onto local disk, and sends it to the per-device remote path', async () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
new PathMapper('host-a', '.obsidian'), null, null, null, null,
shadow,
);
await adapter.write('.obsidian/plugins/claudian/data.json', '{"model":"x"}');
// Local: the exact path Obsidian reads at the next startup.
const local = path.join(shadow, '.obsidian', 'plugins', 'claudian', 'data.json');
expect(await fs.readFile(local, 'utf-8')).toBe('{"model":"x"}');
// Remote: redirected into THIS device's private subtree, so a second
// device never collides on the same file.
expect(fake.files['/srv/vault/.obsidian/user/host-a/plugins/claudian/data.json']).toBeDefined();
expect(fake.files['/srv/vault/.obsidian/plugins/claudian/data.json']).toBeUndefined();
});
it('mirrors the core per-device config files too', async () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
new PathMapper('host-a', '.obsidian'), null, null, null, null,
shadow,
);
await adapter.write('.obsidian/app.json', '{"promptDelete":false}');
expect(await fs.readFile(path.join(shadow, '.obsidian', 'app.json'), 'utf-8'))
.toBe('{"promptDelete":false}');
});
it('does NOT mirror ordinary vault notes — the note tree stays virtual', async () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
new PathMapper('host-a', '.obsidian'), null, null, null, null,
shadow,
);
await adapter.write('Notes/foo.md', '# hi');
// The remote got it...
expect(fake.files['/srv/vault/Notes/foo.md']).toBeDefined();
// ...but nothing was mirrored to the local shadow disk.
await expect(fs.stat(path.join(shadow, 'Notes', 'foo.md'))).rejects.toThrow();
});
it('a local mirror failure never fails the write that already landed remotely', async () => {
const fake = makeFakeClient();
// Shadow root is a FILE, so creating any directory beneath it fails.
const bogus = path.join(shadow, 'not-a-dir');
await fs.writeFile(bogus, 'x');
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
new PathMapper('host-a', '.obsidian'), null, null, null, null,
bogus,
);
await expect(
adapter.write('.obsidian/plugins/claudian/data.json', '{"a":1}'),
).resolves.toBeUndefined();
// The remote write still succeeded — only the warm-start cache is degraded.
expect(fake.files['/srv/vault/.obsidian/user/host-a/plugins/claudian/data.json']).toBeDefined();
});
it('is a no-op when no shadow base path is wired (unit-test default)', async () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
new PathMapper('host-a', '.obsidian'),
);
await expect(adapter.write('.obsidian/app.json', '{}')).resolves.toBeUndefined();
});
// ShadowVaultBootstrap.installPlugin symlinks remote-ssh's OWN
// main.js/manifest.json/styles.css in the shadow vault back to the SOURCE
// vault's real files. fs.writeFileSync FOLLOWS symlinks, so mirroring one
// would overwrite the source vault's plugin install and brick it — the exact
// bug class #455 fixed. The mirror must refuse.
it('never writes THROUGH a symlink (would clobber the source vault — #455 regression guard)', async () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
new PathMapper('host-a', '.obsidian'), null, null, null, null,
shadow,
);
// The "source vault" real file, living OUTSIDE the shadow root.
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'source-vault-'));
const sourceMain = path.join(sourceDir, 'main.js');
await fs.writeFile(sourceMain, 'SOURCE-REAL-FILE');
// The shadow's plugin dir symlinks main.js at that source file.
const shadowPluginDir = path.join(shadow, '.obsidian', 'plugins', 'remote-ssh');
await fs.mkdir(shadowPluginDir, { recursive: true });
await fs.symlink(sourceMain, path.join(shadowPluginDir, 'main.js'));
await adapter.write('.obsidian/plugins/remote-ssh/main.js', 'NEW-BUNDLE-BYTES');
// The remote still got the write...
expect(fake.files['/srv/vault/.obsidian/plugins/remote-ssh/main.js']).toBeDefined();
// ...but the SOURCE vault's real file is untouched.
expect(await fs.readFile(sourceMain, 'utf-8')).toBe('SOURCE-REAL-FILE');
// ...and the symlink is still a symlink (not replaced by a real file).
expect((await fs.lstat(path.join(shadowPluginDir, 'main.js'))).isSymbolicLink()).toBe(true);
});
// The `startsWith(configDir + '/')` guard runs on the RAW string, before
// `..` is resolved — so it must not be the only containment check.
it('refuses to mirror a traversal path that escapes the shadow root', async () => {
const fake = makeFakeClient();
const adapter = new SftpDataAdapter(
fake.client, '/srv/vault', readCache, dirCache, 'v',
new PathMapper('host-a', '.obsidian'), null, null, null, null,
shadow,
);
const victim = path.join(shadow, '..', `escaped-${path.basename(shadow)}.txt`);
await expect(fs.stat(victim)).rejects.toThrow(); // not there yet
// Starts with `.obsidian/` so it passes the raw prefix test, but resolves
// out of the shadow root.
await adapter.write(`.obsidian/../escaped-${path.basename(shadow)}.txt`, 'pwned');
// Nothing was written outside the shadow root.
await expect(fs.stat(victim)).rejects.toThrow();
});
});