mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
commit
e0f3117ca6
16 changed files with 719 additions and 294 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
537
plugin/package-lock.json
generated
537
plugin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-remote-ssh",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"description": "VS Code Remote SSH-like experience for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -45,15 +45,15 @@
|
|||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/ssh2": "^1.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.64.0",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"fast-check": "^4.8.0",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"obsidian": "~1.12.3",
|
||||
"typescript": "^6.0.3",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ function isThumbnailEligible(vaultPath: string): boolean {
|
|||
if (dot < 0) return false;
|
||||
return THUMBNAIL_EXTENSIONS.has(vaultPath.slice(dot + 1).toLowerCase());
|
||||
}
|
||||
import * as fs from 'fs';
|
||||
import * as nodePath from 'path';
|
||||
import type { RemoteFsClient } from './RemoteFsClient';
|
||||
import type { WriterReflector } from './WriterReflector';
|
||||
import type { LocalOpRegistry } from './LocalOpRegistry';
|
||||
|
|
@ -869,6 +871,87 @@ export class SftpDataAdapter {
|
|||
* than whatever the cache holds now (which is the synthetic
|
||||
* mtime from the offline cache update).
|
||||
*/
|
||||
/**
|
||||
* Mirror a successful `<configDir>/**` write onto the LOCAL shadow disk
|
||||
* (#342 / #429 — "plugin settings are not kept after restarting the vault").
|
||||
*
|
||||
* Why this is load-bearing, and why a pull-on-connect cannot replace it:
|
||||
* Obsidian loads community plugins during startup, and each plugin's
|
||||
* `onload()` calls `Plugin.loadData()` — which reads
|
||||
* `<configDir>/plugins/<id>/data.json` — BEFORE remote-ssh has connected
|
||||
* over SSH and patched the adapter. That read therefore always hits the
|
||||
* real `FileSystemAdapter`, i.e. the local shadow disk. We cannot get in
|
||||
* front of it: the SSH connect is async and happens at layout-ready.
|
||||
*
|
||||
* So the local disk must ALREADY be correct at startup. Previously nothing
|
||||
* ever wrote it: `saveData()` went through the patched adapter straight to
|
||||
* the remote and the local copy stayed empty, so every restart booted the
|
||||
* plugin on DEFAULTS — which the plugin then saved back, destroying the
|
||||
* real settings on the remote too.
|
||||
*
|
||||
* Write-through fixes that at the source: local disk is a warm cache of the
|
||||
* remote, so whichever way the shadow window is launched (Connect from the
|
||||
* source window, or reopening the vault directly) the startup read sees the
|
||||
* settings this device last saved. The remote per-device copy stays the
|
||||
* source of truth and the cross-session backup.
|
||||
*
|
||||
* Scope is deliberately `<configDir>/**` only — the vault's NOTE tree stays
|
||||
* virtual (served from the remote, never mirrored to disk); mirroring it
|
||||
* would defeat the whole shadow-vault design. Best-effort: a failure here
|
||||
* must never fail the write that already landed on the remote.
|
||||
*/
|
||||
private writeThroughConfig(normalizedPath: string, data: Buffer): void {
|
||||
if (!this.shadowBasePath || !this.pathMapper) return;
|
||||
const rel = normalizedPath.startsWith('/') ? normalizedPath.slice(1) : normalizedPath;
|
||||
if (!rel.startsWith(`${this.pathMapper.configDir}/`)) return;
|
||||
|
||||
// Containment must be checked on the RESOLVED path, not the raw string:
|
||||
// the prefix test above is a plain `startsWith`, so `.obsidian/x/../../../
|
||||
// evil` would sail past it and `join` would then resolve the `..` right
|
||||
// out of the shadow root. `resolve` also collapses win32 backslashes,
|
||||
// which would otherwise be a second way to smuggle a separator through.
|
||||
// The vault path reaches us from Obsidian / other plugins — not trusted.
|
||||
const root = nodePath.resolve(this.shadowBasePath);
|
||||
const abs = nodePath.resolve(root, rel);
|
||||
if (abs !== root && !abs.startsWith(root + nodePath.sep)) {
|
||||
logger.warn(`writeThroughConfig: refusing to mirror outside the shadow root: "${rel}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// NEVER write through a symlink. `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, and
|
||||
// `fs.writeFileSync` follows symlinks — mirroring one would overwrite
|
||||
// the source vault's plugin install and brick it. That is exactly the
|
||||
// bug class #455 just fixed; do not reintroduce it here.
|
||||
//
|
||||
// Skipping costs nothing: the remote write already succeeded, and for
|
||||
// plugin CODE the local copy is owned by the pull/push binary
|
||||
// round-trip (`PLUGIN_BINARY_FILES`), not by this cache.
|
||||
if (fs.lstatSync(abs, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
||||
logger.warn(`writeThroughConfig: "${rel}" is a symlink — not mirrored (would clobber its target)`);
|
||||
return;
|
||||
}
|
||||
// Same hazard one level up: a symlinked ANCESTOR (e.g. a stale
|
||||
// whole-dir plugin symlink from an older build) would redirect the
|
||||
// write out of the shadow root even though `abs` looked contained.
|
||||
const dir = nodePath.dirname(abs);
|
||||
const realDir = fs.existsSync(dir) ? fs.realpathSync(dir) : null;
|
||||
if (realDir && realDir !== root && !realDir.startsWith(root + nodePath.sep)) {
|
||||
logger.warn(`writeThroughConfig: "${rel}" resolves outside the shadow root via a symlinked parent — not mirrored`);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(abs, data);
|
||||
} catch (e) {
|
||||
// The remote write already succeeded — the session is correct either
|
||||
// way; only the next restart's warm start is degraded.
|
||||
logger.warn(`writeThroughConfig: "${rel}" not mirrored locally (${errorMessage(e)})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeBuffer(
|
||||
normalizedPath: string,
|
||||
data: Buffer,
|
||||
|
|
@ -900,6 +983,10 @@ export class SftpDataAdapter {
|
|||
this.transferTracker?.end(txId);
|
||||
}
|
||||
|
||||
// The remote now holds `writtenData`. Mirror it onto the local shadow
|
||||
// disk too, so the next Obsidian start reads the real settings (#342/#429).
|
||||
this.writeThroughConfig(normalizedPath, writtenData);
|
||||
|
||||
let mtime = 0;
|
||||
try {
|
||||
const s = await this.client.stat(remote);
|
||||
|
|
|
|||
|
|
@ -1043,6 +1043,18 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
* shadow window (Phase 4).
|
||||
*/
|
||||
async openShadowVaultFor(profile: SshProfile): Promise<void> {
|
||||
// Connect clicked from INSIDE the shadow window for this same
|
||||
// profile (Settings row button / connect modal). This vault IS the
|
||||
// shadow — there is no second window to spawn, and re-running the
|
||||
// bootstrap from here would make installPlugin's rm+symlink cycle
|
||||
// target its own files (src == dst), turning main.js/manifest.json
|
||||
// into self-referential symlinks that brick the install on the
|
||||
// next start. Reconnect in place instead.
|
||||
if (this.settings.autoConnectProfileId === profile.id) {
|
||||
await this.runAutoConnect('reconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
// Source dir: where this running plugin lives, so the shadow
|
||||
// vault's plugin install symlinks the same bundle.
|
||||
const sourcePluginDir = this.pluginDir();
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ function defaultObsidianConfigDir(): string {
|
|||
* (corrupted layout files, conflicting graph views) are loud failures.
|
||||
*
|
||||
* Patterns are matched as either an exact configDir-relative filename
|
||||
* or a directory prefix (so `cache` covers everything inside).
|
||||
* or a directory prefix (so `cache` covers everything inside). A `*`
|
||||
* segment matches exactly one path segment (see `matchesPrivatePattern`),
|
||||
* which is what lets us privatise `plugins/<id>/data.json` without
|
||||
* dragging the plugin's *code* along with it.
|
||||
*/
|
||||
export const DEFAULT_PRIVATE_PATTERN_BASENAMES: readonly string[] = [
|
||||
'workspace.json',
|
||||
|
|
@ -41,6 +44,17 @@ export const DEFAULT_PRIVATE_PATTERN_BASENAMES: readonly string[] = [
|
|||
'appearance.json',
|
||||
'core-plugins.json',
|
||||
'hotkeys.json',
|
||||
// Third-party plugin SETTINGS (#342 / #429) — per-device for exactly the
|
||||
// same reason as the four above. `Plugin.saveData()` fires on every
|
||||
// settings change, so a shared `<configDir>/plugins/<id>/data.json` would
|
||||
// reintroduce the perpetual write-conflict the redirect above just killed.
|
||||
//
|
||||
// Deliberately `plugins/*/data.json` and NOT `plugins`: the plugin's CODE
|
||||
// (manifest.json / main.js / styles.css) must stay SHARED at the identity
|
||||
// path so a plugin installed on one machine still loads on every other —
|
||||
// that round-trip is `ShadowVaultBootstrap.PLUGIN_BINARY_FILES`. Only the
|
||||
// settings go per-device.
|
||||
'plugins/*/data.json',
|
||||
'cache',
|
||||
'cache.zlib',
|
||||
'types.json',
|
||||
|
|
@ -49,6 +63,32 @@ export const DEFAULT_PRIVATE_PATTERN_BASENAMES: readonly string[] = [
|
|||
'canvas.json',
|
||||
];
|
||||
|
||||
/**
|
||||
* Match a configDir-rooted private pattern against a normalized
|
||||
* vault-relative path.
|
||||
*
|
||||
* Semantics: exact match, or directory-prefix match (`cache` covers
|
||||
* `cache/anything`). A `*` pattern segment matches exactly one path
|
||||
* segment — never across `/` — so `plugins/*` + `data.json` catches
|
||||
* `plugins/claudian/data.json` but leaves `plugins/claudian/main.js`
|
||||
* shared.
|
||||
*/
|
||||
function matchesPrivatePattern(pattern: string, normalized: string): boolean {
|
||||
if (!pattern.includes('*')) {
|
||||
return normalized === pattern || normalized.startsWith(pattern + '/');
|
||||
}
|
||||
const pat = pattern.split('/');
|
||||
const seg = normalized.split('/');
|
||||
// Shorter than the pattern → neither the file itself nor inside it.
|
||||
if (seg.length < pat.length) return false;
|
||||
for (let i = 0; i < pat.length; i++) {
|
||||
if (pat[i] === '*') continue; // any single segment
|
||||
if (pat[i] !== seg[i]) return false;
|
||||
}
|
||||
// Equal depth = the file itself; deeper = inside it (directory-prefix).
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat re-export: the basenames joined onto the default
|
||||
* configDir. Kept as a `readonly string[]` so existing callers /
|
||||
|
|
@ -170,7 +210,7 @@ export class PathMapper {
|
|||
/** True when the vault-relative path should live in this client's private subtree. */
|
||||
isPrivate(vaultPath: string): boolean {
|
||||
const normalized = stripLeadingSlash(vaultPath);
|
||||
return this.privatePatterns.some(p => normalized === p || normalized.startsWith(p + '/'));
|
||||
return this.privatePatterns.some(p => matchesPrivatePattern(p, normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -184,7 +224,12 @@ export class PathMapper {
|
|||
isCrossingPoint(vaultPath: string): boolean {
|
||||
const normalized = stripLeadingSlash(vaultPath);
|
||||
if (this.isPrivate(normalized)) return false;
|
||||
return this.privatePatterns.some(p => parentDirOf(p) === normalized);
|
||||
// Exact-depth match (not dir-prefix): the crossing point is the pattern's
|
||||
// immediate parent. For `plugins/*/data.json` that is `plugins/<id>` — so
|
||||
// listing a plugin's own dir merges in this client's private `data.json`,
|
||||
// while listing `plugins/` itself does not (every plugin dir already
|
||||
// exists at the shared identity path, because the CODE is shared).
|
||||
return this.privatePatterns.some(p => matchesPatternExact(parentDirOf(p), normalized));
|
||||
}
|
||||
|
||||
// ─── translation ─────────────────────────────────────────────────────────
|
||||
|
|
@ -245,13 +290,21 @@ export class PathMapper {
|
|||
return { primary: this.toRemote(vaultPath), mergeFromUser: false };
|
||||
}
|
||||
if (this.isCrossingPoint(normalized)) {
|
||||
// The private subtree mirrors the configDir-relative path, so the
|
||||
// crossing point's counterpart is `privateRoot/<same relative path>`.
|
||||
// For the configDir itself that relative path is empty → privateRoot.
|
||||
// For `<configDir>/plugins/<id>` it is `privateRoot/plugins/<id>`.
|
||||
const rest = normalized.startsWith(this.configDirSlash)
|
||||
? normalized.slice(this.configDirSlash.length)
|
||||
: '';
|
||||
return {
|
||||
primary: vaultPath,
|
||||
mergeFromUser: true,
|
||||
userSubtree: this.privateRoot,
|
||||
// Hide the user-subdir entry from the primary listing so
|
||||
// other clients' subtrees never surface.
|
||||
hideUserDirName: PRIVATE_USER_SUBDIR,
|
||||
userSubtree: rest ? `${this.privateRoot}/${rest}` : this.privateRoot,
|
||||
// Only the configDir itself has the `user` sibling to hide, so other
|
||||
// clients' subtrees never surface. Deeper crossing points (a plugin's
|
||||
// own dir) have no such sibling.
|
||||
hideUserDirName: normalized === this.configDir ? PRIVATE_USER_SUBDIR : undefined,
|
||||
};
|
||||
}
|
||||
return { primary: vaultPath, mergeFromUser: false };
|
||||
|
|
@ -260,6 +313,23 @@ export class PathMapper {
|
|||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Segment-wise EXACT match (same depth, `*` matching any one segment).
|
||||
* Unlike `matchesPrivatePattern` this never matches a deeper path, so it
|
||||
* is safe for crossing-point detection where a dir-prefix match would
|
||||
* wrongly flag every ancestor.
|
||||
*/
|
||||
function matchesPatternExact(pattern: string, normalized: string): boolean {
|
||||
const pat = pattern.split('/');
|
||||
const seg = normalized.split('/');
|
||||
if (pat.length !== seg.length) return false;
|
||||
for (let i = 0; i < pat.length; i++) {
|
||||
if (pat[i] === '*') continue;
|
||||
if (pat[i] !== seg[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function stripLeadingSlash(p: string): string {
|
||||
return p.startsWith('/') ? p.slice(1) : p;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,8 +60,12 @@ export interface BootstrapResult {
|
|||
* surfaces the one-time "restart Obsidian" notice.
|
||||
*/
|
||||
migrated: boolean;
|
||||
/** How the plugin source landed in the shadow vault. */
|
||||
pluginInstallMethod: 'symlink' | 'copy';
|
||||
/**
|
||||
* How the plugin source landed in the shadow vault. `in-place` means
|
||||
* source and target were the same directory (bootstrap re-run from
|
||||
* inside the shadow window) so nothing needed installing.
|
||||
*/
|
||||
pluginInstallMethod: 'symlink' | 'copy' | 'in-place';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1164,7 +1168,7 @@ export class ShadowVaultBootstrap {
|
|||
* writes the per-vault data.json into pluginDir as a real file,
|
||||
* leaving the source vault's data.json untouched.
|
||||
*/
|
||||
private installPlugin(pluginDir: string): 'symlink' | 'copy' {
|
||||
private installPlugin(pluginDir: string): 'symlink' | 'copy' | 'in-place' {
|
||||
// If pluginDir is a stale whole-dir symlink from an older build
|
||||
// (or a previous run of this same code on an older version),
|
||||
// unlink it — DO NOT rmSync, that would follow the link and
|
||||
|
|
@ -1178,6 +1182,23 @@ export class ShadowVaultBootstrap {
|
|||
// Doesn't exist yet, fine.
|
||||
}
|
||||
|
||||
// Self-install guard: when the bootstrap runs from INSIDE the
|
||||
// shadow window it targets, sourcePluginDir IS pluginDir. The
|
||||
// rm+symlink cycle below would then delete each real plugin file
|
||||
// and replace it with a symlink pointing at its own path — a
|
||||
// self-referential link Obsidian can't resolve, so the plugin
|
||||
// "disappears" on the next start. The bundle is already here;
|
||||
// there is nothing to install.
|
||||
try {
|
||||
if (fs.realpathSync(this.sourcePluginDir) === fs.realpathSync(pluginDir)) {
|
||||
return 'in-place';
|
||||
}
|
||||
} catch {
|
||||
// Either side unresolvable (e.g. pluginDir doesn't exist yet, or
|
||||
// the stale whole-dir symlink was just unlinked) → not the same
|
||||
// dir; proceed with a normal install.
|
||||
}
|
||||
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
const sharedFiles = ['main.js', 'manifest.json', 'styles.css'];
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ describe('PathMapper.isPrivate', () => {
|
|||
// community-plugins.json stays shared (round-tripped with a forced
|
||||
// remote-ssh union), so it is NOT redirected per-client.
|
||||
expect(m.isPrivate('.obsidian/community-plugins.json')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/plugins/myplugin/data.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('tolerates a leading slash on the input', () => {
|
||||
|
|
@ -82,6 +81,63 @@ describe('PathMapper.isPrivate', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #342 / #429 — a plugin's SETTINGS go per-device: `Plugin.saveData()` fires on
|
||||
// every settings change, so a shared `plugins/<id>/data.json` is the same
|
||||
// perpetual write-conflict the four core config files were redirected to
|
||||
// escape. Its CODE must stay SHARED, or a plugin installed on one machine
|
||||
// stops loading on the others (that round-trip is PLUGIN_BINARY_FILES).
|
||||
describe('PathMapper — plugin settings vs plugin code (#342 / #429)', () => {
|
||||
const m = new PathMapper(ID);
|
||||
|
||||
it('privatises a plugin data.json', () => {
|
||||
expect(m.isPrivate('.obsidian/plugins/claudian/data.json')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/plugins/tasknotes/data.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the plugin code SHARED', () => {
|
||||
expect(m.isPrivate('.obsidian/plugins/claudian/main.js')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/plugins/claudian/manifest.json')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/plugins/claudian/styles.css')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not over-match: `*` spans exactly one segment', () => {
|
||||
expect(m.isPrivate('.obsidian/plugins')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/plugins/claudian')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/plugins/a/b/data.json')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/data.json')).toBe(false);
|
||||
expect(m.isPrivate('Notes/data.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('redirects data.json per-client, keeping code at the identity path', () => {
|
||||
expect(m.toRemote('.obsidian/plugins/claudian/data.json'))
|
||||
.toBe('.obsidian/user/host-a/plugins/claudian/data.json');
|
||||
expect(m.toRemote('.obsidian/plugins/claudian/main.js'))
|
||||
.toBe('.obsidian/plugins/claudian/main.js');
|
||||
});
|
||||
|
||||
it('round-trips through toVault', () => {
|
||||
const remote = m.toRemote('.obsidian/plugins/claudian/data.json');
|
||||
expect(m.toVault(remote)).toBe('.obsidian/plugins/claudian/data.json');
|
||||
});
|
||||
|
||||
it('listing a plugin dir merges in this client private subtree', () => {
|
||||
const plan = m.resolveListing('.obsidian/plugins/claudian');
|
||||
expect(plan.primary).toBe('.obsidian/plugins/claudian');
|
||||
expect(plan.mergeFromUser).toBe(true);
|
||||
expect(plan.userSubtree).toBe('.obsidian/user/host-a/plugins/claudian');
|
||||
// The `user` sibling only exists at the configDir level, so nothing to
|
||||
// hide at this depth.
|
||||
expect(plan.hideUserDirName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still hides the user subdir when listing the configDir itself', () => {
|
||||
const plan = m.resolveListing('.obsidian');
|
||||
expect(plan.mergeFromUser).toBe(true);
|
||||
expect(plan.userSubtree).toBe('.obsidian/user/host-a');
|
||||
expect(plan.hideUserDirName).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PathMapper.isCrossingPoint', () => {
|
||||
it('flags `.obsidian/` because every private pattern lives directly under it', () => {
|
||||
const m = new PathMapper(ID);
|
||||
|
|
|
|||
|
|
@ -1496,3 +1496,157 @@ describe('SftpDataAdapter — writer reflect (#341)', () => {
|
|||
expect(fake.files['/v/a.md']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -438,6 +438,28 @@ describe('ShadowVaultBootstrap.bootstrap', () => {
|
|||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('regression: re-bootstrap from INSIDE the shadow window (source == target plugin dir) must not self-symlink the plugin files', async () => {
|
||||
// First bootstrap from a normal source vault.
|
||||
const r1 = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const profile = makeProfile({ id: 'p1', name: 'P', remotePath: '/p1' });
|
||||
const first = await r1.bootstrap(profile, [profile]);
|
||||
|
||||
// Simulate the plugin now RUNNING inside that shadow window with
|
||||
// the user clicking Connect again: the running plugin's own dir
|
||||
// (the bootstrap source) IS the shadow's plugin dir. Before the
|
||||
// self-install guard, installPlugin rm'd each real file and left a
|
||||
// symlink pointing at its own path — the plugin then failed to
|
||||
// load ("disappeared") on the next Obsidian start.
|
||||
const r2 = new ShadowVaultBootstrap(scratch.baseDir, first.layout.pluginDir, new ObsidianRegistry(scratch.configPath));
|
||||
const second = await r2.bootstrap(profile, [profile]);
|
||||
|
||||
expect(second.layout.pluginDir).toBe(first.layout.pluginDir);
|
||||
expect(second.pluginInstallMethod).toBe('in-place');
|
||||
// main.js must still resolve to the staged bundle content.
|
||||
const mainJs = path.join(second.layout.pluginDir, 'main.js');
|
||||
expect(fs.readFileSync(mainJs, 'utf-8')).toContain('fake bundled plugin');
|
||||
});
|
||||
|
||||
it('writes [remote-ssh] only into shadow community-plugins.json on first bootstrap (does not auto-install source plugins)', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(scratch.sourceConfigDir, 'community-plugins.json'),
|
||||
|
|
|
|||
|
|
@ -191,5 +191,6 @@
|
|||
"1.1.3": "1.5.0",
|
||||
"1.1.4": "1.5.0",
|
||||
"1.1.5": "1.5.0",
|
||||
"1.1.6": "1.5.0"
|
||||
"1.1.6": "1.5.0",
|
||||
"1.1.7": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ go 1.25.0
|
|||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/image v0.44.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.13.0 // indirect
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
|
|||
|
|
@ -191,5 +191,6 @@
|
|||
"1.1.3": "1.5.0",
|
||||
"1.1.4": "1.5.0",
|
||||
"1.1.5": "1.5.0",
|
||||
"1.1.6": "1.5.0"
|
||||
"1.1.6": "1.5.0",
|
||||
"1.1.7": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue