import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; /** * deploy/.env 的「可被 shell 安全载入」保证。 * * 背景(真实故障):install.sh 用 `set -a; . deploy/.env` 载入配置,而模板里 * MODEL_CONVERT_PMX=Hatsune Miku.pmx * 的值含空格却没加引号,这一行被切成「赋值 + 命令」,安装直接失败: * ./deploy/.env: line 53: Miku.pmx: command not found * [install] 第 53 行执行失败(退出码 127) * * 因此这里锁两条线: * 1. deploy/.env.example 必须能被 `set -a; .` 原样载入(回归用例); * 2. deploy/env-lib.sh 的预检能在载入前拦住不安全写法,并给出行号与改法。 */ function findDeployFile(name: string): string { const candidates = [ resolve(process.cwd(), '..', 'deploy', name), // 从 web-server/ 运行(vitest 默认) resolve(process.cwd(), 'deploy', name), // 从仓库根运行 resolve(process.cwd(), '..', '..', 'deploy', name), ]; for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } throw new Error(`找不到 deploy/${name},候选路径:\n${candidates.join('\n')}`); } const EXAMPLE = findDeployFile('.env.example'); const ENV_LIB = findDeployFile('env-lib.sh'); const INSTALL_SH = findDeployFile('install.sh'); const CTL_SH = findDeployFile('ctl.sh'); const dirs: string[] = []; function makeDir(): string { const dir = mkdtempSync(join(tmpdir(), 'opendance-env-')); dirs.push(dir); return dir; } afterEach(() => { while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); }); /** 用真实 shell 载入一个 .env(与 install.sh 的做法一致) */ function sourceEnv(file: string) { return spawnSync( 'bash', [ '-c', 'set -a; . "$1"; set +a; printf "%s\\n" "${MODEL_CONVERT_PMX:-}" "${DB_HOST:-}" "${DB_PASS:-}" "${UPLOAD_MAX_BYTES:-}"', '_', file, ], { encoding: 'utf8', timeout: 15000 }, ); } /** 调用 deploy/env-lib.sh 的预检函数 */ function validate(file: string) { return spawnSync('bash', ['-c', '. "$1"; opendance_validate_env_file "$2"', '_', ENV_LIB, file], { encoding: 'utf8', timeout: 15000, }); } function writeEnv(content: string): string { const file = join(makeDir(), '.env'); writeFileSync(file, content); return file; } describe('deploy/.env.example:必须能被 shell 原样载入', () => { it('`set -a; . .env.example` 成功, 且带空格的值读回完整 (回归: 修复前此处退出码 127)', () => { const result = sourceEnv(EXAMPLE); expect(result.stderr).not.toContain('command not found'); expect(result.status).toBe(0); const [pmx, host, pass, uploadMax] = result.stdout.trimEnd().split('\n'); expect(pmx).toBe('Hatsune Miku.pmx'); expect(host).toBe(''); expect(pass).toBe('<数据库口令>'); expect(uploadMax).toBe('536870912'); }); it('模板整体通过语法预检', () => { const result = validate(EXAMPLE); expect(result.status).toBe(0); expect(result.stderr).toBe(''); }); it('模板里每个赋值行都通过预检(逐行定位,便于以后加字段时发现问题)', () => { const lines = readFileSync(EXAMPLE, 'utf8').split('\n'); const assigns = lines.filter((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l)); expect(assigns.length).toBeGreaterThan(10); // 模板字段数量下限,防止误删 for (const line of assigns) { expect(validate(writeEnv(`${line}\n`)).status, `模板行应通过预检:${line}`).toBe(0); } }); }); describe('deploy/env-lib.sh:载入前的语法预检', () => { it('拦住「值含空格却未加引号」,并给出行号、原文与改法(本次故障的原始写法)', () => { const result = validate(writeEnv('NODE_ENV=production\nMODEL_CONVERT_PMX=Hatsune Miku.pmx\n')); expect(result.status).toBe(1); expect(result.stderr).toContain('第 2 行'); expect(result.stderr).toContain('MODEL_CONVERT_PMX=Hatsune Miku.pmx'); expect(result.stderr).toContain('MODEL_CONVERT_PMX="Hatsune Miku.pmx"'); }); it('拦住 shell 元字符,且对 $( ) 建议单引号(双引号仍会执行)', () => { const result = validate(writeEnv('A=x&y\nB=$(id)\n')); expect(result.status).toBe(1); expect(result.stderr).toContain('第 1 行'); expect(result.stderr).toContain('第 2 行'); expect(result.stderr).toContain("B='$(id)'"); }); it('拦住未闭合引号', () => { const result = validate(writeEnv('A="unclosed\n')); expect(result.status).toBe(1); expect(result.stderr).toContain('双引号未闭合'); }); it('不误伤合法写法:* % 口令、IPv4、路径、空值、单引号、引号 + 行尾注释', () => { const result = validate( writeEnv( [ 'DB_PASS=S3cr3t*Pwd%1', 'DB_HOST=127.0.0.1', 'DB_PORT=3306', 'STORAGE_DIR=/var/lib/opendance/storage', 'EMPTY=', "WITH_SPACE='a b c'", 'QUOTED_COMMENT="x y" # 行尾注释', 'MODEL_CONVERT_PMX="Hatsune Miku.pmx"', '# 注释与空行也必须放过', '', ].join('\n'), ), ); expect(result.stderr).toBe(''); expect(result.status).toBe(0); }); it('拦住非「键=值」的行号并指出原因', () => { const result = validate(writeEnv('A=1\n这一行不是赋值\n')); expect(result.status).toBe(1); expect(result.stderr).toContain('第 2 行'); expect(result.stderr).toContain('不是「键=值」形式'); }); it('文件不存在时返回 1(而不是静默通过)', () => { const result = validate(join(makeDir(), 'missing.env')); expect(result.status).toBe(1); expect(result.stderr).toContain('找不到配置文件'); }); }); describe('载入顺序:install.sh / ctl.sh 必须先预检再 source', () => { it('install.sh 在 `. ./deploy/.env` 之前调用预检', () => { const text = readFileSync(INSTALL_SH, 'utf8'); const check = text.indexOf('opendance_validate_env_file "./deploy/.env"'); const source = text.indexOf('. ./deploy/.env'); expect(check).toBeGreaterThan(-1); expect(source).toBeGreaterThan(-1); expect(check).toBeLessThan(source); // .env 被 source 时 LINENO 指向 .env 的行号,报错必须带上文件名才定位得到 expect(text).toContain('${BASH_SOURCE[0]}'); }); it('install.sh 在迁移前再次预检安装目录里的 .env', () => { const text = readFileSync(INSTALL_SH, 'utf8'); expect(text).toContain('opendance_validate_env_file "$APP_DIR/deploy/.env"'); }); it('ctl.sh 载入 .env 前调用预检,且引用同目录的 env-lib.sh', () => { const text = readFileSync(CTL_SH, 'utf8'); expect(text).toContain('"$SCRIPT_DIR/env-lib.sh"'); const check = text.indexOf('opendance_validate_env_file "$APP_DIR/deploy/.env"'); const source = text.indexOf('. "$APP_DIR/deploy/.env"'); expect(check).toBeGreaterThan(-1); expect(source).toBeGreaterThan(-1); expect(check).toBeLessThan(source); }); });