Merge pull request #330 from Tencent-Hunyuan/fix/memhub-complete-log-export
fix(memhub): export complete logs from Settings
This commit is contained in:
19 files changed
+296
-29
No files matched your search
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
|
||||
).rejects.toThrow(/untrusted_window/);
|
||||
});
|
||||
|
||||
it("marks a problem and exports a redacted local bundle", async () => {
|
||||
it("marks a problem and exports the complete local log bundle", async () => {
|
||||
const value = await fixture();
|
||||
const mark = await value.handlers.get(
|
||||
"one-memory:mark-diagnostic-incident",
|
||||
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
||||
incident_id: expect.any(String),
|
||||
session_id: expect.any(String),
|
||||
});
|
||||
await writeFile(
|
||||
path.join(value.dataDir, "logs", "core.log"),
|
||||
"2026-10-14 10:31:00 WARNING database wait\n",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(value.dataDir, "logs", "service-error.log"),
|
||||
"worker failed\n",
|
||||
);
|
||||
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
|
||||
{},
|
||||
);
|
||||
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
|
||||
),
|
||||
);
|
||||
expect(summary).toMatchObject({ build: "3800" });
|
||||
const entries = execFileSync(
|
||||
"/usr/bin/unzip",
|
||||
["-Z1", path.join(value.dataDir, zip)],
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n");
|
||||
expect(entries).toContain("logs/core.log");
|
||||
expect(entries).toContain("logs/service-error.log");
|
||||
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
|
||||
|
||||
export async function exportDesktopDiagnostics(
|
||||
options: DiagnosticExportOptions,
|
||||
exportOptions: { includeRawLogs?: boolean } = {},
|
||||
) {
|
||||
const build = options.buildNumber ?? options.productVersion;
|
||||
const target = path.join(
|
||||
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
|
||||
dataDir: options.dataDir,
|
||||
target,
|
||||
productVersion: build,
|
||||
includeRawLogs: exportOptions.includeRawLogs === true,
|
||||
...(options.commit === undefined ? {} : { commit: options.commit }),
|
||||
...(options.coreSha256 === undefined
|
||||
? {}
|
||||
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
|
||||
options.ipc.handle(EXPORT, async (event, ...args) => {
|
||||
if (args.length) throw new Error("diagnostic_arguments_rejected");
|
||||
options.requireTrustedWindow(event);
|
||||
return exportDesktopDiagnostics(options);
|
||||
return exportDesktopDiagnostics(options, { includeRawLogs: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
|
||||
});
|
||||
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
|
||||
render(<HealthPanel imports={imports()} />);
|
||||
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
|
||||
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
|
||||
|
||||
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
|
||||
<Download size={16} aria-hidden="true" />
|
||||
{t("nativeExportLogs")}
|
||||
</R6Button>
|
||||
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
|
||||
</div>
|
||||
|
||||
<section className="health-block" aria-label={t("healthLocal")}>
|
||||
|
||||
@@ -169,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
||||
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
||||
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
|
||||
expect(screen.getByText("1.0.4")).toBeTruthy();
|
||||
expect(screen.getByText("8")).toBeTruthy();
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
|
||||
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
|
||||
|
||||
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
|
||||
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug 级别的
|
||||
`hy_memory.log`;后者如确需分享,必须由开发人员另行确认并脱敏。
|
||||
App 的“导出日志”会同时打包安全投影和 `~/.one-memory/logs/` 下的完整运行日志,供可信开发人员
|
||||
还原本机故障。原始日志可能包含会话正文和本机路径,发送前必须确认接收方;不要公开分享,也不要
|
||||
发送整个 `~/.one-memory` 数据目录。SDK 的 debug 级 `hy_memory.log` 不在该 ZIP 中;如确需分享,
|
||||
必须由开发人员另行确认并脱敏。只需要脱敏事件时使用 `memhub logs export`。
|
||||
|
||||
## 1. Log level 设什么
|
||||
|
||||
@@ -41,8 +42,8 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
|
||||
|
||||
## 2. 日志在哪里
|
||||
|
||||
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
|
||||
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
|
||||
App 内推荐直接使用“运行诊断 → 导出日志”,它会生成包含完整运行日志的 ZIP。开发人员远程指导
|
||||
且不需要原始日志时,优先使用 `memhub logs`,它会过滤不安全字段。不要手工打包整个数据目录。
|
||||
Core 日志默认位于:
|
||||
|
||||
```text
|
||||
|
||||
@@ -45,13 +45,18 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
|
||||
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
|
||||
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
|
||||
|
||||
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||
CLI 导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
|
||||
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
|
||||
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
|
||||
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
|
||||
路径和凭证形态的字符串。CLI 不复制原始日志或崩溃报告。
|
||||
|
||||
Desktop 设置页的“导出日志”用于开发人员排查本机运行故障。除上述安全投影和状态摘要外,它还会
|
||||
把 One Memory Home 的 `logs/` 目录中全部普通文件及轮转文件放入 ZIP 的 `logs/` 目录。它不跟随
|
||||
符号链接、不递归读取其他目录,也不包含数据库、SDK 数据目录或系统崩溃报告。原始日志可能包含
|
||||
会话正文、本机路径或其他私人信息,ZIP 的 README 必须明确提示只发送给可信的开发人员。
|
||||
|
||||
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
|
||||
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
|
||||
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
|
||||
诊断包。发布使用已设好权限的临时文件原子创建最终目标,已存在的目标不得覆盖,发布后也不存在
|
||||
再 chmod 失败留下伪成功文件的窗口。
|
||||
|
||||
## 覆盖与节流
|
||||
|
||||
|
||||
@@ -877,7 +877,7 @@ const baseEnglishMessages = {
|
||||
"Keep syncing, processing and providing memories without opening the app.",
|
||||
导出日志: "Export logs",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Package local diagnostic logs into a file to share with support.",
|
||||
"Package all local logs. They may contain conversation text or local paths; share only with trusted developers.",
|
||||
"确定退出 One Memory 界面吗?": "Quit the MemHub window?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"The background service will continue syncing, processing and providing memories.",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const settingsSpanishMessages = {
|
||||
"Sincroniza, organiza y ofrece memorias sin abrir la interfaz.",
|
||||
导出日志: "Exportar registros",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Empaqueta los registros locales en un archivo para su diagnóstico.",
|
||||
"Empaqueta todos los registros locales. Pueden contener conversaciones o rutas privadas; compártelos solo con personal de desarrollo de confianza.",
|
||||
"确定退出 One Memory 界面吗?": "¿Cerrar la interfaz de MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"El servicio en segundo plano seguirá sincronizando, organizando y ofreciendo memorias.",
|
||||
@@ -362,6 +362,8 @@ export const settingsSpanishMessages = {
|
||||
const chineseOverrides: Partial<
|
||||
Record<keyof typeof settingsSpanishMessages, string>
|
||||
> = {
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"打包完整本机日志,可能包含会话正文和本机路径;只发送给可信的开发人员。",
|
||||
"确定退出 One Memory 界面吗?": "确定退出 MemHub 界面吗?",
|
||||
appSettingsTitle: "{name} 应用设置",
|
||||
appMemoryScope: "让 {name} 查询已授权范围内的记忆。",
|
||||
|
||||
@@ -1065,7 +1065,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"Erinnerungen weiter synchronisieren, verarbeiten und bereitstellen, ohne die App zu öffnen.",
|
||||
导出日志: "Protokolle exportieren",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Packen Sie lokale Diagnoseprotokolle in eine Datei, um sie an den Support zu senden.",
|
||||
"Packt alle lokalen Protokolle. Sie können Gesprächsinhalte oder lokale Pfade enthalten; nur mit vertrauenswürdigen Entwicklern teilen.",
|
||||
"确定退出 One Memory 界面吗?": "MemHub-Fenster beenden?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Der Hintergrunddienst synchronisiert, verarbeitet und liefert Erinnerungen weiter.",
|
||||
|
||||
@@ -1078,7 +1078,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"Continuez à synchroniser, traiter et fournir les mémoires sans ouvrir l’application.",
|
||||
导出日志: "Exporter les journaux",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Regroupez les journaux de diagnostic locaux dans un fichier à transmettre à l’assistance.",
|
||||
"Regroupe tous les journaux locaux. Ils peuvent contenir des conversations ou des chemins locaux ; partagez-les uniquement avec des développeurs de confiance.",
|
||||
"确定退出 One Memory 界面吗?": "Quitter la fenêtre MemHub ?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Le service en arrière-plan continuera de synchroniser, traiter et fournir les mémoires.",
|
||||
|
||||
@@ -1048,7 +1048,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"Terus sinkronkan, proses, dan sediakan memori tanpa membuka aplikasi.",
|
||||
导出日志: "Ekspor log",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Kemas log diagnostik lokal menjadi satu file untuk dibagikan ke tim dukungan.",
|
||||
"Kemas semua log lokal. Log dapat memuat isi percakapan atau jalur lokal; bagikan hanya kepada pengembang tepercaya.",
|
||||
"确定退出 One Memory 界面吗?": "Tutup jendela MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Layanan latar belakang akan terus menyinkronkan, memproses, dan menyediakan memori.",
|
||||
|
||||
@@ -1045,7 +1045,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"アプリを開かなくても、記憶の同期、処理、提供を続けます。",
|
||||
导出日志: "ログを書き出す",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"ローカルの診断ログを 1 つのファイルにまとめ、サポートに共有できます。",
|
||||
"ローカルログをすべてまとめます。会話本文やローカルパスが含まれる場合があるため、信頼できる開発者にのみ共有してください。",
|
||||
"确定退出 One Memory 界面吗?": "MemHub のウインドウを終了しますか?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"バックグラウンドサービスは記憶の同期、処理、提供を続けます。",
|
||||
|
||||
@@ -1021,7 +1021,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"앱을 열지 않아도 기억을 계속 동기화, 정리하고 제공합니다.",
|
||||
导出日志: "로그 내보내기",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"로컬 진단 로그를 파일 하나로 묶어 지원팀에 보냅니다.",
|
||||
"모든 로컬 로그를 묶습니다. 대화 내용이나 로컬 경로가 포함될 수 있으므로 신뢰할 수 있는 개발자에게만 공유하세요.",
|
||||
"确定退出 One Memory 界面吗?": "MemHub 창을 종료하시겠습니까?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"백그라운드 서비스는 계속 기억을 동기화, 정리하고 제공합니다.",
|
||||
|
||||
@@ -1069,7 +1069,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"Continue sincronizando, processando e fornecendo memórias sem abrir o app.",
|
||||
导出日志: "Exportar logs",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Empacote os logs de diagnóstico locais em um arquivo para compartilhar com o suporte.",
|
||||
"Empacota todos os logs locais. Eles podem conter conversas ou caminhos locais; compartilhe apenas com desenvolvedores de confiança.",
|
||||
"确定退出 One Memory 界面吗?": "Fechar a janela do MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"O serviço em segundo plano continuará sincronizando, processando e fornecendo memórias.",
|
||||
|
||||
@@ -1053,7 +1053,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"Синхронизация, обработка и предоставление памяти без открытия приложения.",
|
||||
导出日志: "Экспорт журналов",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"Упакуйте локальные журналы диагностики в файл, чтобы отправить в поддержку.",
|
||||
"Упаковывает все локальные журналы. Они могут содержать текст диалогов или локальные пути; отправляйте их только доверенным разработчикам.",
|
||||
"确定退出 One Memory 界面吗?": "Закрыть окно MemHub?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"Фоновая служба продолжит синхронизацию, обработку и предоставление памяти.",
|
||||
|
||||
@@ -951,7 +951,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"不需開啟視窗,也能持續同步、整理和提供記憶。",
|
||||
导出日志: "匯出日誌",
|
||||
"把本机运行日志打包成一个文件,发给开发排查。":
|
||||
"把本機執行日誌打包成一個檔案,提供給開發人員排查。",
|
||||
"打包完整本機日誌,可能包含對話正文和本機路徑;只傳送給可信的開發人員。",
|
||||
"确定退出 One Memory 界面吗?": "要關閉 MemHub 視窗嗎?",
|
||||
"后台服务会继续同步、整理和提供记忆。":
|
||||
"背景服務會繼續同步、整理和提供記憶。",
|
||||
|
||||
@@ -735,6 +735,76 @@ describe("diagnostic log", () => {
|
||||
expect(all).not.toContain("/Users/");
|
||||
});
|
||||
|
||||
it("optionally includes every regular file from the local logs directory", async () => {
|
||||
const dataDir = await root();
|
||||
const outputDir = await root();
|
||||
const logs = path.join(dataDir, "logs");
|
||||
await mkdir(logs, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(logs, "diagnostic-core.jsonl"),
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
timestamp: "2026-10-05T01:00:00.000Z",
|
||||
level: "error",
|
||||
service: "core",
|
||||
component: "core.database",
|
||||
event: "database.error",
|
||||
outcome: "failed",
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
error_code: "database_locked",
|
||||
})}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(logs, "core.log"),
|
||||
"raw private conversation and full local path\n",
|
||||
);
|
||||
await writeFile(path.join(logs, "service-error.log.1"), "rotated error\n");
|
||||
await writeFile(path.join(logs, ".hidden log"), "hidden diagnostic\n");
|
||||
await writeFile(path.join(logs, "服务 日志.log"), "unicode diagnostic\n");
|
||||
await writeFile(path.join(logs, "empty.log"), "");
|
||||
await writeFile(
|
||||
path.join(logs, "large.log"),
|
||||
Buffer.alloc(256 * 1024, 120),
|
||||
);
|
||||
const outside = path.join(await root(), "outside.log");
|
||||
await writeFile(outside, "must not be followed\n");
|
||||
await symlink(outside, path.join(logs, "linked.log"));
|
||||
const target = path.join(outputDir, "MemHub.zip");
|
||||
|
||||
const result = await exportDiagnosticZip({
|
||||
dataDir,
|
||||
target,
|
||||
productVersion: "3800",
|
||||
includeRawLogs: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ filename: "MemHub.zip", file_count: 10 });
|
||||
const files = unzipSync(await readFile(target));
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
"README.txt",
|
||||
"diagnostics/events.jsonl",
|
||||
"logs/.hidden log",
|
||||
"logs/core.log",
|
||||
"logs/diagnostic-core.jsonl",
|
||||
"logs/empty.log",
|
||||
"logs/large.log",
|
||||
"logs/service-error.log.1",
|
||||
"logs/服务 日志.log",
|
||||
"summary.json",
|
||||
]);
|
||||
expect(strFromU8(files["logs/core.log"] ?? new Uint8Array())).toContain(
|
||||
"raw private conversation",
|
||||
);
|
||||
expect(strFromU8(files["README.txt"] ?? new Uint8Array())).toContain(
|
||||
"原始日志可能包含会话正文",
|
||||
);
|
||||
expect(files["logs/linked.log"]).toBeUndefined();
|
||||
expect(files["logs/empty.log"]).toHaveLength(0);
|
||||
expect(files["logs/large.log"]).toHaveLength(256 * 1024);
|
||||
if (process.platform !== "win32")
|
||||
expect((await lstat(target)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
|
||||
const dataDir = await root();
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
createWriteStream,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
import {
|
||||
appendFile,
|
||||
chmod,
|
||||
type FileHandle,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
@@ -25,7 +28,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { strToU8, Zip, ZipPassThrough, zipSync } from "fflate";
|
||||
|
||||
export const DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
|
||||
export const DIAGNOSTIC_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||
@@ -627,6 +630,7 @@ export async function exportDiagnosticZip(options: {
|
||||
now?: Date;
|
||||
markers?: DiagnosticMarker[];
|
||||
summary?: Record<string, unknown>;
|
||||
includeRawLogs?: boolean;
|
||||
}): Promise<{ filename: string; file_count: number }> {
|
||||
const now = options.now ?? new Date();
|
||||
const home = options.home ?? os.homedir();
|
||||
@@ -672,7 +676,14 @@ export async function exportDiagnosticZip(options: {
|
||||
"MemHub diagnostics",
|
||||
"",
|
||||
"包含:最近 7 天通过安全白名单重新投影的诊断事件,以及不含正文的状态摘要。",
|
||||
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
||||
...(options.includeRawLogs
|
||||
? [
|
||||
"同时包含:One Memory Home 的 logs/ 目录中全部普通日志文件及轮转文件。",
|
||||
"注意:原始日志可能包含会话正文、本机路径或其他私人信息,只应发送给可信的开发人员。",
|
||||
]
|
||||
: [
|
||||
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
|
||||
]),
|
||||
...(markers.length
|
||||
? [
|
||||
"",
|
||||
@@ -689,20 +700,30 @@ export async function exportDiagnosticZip(options: {
|
||||
const directory = path.dirname(options.target);
|
||||
await mkdir(directory, { recursive: true });
|
||||
const temporary = path.join(directory, `.memhub-logs-${randomUUID()}.tmp`);
|
||||
const rawLogs = options.includeRawLogs
|
||||
? await openRawLogSnapshots(options.dataDir)
|
||||
: [];
|
||||
try {
|
||||
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
if (options.includeRawLogs)
|
||||
await writeStreamingDiagnosticZip(temporary, archive, rawLogs);
|
||||
else
|
||||
await writeFile(temporary, zipSync(archive, { level: 6 }), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
await chmod(temporary, 0o600);
|
||||
await publishTemporaryFile(temporary, options.target);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all(
|
||||
rawLogs.map((file) => file.handle.close().catch(() => undefined)),
|
||||
);
|
||||
}
|
||||
return {
|
||||
filename: path.basename(options.target),
|
||||
file_count: Object.keys(archive).length,
|
||||
file_count: Object.keys(archive).length + rawLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -902,6 +923,151 @@ async function readRegularFileNoFollow(
|
||||
}
|
||||
}
|
||||
|
||||
interface RawLogSnapshot {
|
||||
name: string;
|
||||
handle: FileHandle;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function openRawLogSnapshots(dataDir: string): Promise<RawLogSnapshot[]> {
|
||||
const directory = diagnosticDirectory(dataDir);
|
||||
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
directoryInfo = await lstat(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
||||
fail("diagnostic_directory_unsafe");
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const snapshots: RawLogSnapshot[] = [];
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (
|
||||
entry.name === "." ||
|
||||
entry.name === ".." ||
|
||||
[...entry.name].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
character === "/" || character === "\\" || code < 32 || code === 127
|
||||
);
|
||||
})
|
||||
)
|
||||
fail("diagnostic_log_name_unsafe");
|
||||
const target = path.join(directory, entry.name);
|
||||
const before = await lstat(target).catch(() => null);
|
||||
if (!before?.isFile() || before.isSymbolicLink()) continue;
|
||||
const handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let retained = false;
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino
|
||||
)
|
||||
fail("diagnostic_file_unsafe");
|
||||
snapshots.push({ name: entry.name, handle, size: opened.size });
|
||||
retained = true;
|
||||
} finally {
|
||||
if (!retained) await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
const after = await lstat(directory);
|
||||
if (
|
||||
!after.isDirectory() ||
|
||||
after.isSymbolicLink() ||
|
||||
after.dev !== directoryInfo.dev ||
|
||||
after.ino !== directoryInfo.ino
|
||||
)
|
||||
fail("diagnostic_directory_unsafe");
|
||||
return snapshots.sort((left, right) => left.name.localeCompare(right.name));
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
snapshots.map((file) => file.handle.close().catch(() => undefined)),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStreamingDiagnosticZip(
|
||||
target: string,
|
||||
archive: Record<string, Uint8Array>,
|
||||
rawLogs: RawLogSnapshot[],
|
||||
): Promise<void> {
|
||||
const output = createWriteStream(target, { flags: "wx", mode: 0o600 });
|
||||
let outputError: Error | null = null;
|
||||
let backpressure = Promise.resolve();
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
output.once("finish", resolve);
|
||||
output.once("error", (error) => {
|
||||
outputError = error;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
// The producer may still be reading a source file when the output stream
|
||||
// fails. Attach a rejection handler immediately; the awaited promise below
|
||||
// still carries the same error into the export's fail-closed cleanup.
|
||||
void finished.catch(() => undefined);
|
||||
const zip = new Zip((error, chunk, final) => {
|
||||
if (error) {
|
||||
outputError = error;
|
||||
output.destroy(error);
|
||||
return;
|
||||
}
|
||||
if (chunk.length > 0 && !output.write(chunk))
|
||||
backpressure = once(output, "drain").then(() => undefined);
|
||||
if (final) output.end();
|
||||
});
|
||||
const ensureWritable = async () => {
|
||||
await backpressure;
|
||||
if (outputError) throw outputError;
|
||||
};
|
||||
try {
|
||||
for (const [name, content] of Object.entries(archive)) {
|
||||
await ensureWritable();
|
||||
const input = new ZipPassThrough(name);
|
||||
zip.add(input);
|
||||
input.push(content, true);
|
||||
}
|
||||
for (const file of rawLogs) {
|
||||
await ensureWritable();
|
||||
const input = new ZipPassThrough(`logs/${file.name}`);
|
||||
zip.add(input);
|
||||
let position = 0;
|
||||
while (position < file.size) {
|
||||
await ensureWritable();
|
||||
const buffer = Buffer.allocUnsafe(
|
||||
Math.min(64 * 1024, file.size - position),
|
||||
);
|
||||
const { bytesRead } = await file.handle.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
position,
|
||||
);
|
||||
if (bytesRead < 1) fail("diagnostic_file_changed");
|
||||
position += bytesRead;
|
||||
input.push(buffer.subarray(0, bytesRead), position === file.size);
|
||||
}
|
||||
if (file.size === 0) input.push(new Uint8Array(), true);
|
||||
}
|
||||
await ensureWritable();
|
||||
zip.end();
|
||||
await finished;
|
||||
} catch (error) {
|
||||
zip.terminate();
|
||||
output.destroy(error instanceof Error ? error : undefined);
|
||||
await finished.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function diagnosticLogFiles(dataDir: string): Promise<string[]> {
|
||||
const directory = diagnosticDirectory(dataDir);
|
||||
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
|
||||
|
||||
Reference in new issue
Block a user