mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Addresses /review-pr findings on #447 and the E2E smoke failure they caused. - F1 (code-reviewer + silent-failure): the connect fast-path trusted existsSync alone, re-opening the "deploy an unverified binary" gap one layer above DaemonDownloader's fixed sha-check. Now records `daemonBinarySha` and re-hashes the cached file on the fast path; a post-write-corrupted binary fails the check and is re-fetched (network-free). Version + sha must both match to reuse. - F2 (silent-failure): a saveSettings() failure after a successful download was caught by the generic "download failed -> SFTP" handler and misreported. Marker persistence is now its own non-fatal try — the verified binary is still returned; a persist failure just re-verifies next connect. - F3 (comment-analyzer): corrected the dev-install marker comment (a stale binary is refreshed only when its sha differs, not "always on a bump"). - F4/F5 (pr-test): new ensureDaemonBinary.test.ts covers the locateDaemonBinary .dev-daemon gate and the fast-path reuse/skip (sha match, sha mismatch, version mismatch). E2E fix: the new locateDaemonBinary `.dev-daemon` gate broke the Obsidian smoke run — E2E stages the daemon via `build:server` (no dev-install), so the staged binary was unmarked -> gated out -> no RPC -> the live-reflect specs failed. build-server.mjs now writes the `.dev-daemon` marker; the E2E vault-scaffold copies server-bin/ verbatim so it rides along. (Release daemons are built via the Makefile, never marked — correct: users' downloads stay subject to the sha refresh.) tsc/lint/vitest green (1193). Still beta.14 (same PR #447). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
5.4 KiB
TypeScript
134 lines
5.4 KiB
TypeScript
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { createHash } from 'crypto';
|
|
|
|
// main.ts pulls in RemoteTerminalView (extends the obsidian ItemView the unit
|
|
// mock doesn't model) — stub it so the import graph resolves. Same shim the
|
|
// sibling connectProfile.daemon-downgrade.test.ts uses.
|
|
vi.mock('../src/ui/RemoteTerminalView', () => ({
|
|
RemoteTerminalView: class {},
|
|
VIEW_TYPE_REMOTE_TERMINAL: 'remote-terminal',
|
|
}));
|
|
|
|
import { App } from 'obsidian';
|
|
import RemoteSshPlugin from '../src/main';
|
|
|
|
/** A fake SftpClient whose `exec` answers the daemon's `uname` probe. */
|
|
const linuxAmd64Client = {
|
|
exec: async (cmd: string) => ({
|
|
stdout: cmd === 'uname -s' ? 'Linux' : 'x86_64',
|
|
stderr: '',
|
|
exitCode: 0,
|
|
}),
|
|
};
|
|
|
|
const tmpDirs: string[] = [];
|
|
function scratchPluginDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `rs-daemon-${process.pid}-`));
|
|
tmpDirs.push(dir);
|
|
fs.mkdirSync(path.join(dir, 'server-bin'), { recursive: true });
|
|
return dir;
|
|
}
|
|
afterEach(() => {
|
|
while (tmpDirs.length) {
|
|
try { fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
const BIN_NAME = 'obsidian-remote-server-linux-amd64';
|
|
|
|
describe('RemoteSshPlugin.locateDaemonBinary — .dev-daemon marker gate', () => {
|
|
it('returns null for an UNMARKED staged binary (a download must not masquerade as a dev build)', () => {
|
|
const plugin = new RemoteSshPlugin(new App() as never);
|
|
const dir = scratchPluginDir();
|
|
fs.writeFileSync(path.join(dir, 'server-bin', BIN_NAME), 'ELF');
|
|
const p = plugin as unknown as { pluginDir: () => string; locateDaemonBinary: () => string | null };
|
|
p.pluginDir = () => dir;
|
|
|
|
expect(p.locateDaemonBinary()).toBeNull();
|
|
});
|
|
|
|
it('returns the binary path once dev-install has written the .dev-daemon marker', () => {
|
|
const plugin = new RemoteSshPlugin(new App() as never);
|
|
const dir = scratchPluginDir();
|
|
const bin = path.join(dir, 'server-bin', BIN_NAME);
|
|
fs.writeFileSync(bin, 'ELF');
|
|
fs.writeFileSync(path.join(dir, 'server-bin', '.dev-daemon'), '');
|
|
const p = plugin as unknown as { pluginDir: () => string; locateDaemonBinary: () => string | null };
|
|
p.pluginDir = () => dir;
|
|
|
|
expect(p.locateDaemonBinary()).toBe(bin);
|
|
});
|
|
});
|
|
|
|
describe('RemoteSshPlugin.ensureDaemonBinary — version + sha fast path', () => {
|
|
interface Internals {
|
|
pluginDir: () => string;
|
|
manifest: { version: string };
|
|
settings: Record<string, unknown>;
|
|
saveData: (d: unknown) => Promise<void>;
|
|
confirmDaemonDownload: (v: string) => Promise<boolean>;
|
|
ensureDaemonBinary: (client: unknown) => Promise<string | null>;
|
|
}
|
|
|
|
function makePlugin(dir: string, settings: Record<string, unknown>): Internals {
|
|
const plugin = new RemoteSshPlugin(new App() as never);
|
|
const p = plugin as unknown as Internals;
|
|
p.pluginDir = () => dir;
|
|
p.manifest = { version: '1.2.0' };
|
|
p.settings = settings;
|
|
p.saveData = async () => { /* no-op persist */ };
|
|
return p;
|
|
}
|
|
|
|
it('reuses the cached binary WITHOUT any download when version + sha both match', async () => {
|
|
const dir = scratchPluginDir();
|
|
const bin = path.join(dir, 'server-bin', BIN_NAME);
|
|
const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01]);
|
|
fs.writeFileSync(bin, bytes);
|
|
const sha = createHash('sha256').update(bytes).digest('hex');
|
|
const p = makePlugin(dir, { daemonBinaryVersion: '1.2.0', daemonBinarySha: sha });
|
|
|
|
// No requestUrl is mocked here: a fall-through to the download path would
|
|
// throw, so returning the cached path proves the fast path fired.
|
|
const result = await p.ensureDaemonBinary(linuxAmd64Client);
|
|
expect(result).toBe(bin);
|
|
});
|
|
|
|
it('does NOT take the fast path when the cached bytes no longer match the sha (post-write corruption)', async () => {
|
|
const dir = scratchPluginDir();
|
|
// File whose real sha does NOT match the recorded marker → integrity fail.
|
|
fs.writeFileSync(path.join(dir, 'server-bin', BIN_NAME), Buffer.from([0x00, 0x11, 0x22]));
|
|
const p = makePlugin(dir, {
|
|
daemonBinaryVersion: '1.2.0',
|
|
daemonBinarySha: 'f'.repeat(64), // deliberately wrong
|
|
daemonDownloadConsented: false,
|
|
});
|
|
// Fall-through reaches the consent gate; decline it to end deterministically
|
|
// on the "staying on SFTP" path (no network) rather than the fast path.
|
|
p.confirmDaemonDownload = async () => false;
|
|
|
|
const result = await p.ensureDaemonBinary(linuxAmd64Client);
|
|
expect(result).toBeNull(); // fell through (not reused) → declined → SFTP
|
|
});
|
|
|
|
it('does NOT take the fast path when the recorded version differs from the plugin version', async () => {
|
|
const dir = scratchPluginDir();
|
|
const bin = path.join(dir, 'server-bin', BIN_NAME);
|
|
const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
|
fs.writeFileSync(bin, bytes);
|
|
const sha = createHash('sha256').update(bytes).digest('hex');
|
|
// sha matches the file, but the marker is for an OLDER plugin version.
|
|
const p = makePlugin(dir, {
|
|
daemonBinaryVersion: '1.1.0',
|
|
daemonBinarySha: sha,
|
|
daemonDownloadConsented: false,
|
|
});
|
|
p.confirmDaemonDownload = async () => false;
|
|
|
|
const result = await p.ensureDaemonBinary(linuxAmd64Client);
|
|
expect(result).toBeNull(); // version mismatch → re-check path → declined → SFTP
|
|
});
|
|
});
|