Merge pull request #461 from sotashimozono/next

release: 1.1.7
This commit is contained in:
sotashimozono 2026-07-14 17:10:10 +09:00 committed by GitHub
commit e0f3117ca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 719 additions and 294 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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);

View file

@ -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();

View file

@ -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;
}

View file

@ -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'];

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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'),

View file

@ -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"
}

View file

@ -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

View file

@ -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=

View file

@ -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"
}