- execute-command/upload/download accept host/port/username/password inline to open an ephemeral connection for a single call - add redactSecret/redactSecrets to mask credentials in logs and errors - logger log()/handleError() accept optional secrets[] for redaction - add SSH_CONFIG_MISSING error code and --password-from-env CLI flag - dynamic-mode startup: server runs with no static config
311 lines
9.9 KiB
JavaScript
311 lines
9.9 KiB
JavaScript
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert';
|
|
import { lookupSshConfig } from '../build/utils/ssh-config-parser.js';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
let fixturesDir;
|
|
|
|
describe('SSH Config Parser', () => {
|
|
let testConfigPath;
|
|
let testConfigWithIncludePath;
|
|
let includedConfigPath;
|
|
let originalHome;
|
|
|
|
before(() => {
|
|
originalHome = process.env.HOME;
|
|
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-config-test-'));
|
|
|
|
testConfigPath = path.join(fixturesDir, 'ssh-config-basic');
|
|
testConfigWithIncludePath = path.join(fixturesDir, 'ssh-config-include');
|
|
includedConfigPath = path.join(fixturesDir, 'ssh-config-included');
|
|
|
|
// 基本测试配置
|
|
fs.writeFileSync(testConfigPath, [
|
|
'# 多别名测试',
|
|
'Host dev staging',
|
|
' HostName 192.168.1.100',
|
|
' Port 2222',
|
|
' User devuser',
|
|
' IdentityFile ~/.ssh/dev_key',
|
|
'',
|
|
'# 单别名测试',
|
|
'Host prod',
|
|
' HostName 10.0.0.50',
|
|
' User produser',
|
|
' IdentityFile ~/.ssh/prod_key',
|
|
'',
|
|
'# 通配符测试',
|
|
'Host *.example.com',
|
|
' User wildcarduser',
|
|
' Port 2200',
|
|
'',
|
|
'# 全局默认值',
|
|
'Host *',
|
|
' Port 22',
|
|
' User defaultuser',
|
|
].join('\n'));
|
|
|
|
// 被包含的配置文件
|
|
fs.writeFileSync(includedConfigPath, [
|
|
'Host included-host',
|
|
' HostName 172.16.0.1',
|
|
' Port 3333',
|
|
' User includeduser',
|
|
].join('\n'));
|
|
|
|
// 带 Include 的配置文件
|
|
fs.writeFileSync(testConfigWithIncludePath, [
|
|
`Include ${includedConfigPath}`,
|
|
'',
|
|
'Host main-host',
|
|
' HostName 192.168.1.1',
|
|
' User mainuser',
|
|
'',
|
|
'Host *',
|
|
' Port 22',
|
|
].join('\n'));
|
|
});
|
|
|
|
after(() => {
|
|
process.env.HOME = originalHome;
|
|
fs.rmSync(fixturesDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('基本功能', () => {
|
|
it('应该正确解析单个 Host 别名', () => {
|
|
const config = lookupSshConfig('prod', testConfigPath);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.hostName, '10.0.0.50');
|
|
assert.strictEqual(config.user, 'produser');
|
|
assert.strictEqual(config.identityFile, path.join(os.homedir(), '.ssh', 'prod_key'));
|
|
assert.strictEqual(config.port, 22); // 从 Host * fallback
|
|
});
|
|
|
|
it('应该正确解析多别名 Host 行 - dev', () => {
|
|
const config = lookupSshConfig('dev', testConfigPath);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.hostName, '192.168.1.100');
|
|
assert.strictEqual(config.port, 2222);
|
|
assert.strictEqual(config.user, 'devuser');
|
|
});
|
|
|
|
it('应该正确解析多别名 Host 行 - staging', () => {
|
|
const config = lookupSshConfig('staging', testConfigPath);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.hostName, '192.168.1.100');
|
|
assert.strictEqual(config.port, 2222);
|
|
assert.strictEqual(config.user, 'devuser');
|
|
});
|
|
|
|
it('应该支持通配符匹配', () => {
|
|
const config = lookupSshConfig('server.example.com', testConfigPath);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.user, 'wildcarduser');
|
|
assert.strictEqual(config.port, 2200);
|
|
});
|
|
|
|
it('应该使用 Host * 作为默认值', () => {
|
|
const config = lookupSshConfig('unknown-host', testConfigPath);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.user, 'defaultuser');
|
|
assert.strictEqual(config.port, 22);
|
|
assert.strictEqual(config.hostName, undefined);
|
|
});
|
|
|
|
it('通配符匹配应转义正则特殊字符', () => {
|
|
const tempConfig = path.join(fixturesDir, 'special-pattern-config');
|
|
fs.writeFileSync(tempConfig, [
|
|
'Host host[1]',
|
|
' User literaluser',
|
|
'',
|
|
'Host *',
|
|
' User defaultuser',
|
|
].join('\n'));
|
|
|
|
const literalConfig = lookupSshConfig('host[1]', tempConfig);
|
|
const regexLikeConfig = lookupSshConfig('host1', tempConfig);
|
|
|
|
assert.ok(literalConfig);
|
|
assert.strictEqual(literalConfig.user, 'literaluser');
|
|
assert.ok(regexLikeConfig);
|
|
assert.strictEqual(regexLikeConfig.user, 'defaultuser');
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
|
|
it('应该支持 Host 中的否定模式', () => {
|
|
const tempConfig = path.join(fixturesDir, 'negated-pattern-config');
|
|
fs.writeFileSync(tempConfig, [
|
|
'Host *.example.com !blocked.example.com',
|
|
' User wildcarduser',
|
|
'',
|
|
'Host *',
|
|
' User defaultuser',
|
|
].join('\n'));
|
|
|
|
const allowedConfig = lookupSshConfig('app.example.com', tempConfig);
|
|
const blockedConfig = lookupSshConfig('blocked.example.com', tempConfig);
|
|
|
|
assert.ok(allowedConfig);
|
|
assert.strictEqual(allowedConfig.user, 'wildcarduser');
|
|
assert.ok(blockedConfig);
|
|
assert.strictEqual(blockedConfig.user, 'defaultuser');
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
});
|
|
|
|
describe('Include 指令', () => {
|
|
it('应该正确处理 Include 指令', () => {
|
|
const config = lookupSshConfig('included-host', testConfigWithIncludePath);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.hostName, '172.16.0.1');
|
|
assert.strictEqual(config.port, 3333);
|
|
assert.strictEqual(config.user, 'includeduser');
|
|
});
|
|
|
|
it('应该在 Include 后继续解析主配置文件', () => {
|
|
const config = lookupSshConfig('main-host', testConfigWithIncludePath);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.hostName, '192.168.1.1');
|
|
assert.strictEqual(config.user, 'mainuser');
|
|
assert.strictEqual(config.port, 22);
|
|
});
|
|
|
|
it('应该静默跳过不存在的 Include 文件', () => {
|
|
const tempConfig = path.join(fixturesDir, 'temp-include-config');
|
|
fs.writeFileSync(tempConfig, [
|
|
'Include /nonexistent/path/config',
|
|
'',
|
|
'Host test',
|
|
' HostName 1.2.3.4',
|
|
].join('\n'));
|
|
|
|
const config = lookupSshConfig('test', tempConfig);
|
|
assert.ok(config, '应该返回非 null 的配置');
|
|
assert.strictEqual(config.hostName, '1.2.3.4');
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
});
|
|
|
|
describe('边界情况', () => {
|
|
it('默认配置文件不存在时应返回 null', () => {
|
|
const fakeHome = fs.mkdtempSync(path.join(fixturesDir, 'fake-home-'));
|
|
process.env.HOME = fakeHome;
|
|
|
|
try {
|
|
const config = lookupSshConfig('any-host');
|
|
assert.strictEqual(config, null);
|
|
} finally {
|
|
process.env.HOME = originalHome;
|
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('显式指定的配置文件不存在时应抛出错误', () => {
|
|
assert.throws(() => {
|
|
lookupSshConfig('any-host', '/nonexistent/config');
|
|
}, /not found/);
|
|
});
|
|
|
|
it('未找到匹配的 Host 时应返回 null', () => {
|
|
const tempConfig = path.join(fixturesDir, 'empty-config');
|
|
fs.writeFileSync(tempConfig, '# Empty config\n');
|
|
|
|
const config = lookupSshConfig('any-host', tempConfig);
|
|
assert.strictEqual(config, null);
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
|
|
it('应该正确展开 ~ 路径', () => {
|
|
const config = lookupSshConfig('dev', testConfigPath);
|
|
assert.ok(config);
|
|
assert.ok(config.identityFile.startsWith(os.homedir()));
|
|
assert.ok(!config.identityFile.includes('~'));
|
|
});
|
|
|
|
it('应该正确处理注释行', () => {
|
|
const tempConfig = path.join(fixturesDir, 'comment-config');
|
|
fs.writeFileSync(tempConfig, [
|
|
'# 这是注释',
|
|
'Host test',
|
|
' HostName 1.2.3.4 # 行内注释',
|
|
' Port 2222',
|
|
].join('\n'));
|
|
|
|
const config = lookupSshConfig('test', tempConfig);
|
|
assert.ok(config);
|
|
assert.strictEqual(config.hostName, '1.2.3.4');
|
|
assert.strictEqual(config.port, 2222);
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
|
|
it('应该正确处理空白行和缩进', () => {
|
|
const tempConfig = path.join(fixturesDir, 'whitespace-config');
|
|
fs.writeFileSync(tempConfig, [
|
|
'',
|
|
' Host test ',
|
|
' HostName 1.2.3.4 ',
|
|
'',
|
|
' Port 2222 ',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const config = lookupSshConfig('test', tempConfig);
|
|
assert.ok(config);
|
|
assert.strictEqual(config.hostName, '1.2.3.4');
|
|
assert.strictEqual(config.port, 2222);
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
});
|
|
|
|
describe('First-match-wins 语义', () => {
|
|
it('应该使用第一个匹配的值', () => {
|
|
const tempConfig = path.join(fixturesDir, 'priority-config');
|
|
fs.writeFileSync(tempConfig, [
|
|
'Host test',
|
|
' Port 2222',
|
|
' User firstuser',
|
|
'',
|
|
'Host test',
|
|
' Port 3333',
|
|
' User seconduser',
|
|
'',
|
|
'Host *',
|
|
' Port 22',
|
|
].join('\n'));
|
|
|
|
const config = lookupSshConfig('test', tempConfig);
|
|
assert.ok(config);
|
|
assert.strictEqual(config.port, 2222);
|
|
assert.strictEqual(config.user, 'firstuser');
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
|
|
it('特定 Host 的值应优先于 Host *', () => {
|
|
const tempConfig = path.join(fixturesDir, 'specific-priority-config');
|
|
fs.writeFileSync(tempConfig, [
|
|
'Host specific',
|
|
' Port 2222',
|
|
'',
|
|
'Host *',
|
|
' Port 22',
|
|
' User globaluser',
|
|
].join('\n'));
|
|
|
|
const config = lookupSshConfig('specific', tempConfig);
|
|
assert.ok(config);
|
|
assert.strictEqual(config.port, 2222); // 来自 Host specific
|
|
assert.strictEqual(config.user, 'globaluser'); // 来自 Host *
|
|
|
|
fs.unlinkSync(tempConfig);
|
|
});
|
|
});
|
|
});
|