fix(utf8): закрыть lossy-границы конфигурации и API
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { prepareDiagnosticFile } from "../src/commands/diagnostics";
|
||||
import { runReadOnlyArgvStatus } from "../src/lib/process";
|
||||
import { redactEnv } from "../src/lib/redaction";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function source(bytes: Uint8Array): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "hy2xs-diagnostics-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "hy2xs.env");
|
||||
await writeFile(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("безопасная подготовка diagnostics", () => {
|
||||
test("секрет редактируется до выдачи текста на запись", async () => {
|
||||
const path = await source(new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n"));
|
||||
const prepared = await prepareDiagnosticFile(path, redactEnv);
|
||||
expect(prepared).toContain("<redacted>");
|
||||
expect(prepared).not.toContain("top-secret");
|
||||
});
|
||||
|
||||
test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => {
|
||||
const path = await source(new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff]));
|
||||
const prepared = await prepareDiagnosticFile(path, redactEnv);
|
||||
expect(prepared).toContain("некорректный UTF-8");
|
||||
expect(prepared).not.toContain("SECRET=");
|
||||
expect(prepared).not.toContain("�");
|
||||
});
|
||||
|
||||
test("вывод внешней команды ограничен по памяти и помечен как обрезанный", async () => {
|
||||
const result = await runReadOnlyArgvStatus(
|
||||
[process.execPath, "-e", `process.stdout.write("x".repeat(128))`],
|
||||
16
|
||||
);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("x".repeat(16));
|
||||
expect(result.stdoutTruncated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,8 @@ describe("запись обратима разбором", () => {
|
||||
});
|
||||
|
||||
test("непредставимое значение — отказ записи, а не потеря части секрета", () => {
|
||||
// Отвергается то, что НЕ ЗАГРУЗИТ systemd, а не то, что нам не нравится.
|
||||
// Отвергается документированное множество EnvironmentFile, а не то, что
|
||||
// нам не нравится.
|
||||
for (const rejected of [
|
||||
String.fromCodePoint(0x0000),
|
||||
String.fromCodePoint(0xfdd0),
|
||||
@@ -157,10 +158,10 @@ describe("запись обратима разбором", () => {
|
||||
]) {
|
||||
expect(() =>
|
||||
assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
|
||||
).toThrow(/systemd refuses to load/);
|
||||
).toThrow(/documented systemd EnvironmentFile contract/);
|
||||
expect(() =>
|
||||
formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
|
||||
).toThrow(/systemd refuses to load/);
|
||||
).toThrow(/documented systemd EnvironmentFile contract/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -172,11 +173,11 @@ describe("запись обратима разбором", () => {
|
||||
expect(new TextEncoder().encode(lone)).toEqual(new Uint8Array([0xef, 0xbf, 0xbd]));
|
||||
expect(isEnvTransportable(lone)).toBe(false);
|
||||
expect(() => assertEnvTransportable("HY2XS_ADMIN_CON_PASS", `abcde${lone}`)).toThrow(
|
||||
/systemd refuses to load/
|
||||
/documented systemd EnvironmentFile contract/
|
||||
);
|
||||
});
|
||||
|
||||
// Домен транспорта — ЧУЖОЕ множество, и он не шире и не уже множества systemd.
|
||||
// Домен транспорта — ЧУЖОЕ документированное множество systemd.
|
||||
//
|
||||
// Управляющие символы формат несёт: внутри двойных кавычек перевод строки
|
||||
// накапливается как обычный байт и переживает round-trip. Запрещает их
|
||||
@@ -191,10 +192,9 @@ describe("запись обратима разбором", () => {
|
||||
expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value);
|
||||
}
|
||||
|
||||
// U+FEFF формат тоже несёт: 0xFEFF & 0xFFFE === 0xFEFE, и unichar_is_valid
|
||||
// его принимает. Комментарий `/* BOM */` в исходнике systemd относится к
|
||||
// U+xFFFE и является его собственной неточностью.
|
||||
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(true);
|
||||
// Публичный контракт EnvironmentFile запрещает U+FEFF. Реализация v257.13
|
||||
// случайно пропускает его; продукт не закрепляет это расхождение.
|
||||
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(false);
|
||||
});
|
||||
|
||||
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
||||
@@ -260,6 +260,16 @@ describe("непредставимая конфигурация отвергае
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("исходный U+FEFF отвергается до разбора, даже вне используемого значения", () => {
|
||||
for (const source of [
|
||||
`\uFEFF${envText()}`,
|
||||
`${envText()}\n# comment \uFEFF\n`,
|
||||
`${envText()}\nUNKNOWN_KEY="value\uFEFF"\n`
|
||||
]) {
|
||||
expect(() => parseRuntimeEnv(source)).toThrow(/U\+FEFF/);
|
||||
}
|
||||
});
|
||||
|
||||
// Ограничение принадлежит ФОРМАТУ, а не полю пароля: любой параметр сломал бы
|
||||
// загрузку юнита тем же способом. HY2XS_ADMIN_CON_PASS проходит через
|
||||
// requireValue и никаких проверок содержимого раньше не имел вовсе.
|
||||
@@ -271,7 +281,7 @@ describe("непредставимая конфигурация отвергае
|
||||
"HY2XS_ACME_EMAIL"
|
||||
]) {
|
||||
expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow(
|
||||
/systemd refuses to load/
|
||||
/documented systemd EnvironmentFile contract/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InvalidUtf8Error } from "../src/lib/fs";
|
||||
import { readPackageValue } from "../src/lib/packageMeta";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function packageDir(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "hy2xs-metadata-"));
|
||||
directories.push(directory);
|
||||
await mkdir(join(directory, "metadata"));
|
||||
return directory;
|
||||
}
|
||||
|
||||
describe("metadata пакета", () => {
|
||||
test("отсутствующее необязательное значение использует fallback", async () => {
|
||||
const directory = await packageDir();
|
||||
expect(await readPackageValue(directory, "missing", "unknown")).toBe("unknown");
|
||||
});
|
||||
|
||||
test("повреждённый UTF-8 не маскируется fallback-значением", async () => {
|
||||
const directory = await packageDir();
|
||||
await writeFile(join(directory, "metadata", "package.version"), new Uint8Array([0x31, 0xff]));
|
||||
await expect(readPackageValue(directory, "package.version", "unknown")).rejects.toBeInstanceOf(
|
||||
InvalidUtf8Error
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InvalidUtf8Error, readText } from "../src/lib/fs";
|
||||
import { disableReadOnlyGuard } from "../src/lib/guard";
|
||||
import { preflightInstall } from "../src/commands/preflight-install";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function fixture(bytes: Uint8Array): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "hy2xs-utf8-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "config.env");
|
||||
await writeFile(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("строгое чтение текстовых файлов", () => {
|
||||
for (const [name, bytes] of [
|
||||
["байт FF", new Uint8Array([0x61, 0xff, 0x62])],
|
||||
["оборванная последовательность C3", new Uint8Array([0x61, 0xc3])],
|
||||
["UTF-8-кодирование суррогата", new Uint8Array([0x61, 0xed, 0xa0, 0x80])]
|
||||
] as const) {
|
||||
test(`отвергает ${name} без U+FFFD`, async () => {
|
||||
const path = await fixture(bytes);
|
||||
await expect(readText(path)).rejects.toBeInstanceOf(InvalidUtf8Error);
|
||||
});
|
||||
}
|
||||
|
||||
test("сохраняет начальный BOM как U+FEFF для транспортной проверки", async () => {
|
||||
const path = await fixture(new Uint8Array([0xef, 0xbb, 0xbf, 0x41, 0x3d, 0x31, 0x0a]));
|
||||
expect(await readText(path)).toBe("\uFEFFA=1\n");
|
||||
});
|
||||
|
||||
test("не путает настоящий U+FFFD с ошибкой декодирования", async () => {
|
||||
const text = "A=abcde�\n";
|
||||
const path = await fixture(new TextEncoder().encode(text));
|
||||
expect(await readText(path)).toBe(text);
|
||||
});
|
||||
|
||||
test("byte-level отказ preflight не изменяет каталог пакета", async () => {
|
||||
for (const bytes of [
|
||||
new Uint8Array([0xff]),
|
||||
new Uint8Array([0xc3]),
|
||||
new Uint8Array([0xed, 0xa0, 0x80])
|
||||
]) {
|
||||
const path = await fixture(bytes);
|
||||
const packageDir = join(path, "..");
|
||||
const before = await readdir(packageDir);
|
||||
try {
|
||||
await expect(
|
||||
preflightInstall({
|
||||
packageDir,
|
||||
sourceConfigPath: path,
|
||||
runtimeConfigPath: path,
|
||||
nonInteractive: true,
|
||||
skipFirewall: false,
|
||||
skipServiceStart: false,
|
||||
skipSmoke: false
|
||||
})
|
||||
).rejects.toBeInstanceOf(InvalidUtf8Error);
|
||||
} finally {
|
||||
disableReadOnlyGuard();
|
||||
}
|
||||
expect(await readdir(packageDir)).toEqual(before);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user