From e068f15136b3506974d54b9a6a7814dc656b2c95 Mon Sep 17 00:00:00 2001 From: Alfred Date: Fri, 9 Oct 2026 19:32:21 +0800 Subject: [PATCH 01/29] =?UTF-8?q?fix:=20=E5=A4=B1=E8=B4=A5=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=BF=9D=E7=95=99=20data=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20SSH=E3=80=8C=E6=B5=8B=E8=AF=95=E8=BF=9E=E6=8E=A5=E3=80=8D?= =?UTF-8?q?=E5=BC=B9=E4=B8=8D=E5=87=BA=E4=B8=BB=E6=9C=BA=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSH 主机页「测试连接」调用 ssh.exec。目标是新主机(1002)或指纹变化(1001)时, 固件把指纹放进 result->data,同时返回非 OK;HTTP 出口的通用失败分支只输出 {code,error} 并释放 data,页面取不到指纹就跳过确认框,用户只看到「连接失败」, 无法在该入口信任新主机,也无法核对并接受指纹变化。 - ts_webui_api.c:通用失败分支同样输出 result.data;没有 data 的失败响应不变。 同一处顺带加固:code / error 写不进去(内存分配失败)时改发 HTTP 500, 不再发出缺 code 的残缺 JSON(原有漏洞,由下面的分配失败遍历发现)。 固件的指纹校验没有改动:确认必须带回当前连接上的指纹,否则继续拒绝, 校验失败时不发凭据、不执行命令。 - 新增 tests/runtime/test_ssh_fingerprint_wire.py:运行生产的 ssh.exec / ssh.test 处理函数和 HTTP 响应分支,检查线上字节,并存为契约夹具 tests/fixtures/ssh-fingerprint;并在构建失败响应的每个分配点注入失败, 结果只能是完整响应或 HTTP 500。 - 新增 tests/prompts/ssh-fingerprint.browser.test.cjs:把夹具原样回放给真实页面, 中英文检查确认框内容、取消不信任不重试、确认只发送用户看到的指纹、 确认时设备换密钥则不信任且不循环。 - test_keystore.py:原先钉着「其他接口的失败响应不带 data」,按新决定改为断言带上 (只改了这一条断言和模拟字段名,共 2 行);密钥生成原有的失败阶段、清理结果、 序列化失败返回 500 等测试全部保留,并逐一破坏生产代码确认它们仍会报错。 - docs/repair/ssh-fingerprint/RESULT.md:根因、修复与验证记录。 验证(未接触真机):运行时全套 41 项、静态 122 项、浏览器 249 项、发布检查通过; ESP-IDF 5.5.2 完整编译通过。还原修复后运行时测试断言失败;夹具换成旧格式后 浏览器测试失败。 Co-Authored-By: Claude Sonnet 5.5 --- components/ts_webui/src/ts_webui_api.c | 21 +- docs/repair/ssh-fingerprint/RESULT.md | 39 ++ tests/fixtures/ssh-fingerprint/README.md | 10 + .../ssh-fingerprint/wire-changed-host.json | 1 + .../ssh-fingerprint/wire-exec-ok.json | 1 + .../ssh-fingerprint/wire-generic-failure.json | 1 + .../ssh-fingerprint/wire-new-host.json | 1 + tests/prompts/package.json | 2 +- .../prompts/ssh-fingerprint.browser.test.cjs | 165 +++++++++ tests/runtime/run_all.sh | 1 + tests/runtime/test_keystore.py | 4 +- tests/runtime/test_ssh_fingerprint_wire.py | 336 ++++++++++++++++++ 12 files changed, 574 insertions(+), 8 deletions(-) create mode 100644 docs/repair/ssh-fingerprint/RESULT.md create mode 100644 tests/fixtures/ssh-fingerprint/README.md create mode 100644 tests/fixtures/ssh-fingerprint/wire-changed-host.json create mode 100644 tests/fixtures/ssh-fingerprint/wire-exec-ok.json create mode 100644 tests/fixtures/ssh-fingerprint/wire-generic-failure.json create mode 100644 tests/fixtures/ssh-fingerprint/wire-new-host.json create mode 100644 tests/prompts/ssh-fingerprint.browser.test.cjs create mode 100644 tests/runtime/test_ssh_fingerprint_wire.py diff --git a/components/ts_webui/src/ts_webui_api.c b/components/ts_webui/src/ts_webui_api.c index 2c7441a..c4b8c32 100644 --- a/components/ts_webui/src/ts_webui_api.c +++ b/components/ts_webui/src/ts_webui_api.c @@ -116,7 +116,7 @@ static esp_err_t check_auth(ts_http_request_t *req, uint32_t *session_id, ts_per #endif } -/* This endpoint retains storage failure details; other response contracts stay unchanged. */ +/* key.generate: same envelope as the generic path (failure details included) plus per-request stage logging. */ static esp_err_t send_key_generate_result(ts_http_request_t *req, ts_api_result_t *result, const char *request_id, int64_t started) { @@ -340,10 +340,21 @@ static esp_err_t api_handler(ts_http_request_t *req, void *user_data) // Return the actual error from the API handler with HTTP 200 // API errors (like "no data") are business logic errors, not HTTP errors cJSON *response = cJSON_CreateObject(); - cJSON_AddNumberToObject(response, "code", result.code); - cJSON_AddStringToObject(response, "error", result.message ? result.message : "Internal error"); - - char *json = cJSON_PrintUnformatted(response); + /* code and error are the failure itself: if either cannot be written, send HTTP 500 rather + * than a document the client would misread. */ + bool built = response && + cJSON_AddNumberToObject(response, "code", result.code) && + cJSON_AddStringToObject(response, "error", result.message ? result.message : "Internal error"); + /* A failing handler may attach details the client must act on, e.g. the host-key + * fingerprint ssh.exec reports with code 1001/1002 so the UI can ask the user to + * confirm it. Dropping them leaves a bare error and no way to continue. Handlers + * that fail without details leave data NULL, so their responses do not change. */ + if (built && result.data) { + cJSON *data = cJSON_Duplicate(result.data, true); + if (data && !cJSON_AddItemToObject(response, "data", data)) cJSON_Delete(data); + } + + char *json = built ? cJSON_PrintUnformatted(response) : NULL; cJSON_Delete(response); ts_api_result_free(&result); diff --git a/docs/repair/ssh-fingerprint/RESULT.md b/docs/repair/ssh-fingerprint/RESULT.md new file mode 100644 index 0000000..40d9dbd --- /dev/null +++ b/docs/repair/ssh-fingerprint/RESULT.md @@ -0,0 +1,39 @@ +# SSH 主机指纹确认框弹不出:修复结果 + +## 现象 + +SSH 主机页点「测试连接」:目标主机是新的(还没登记指纹),或它的指纹和已登记的不一样时,页面只显示「连接失败」,没有确认框,用户在这个入口无法信任新主机、也无法核对并接受指纹变化。 + +## 根因(三处接力丢失) + +1. 固件:`ssh.exec` 遇到新主机返回业务码 `1002`、指纹变化返回 `1001`,并把指纹放进 `result->data`;同时返回值是 `ESP_ERR_INVALID_STATE`(`ts_api_ssh.c` 的 `verify_host_fingerprint`)。 +2. HTTP 出口:返回值非 `ESP_OK` 且业务码非 0 时走 `ts_webui_api.c` 的通用失败分支,该分支只输出 `{code, error}`,把 `data` 释放了。 +3. 页面:`testSshHostByIndex` 必须取到 `data.fingerprint` / `data.current_fingerprint` 才弹确认框;取不到就跳过,显示连接失败。 + +`ssh.test` 对同样的状态返回 `ESP_OK`,走成功分支,`data` 保留,所以直接调用 `ssh.test` 不受影响;页面用的是 `ssh.exec`。 + +原有测试没发现,是因为它们直接替换 `api.call`,或者让模拟设备在失败响应里带 `data`,绕过了真实的 HTTP 序列化。 + +## 修复 + +`ts_webui_api.c` 的通用失败分支同样输出处理函数留下的 `result.data`。没有 `data` 的失败响应字节不变(仍是 `{"code":N,"error":"..."}`)。 + +固件的指纹校验逻辑没有改:新主机必须带回当前连接上看到的那个指纹(`confirmed_fingerprint`)并显式 `trust_new`,指纹变化必须带回当前指纹并显式 `accept_changed`,否则继续拒绝;校验失败时不会发送凭据、不会执行命令。 + +## 验证(均未接触真机) + +- `tests/runtime/test_ssh_fingerprint_wire.py`:把生产代码里的 `ssh.exec` / `ssh.test` 处理函数和 `ts_webui_api.c` 的响应分支原文抽出来,配合合成的 SSH / known-hosts 后端运行,检查线上字节:新主机、指纹变化的响应带指纹;没有精确确认时不信任、不发凭据、不执行命令;错误或缺失的确认仍被拒绝;普通失败仍是 `{code,error}`;`API not found` 仍是 404。线上字节存为契约夹具 `tests/fixtures/ssh-fingerprint/`,与代码不一致时测试失败。 +- `tests/prompts/ssh-fingerprint.browser.test.cjs`:把这些夹具原样回放给真实页面(真实 `api.js`、真实「测试」按钮、真实确认框),中英文各自检查:确认框显示设备发来的指纹;取消后不信任、不重试;确认后恰好发送用户看到的那个指纹,且 `trust_new` / `accept_changed` 与状态码对应;确认时设备换了另一把密钥则不信任、不循环。 +- 反向验证:还原修复后运行时测试断言失败;夹具换成旧固件的线上格式后浏览器测试失败。 +- `test_keystore.py` 中原有一条断言钉着「其他接口的失败响应不带 data」(0.6.1 为控制范围有意保留,模拟接口的字段就叫 `should_omit`),已按新决定改为断言带上——该文件只改了这一条断言和模拟字段的名字(共 2 行)。密钥生成原有的失败测试全部保留:失败阶段(`failed_stage`)、清理结果(`cleanup_complete`)、响应序列化失败返回 HTTP 500 并保留已生成的密钥,等等。逐一破坏对应的生产代码(失败细节不再送出、清理结果缺失、序列化失败不再返回 500),这些旧测试分别报错。 +- 分配失败遍历:在构建失败响应的每一个可能分配失败的点注入失败,响应只能是完整的 `{code,error[,data]}`(`data` 要么完整要么没有)或 HTTP 500。遍历发现了一个原有的漏洞:写入 `code` 时分配失败,会发出缺 `code` 的残缺 JSON。已一并加固——失败分支里 `code` / `error` 写不进去就改发 500(正常情况输出不变)。成功分支有同类写法,这次没有改。 +- 其他:运行时全套 41 项、静态 122 项、浏览器 249 项(原 232 + 新增 17)、发布检查通过;ESP-IDF 5.5.2 完整编译通过,镜像大小不变(2,202,336 字节)。 +- 排查:全部接口处理函数里,失败时可能同时带着 `data` 的共 13 个,字段名中没有密码、私钥、令牌一类(按字段名的启发式检查)。 + +## 行为变化 + +其他接口的失败响应现在也可能带 `data`——只限处理函数主动留下的细节(服务状态、测试输出、指纹等)。已逐个核对页面里对这些接口的调用点(`automation.rules.get`、`actions.get` / `execute`、`sources.add`、`proxy.*`、`services.*` 等):都是先判断 `code` 再使用 `data`,不依赖失败响应没有 `data`;`variables.set`、`test.ssh` 页面并不调用。 + +## 尚未验证 + +真机。建议刷机后检查:(1) 对一台从未连接过的主机点「测试连接」→ 出现确认框;取消 → 不登记;再点并确认 → 连接成功且已登记。(2) 把已登记主机换一把 SSH 主机密钥后点「测试连接」→ 出现确认框并显示新指纹。 diff --git a/tests/fixtures/ssh-fingerprint/README.md b/tests/fixtures/ssh-fingerprint/README.md new file mode 100644 index 0000000..1fea2e6 --- /dev/null +++ b/tests/fixtures/ssh-fingerprint/README.md @@ -0,0 +1,10 @@ +# SSH host-key confirmation: bytes on the wire + +`wire-*.json` are the HTTP response bodies the firmware sends for `ssh.exec` when the target host is unknown +(code 1002) or its fingerprint changed (code 1001), plus a successful run and a plain failure. They are produced by +`tests/runtime/test_ssh_fingerprint_wire.py`, which runs the production `ssh.exec` handler and the production HTTP +response chain of `ts_webui_api.c` against synthetic SSH/known-hosts back ends, and fails if the committed files differ +from what the code now sends (`UPDATE_FIXTURES=1` regenerates them after an intentional change). + +`tests/prompts/ssh-fingerprint.browser.test.cjs` replays the same bytes into the real Web UI, so the firmware's wire +format and the page's expectations are checked against one shared contract. Fingerprints and hosts are synthetic. diff --git a/tests/fixtures/ssh-fingerprint/wire-changed-host.json b/tests/fixtures/ssh-fingerprint/wire-changed-host.json new file mode 100644 index 0000000..9f55e36 --- /dev/null +++ b/tests/fixtures/ssh-fingerprint/wire-changed-host.json @@ -0,0 +1 @@ +{"code":1001,"error":"Host key mismatch - possible MITM attack","data":{"status":"mismatch","host":"192.0.2.8","port":22,"current_fingerprint":"SHA256:SYNTHETICobservedFINGERPRINT0123456789abcdefghi","stored_fingerprint":"SHA256:SYNTHETICstoredFINGERPRINT0123456789abcdefghijkl","message":"WARNING: Host key has changed! This could indicate a man-in-the-middle attack. Set accept_changed=true only if you are sure the server was reinstalled."}} \ No newline at end of file diff --git a/tests/fixtures/ssh-fingerprint/wire-exec-ok.json b/tests/fixtures/ssh-fingerprint/wire-exec-ok.json new file mode 100644 index 0000000..9fa635f --- /dev/null +++ b/tests/fixtures/ssh-fingerprint/wire-exec-ok.json @@ -0,0 +1 @@ +{"code":0,"data":{"exit_code":0,"stdout":"TianshanOS SSH Test OK\n","stderr":"","host_status":"trusted","fingerprint":"SHA256:SYNTHETICobservedFINGERPRINT0123456789abcdefghi"}} \ No newline at end of file diff --git a/tests/fixtures/ssh-fingerprint/wire-generic-failure.json b/tests/fixtures/ssh-fingerprint/wire-generic-failure.json new file mode 100644 index 0000000..ed1eb7c --- /dev/null +++ b/tests/fixtures/ssh-fingerprint/wire-generic-failure.json @@ -0,0 +1 @@ +{"code":1,"error":"Missing 'command' parameter"} \ No newline at end of file diff --git a/tests/fixtures/ssh-fingerprint/wire-new-host.json b/tests/fixtures/ssh-fingerprint/wire-new-host.json new file mode 100644 index 0000000..e285a31 --- /dev/null +++ b/tests/fixtures/ssh-fingerprint/wire-new-host.json @@ -0,0 +1 @@ +{"code":1002,"error":"New host requires confirmation","data":{"status":"new_host","host":"192.0.2.8","port":22,"fingerprint":"SHA256:SYNTHETICobservedFINGERPRINT0123456789abcdefghi","message":"New host - set trust_new=true or use hosts.add to trust this host"}} \ No newline at end of file diff --git a/tests/prompts/package.json b/tests/prompts/package.json index 1f5604a..b2342bd 100644 --- a/tests/prompts/package.json +++ b/tests/prompts/package.json @@ -6,7 +6,7 @@ }, "scripts": { "test": "cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs", - "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs", + "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/ssh-fingerprint.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs", "test:xterm": "cd ../.. && node --test tests/prompts/xterm-vendor.test.cjs tests/prompts/xterm-offline.test.cjs" }, "devDependencies": { diff --git a/tests/prompts/ssh-fingerprint.browser.test.cjs b/tests/prompts/ssh-fingerprint.browser.test.cjs new file mode 100644 index 0000000..2e479cf --- /dev/null +++ b/tests/prompts/ssh-fingerprint.browser.test.cjs @@ -0,0 +1,165 @@ +// "Test connection" on the SSH hosts page, against the exact bytes the firmware puts on the wire. +// +// The device answers ssh.exec for an unknown / changed host with HTTP 200 and {code:1002|1001, error, data:{fingerprint…}}. +// tests/runtime/test_ssh_fingerprint_wire.py produces those bytes with the production handlers and the production HTTP +// response chain and keeps them in tests/fixtures/ssh-fingerprint/. Here they are replayed verbatim into the real page: +// real api.js request pipeline, real button, real confirmation sheet. Nothing replaces api.call. +const {test, before, after} = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'), fs = require('node:fs'), path = require('node:path'); +const {chromium} = require('playwright'); +const {root} = require('./harness.cjs'); + +const fixtureDir = path.resolve(__dirname, '../fixtures/ssh-fingerprint'); +const device = name => fs.readFileSync(path.join(fixtureDir, name), 'utf8'); +const NEW_HOST = JSON.parse(device('wire-new-host.json')); +const CHANGED_HOST = JSON.parse(device('wire-changed-host.json')); +const host = {id: 'test@192.0.2.8', host: '192.0.2.8', port: 22, username: 'test', keyid: 'synthetic-key'}; +const TEST_BUTTON = '#ssh-hosts-table-body button[onclick="testSshHostByIndex(0)"]'; + +let browser; +before(async () => { browser = await chromium.launch({channel: 'chrome', headless: true}); }); +after(async () => { await browser?.close(); }); + +// respond(params, n) returns the raw response body for the n-th ssh.exec request. +async function withPage(language, respond, run) { + const execRequests = [], errors = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://local'); + const raw = body => { res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end(body); }; + if (url.pathname.startsWith('/api/')) { + if (url.pathname === '/api/v1/ssh/exec') { + let body = ''; + req.setEncoding('utf8'); + req.on('data', part => body += part); + req.on('end', () => { const params = JSON.parse(body); execRequests.push(params); raw(respond(params, execRequests.length)); }); + return; + } + if (url.pathname === '/api/v1/ssh/hosts/list') return raw(JSON.stringify({code: 0, data: {hosts: [host]}})); + if (url.pathname === '/api/v1/hosts/list') return raw(JSON.stringify({code: 0, data: {hosts: []}})); + if (url.pathname === '/api/v1/key/list') return raw(JSON.stringify({code: 0, data: {keys: []}})); + return raw(JSON.stringify({code: 0, data: {}})); + } + const file = path.resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.pathname)); + if (!file.startsWith(root + '/')) { res.writeHead(403).end(); return; } + fs.readFile(file, (error, data) => { + if (error) { res.writeHead(404).end(); return; } + res.setHeader('Content-Type', ({'.js': 'application/javascript', '.html': 'text/html', '.css': 'text/css', '.woff2': 'font/woff2', '.svg': 'image/svg+xml'})[path.extname(file)] || 'application/octet-stream'); + res.end(data); + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const context = await browser.newContext({locale: language}); + await context.addInitScript(language => { + localStorage.setItem('ts_language', language); + class Socket { static OPEN = 1; static CONNECTING = 0; constructor() { this.readyState = 0; } send() {} close() { this.readyState = 3; } } + window.WebSocket = Socket; + }, language); + const page = await context.newPage(); + page.on('pageerror', error => errors.push(error.message)); + try { + await page.goto(base); + await page.waitForFunction(() => i18n.isReady()); + await page.evaluate(async () => { closeLoginModal(); await loadSecurityPage(); }); + await page.locator(TEST_BUTTON).waitFor(); + await run({page, execRequests}); + assert.deepEqual(errors, []); + } finally { + await context.close(); + await new Promise(resolve => server.close(resolve)); + } +} + +const sheet = page => page.locator('.confirm-sheet'); +const toastKind = (page, kind) => page.waitForFunction(kind => document.getElementById('toast')?.classList.contains('toast-' + kind), kind); +const settle = page => page.waitForTimeout(400); // a background retry would show up as an extra request + +for (const language of ['zh-CN', 'en-US']) { + for (const [name, wire, shownKey, code, flags] of [ + ['new host', NEW_HOST, 'fingerprint', 1002, {trust_new: true, accept_changed: false}], + ['changed fingerprint', CHANGED_HOST, 'current_fingerprint', 1001, {trust_new: false, accept_changed: true}], + ]) { + const fingerprint = wire.data?.[shownKey]; + const needData = () => assert.equal(typeof fingerprint, 'string', + `fixture has no data.${shownKey}: the device must send the fingerprint together with code ${code}`); + + test(`${language}: ${name}: the confirmation sheet shows the fingerprint the device sent`, async () => { + assert.equal(wire.code, code); + needData(); + await withPage(language, () => JSON.stringify(wire), async ({page, execRequests}) => { + await page.locator(TEST_BUTTON).click(); + await sheet(page).waitFor({timeout: 5000}); + const shown = await sheet(page).locator('.mono').textContent(); + assert(shown.includes(`${host.host}:${host.port}`), shown); + assert(shown.includes(fingerprint), shown); + assert.equal(execRequests.length, 1); + assert.equal(execRequests[0].trust_new, false); // nothing is trusted before the user decides + assert.equal('confirmed_fingerprint' in execRequests[0], false); + await sheet(page).locator('button[data-r="0"]').click(); + }); + }); + + test(`${language}: ${name}: cancelling trusts nothing and does not retry`, async () => { + needData(); + await withPage(language, () => JSON.stringify(wire), async ({page, execRequests}) => { + await page.locator(TEST_BUTTON).click(); + await sheet(page).waitFor({timeout: 5000}); + await sheet(page).locator('button[data-r="0"]').click(); + await toastKind(page, 'error'); + await settle(page); + assert.equal(await sheet(page).count(), 0); + assert.equal(execRequests.length, 1); + assert.equal(await page.evaluate(() => document.getElementById('toast').classList.contains('toast-success')), false); + }); + }); + + test(`${language}: ${name}: confirming sends exactly the fingerprint the user was shown, once`, async () => { + needData(); + await withPage(language, (_, n) => n === 1 ? JSON.stringify(wire) : device('wire-exec-ok.json'), async ({page, execRequests}) => { + await page.locator(TEST_BUTTON).click(); + await sheet(page).waitFor({timeout: 5000}); + await sheet(page).locator('button[data-r="1"]').click(); + await toastKind(page, 'success'); + await settle(page); + assert.equal(execRequests.length, 2); + const [first, second] = execRequests; + assert.equal(second.confirmed_fingerprint, fingerprint); + assert.equal(second.trust_new, flags.trust_new); + assert.equal(second.accept_changed, flags.accept_changed); + for (const key of ['host', 'port', 'user', 'keyid', 'command']) assert.equal(second[key], first[key], key); + const expected = await page.evaluate(id => t('toast.connectionSuccess', {host: id}), host.id); + assert.equal(await page.locator('#toast').textContent(), expected); + }); + }); + + test(`${language}: ${name}: if the device presents a different key at confirmation, nothing is trusted and there is no loop`, async () => { + needData(); + const other = JSON.parse(JSON.stringify(wire)); + other.data[shownKey] = 'SHA256:SYNTHETICanotherKEYpresentedAtConfirmation0000000'; + await withPage(language, (_, n) => JSON.stringify(n === 1 ? wire : other), async ({page, execRequests}) => { + await page.locator(TEST_BUTTON).click(); + await sheet(page).waitFor({timeout: 5000}); + await sheet(page).locator('button[data-r="1"]').click(); + await toastKind(page, 'error'); + await settle(page); + assert.equal(execRequests.length, 2); + assert.equal(execRequests[1].confirmed_fingerprint, fingerprint); // the old text, not the new key + assert.equal(await sheet(page).count(), 0); + assert.equal(await page.evaluate(() => document.getElementById('toast').classList.contains('toast-success')), false); + }); + }); + } +} + +test(`the fixtures are real firmware output: failures carry data, plain failures do not`, () => { + assert.equal(NEW_HOST.code, 1002); + assert.equal(NEW_HOST.error, 'New host requires confirmation'); + assert.equal(typeof NEW_HOST.data.fingerprint, 'string'); + assert.equal(CHANGED_HOST.code, 1001); + assert.equal(typeof CHANGED_HOST.data.current_fingerprint, 'string'); + assert.equal(typeof CHANGED_HOST.data.stored_fingerprint, 'string'); + assert.notEqual(CHANGED_HOST.data.current_fingerprint, CHANGED_HOST.data.stored_fingerprint); + assert.equal(JSON.parse(device('wire-exec-ok.json')).code, 0); + assert.deepEqual(JSON.parse(device('wire-generic-failure.json')), {code: 1, error: "Missing 'command' parameter"}); +}); diff --git a/tests/runtime/run_all.sh b/tests/runtime/run_all.sh index cba25f4..0fa1280 100755 --- a/tests/runtime/run_all.sh +++ b/tests/runtime/run_all.sh @@ -14,6 +14,7 @@ python3 tests/runtime/test_expansion.py python3 tests/runtime/test_configuration_protocol.py python3 tests/runtime/test_ssh_hosts.py python3 tests/runtime/test_ssh_copyid.py +python3 tests/runtime/test_ssh_fingerprint_wire.py python3 tests/runtime/test_copyid_quote.py python3 tests/runtime/test_lpmu_access.py python3 tests/runtime/test_input_repair.py diff --git a/tests/runtime/test_keystore.py b/tests/runtime/test_keystore.py index 8562560..3c9c785 100644 --- a/tests/runtime/test_keystore.py +++ b/tests/runtime/test_keystore.py @@ -92,7 +92,7 @@ esp_err_t ts_crypto_keypair_export_openssh(ts_keypair_t p,char *b,size_t *n,const char *c){if(public_error)return public_error;strcpy(b,"SYNTHETIC");*n=10;return ESP_OK;} esp_err_t ts_api_call(const char *name,const cJSON *p,ts_api_result_t *r){ if(!strcmp(name,"key.generate"))return api_key_generate(p,r); - r->code=TS_API_ERR_INTERNAL;r->message=strdup("fixture");r->data=cJSON_CreateObject();cJSON_AddBoolToObject(r->data,"should_omit",true);return ESP_FAIL; + r->code=TS_API_ERR_INTERNAL;r->message=strdup("fixture");r->data=cJSON_CreateObject();cJSON_AddBoolToObject(r->data,"failure_details",true);return ESP_FAIL; } void ts_api_result_error(ts_api_result_t *r,ts_api_result_code_t c,const char *s){r->code=c;r->message=strdup(s);} void ts_api_result_ok(ts_api_result_t *r,cJSON *data){r->code=0;r->data=data;} @@ -176,7 +176,7 @@ reset();const char *body="{\"id\":\"new\",\"request_id\":\"kg-test\"}";ts_http_request_t req={.uri="/api/v1/key/generate",.body=body,.body_len=strlen(body)}; fail_key="new_meta";fail_write_at=1;assert(api_handler(&req,NULL)==ESP_OK);response=cJSON_Parse(sent_json);assert(cJSON_GetObjectItem(response,"data")&&cJSON_GetObjectItem(response,"code")->valueint!=0);cJSON_Delete(response);absent("new"); reset();assert(api_handler(&req,NULL)==ESP_OK);assert(find("new_priv"));response=cJSON_Parse(sent_json);assert(cJSON_IsTrue(cJSON_GetObjectItem(cJSON_GetObjectItem(response,"data"),"generated")));cJSON_Delete(response); - req.uri="/api/v1/other/endpoint";assert(api_handler(&req,NULL)==ESP_OK);response=cJSON_Parse(sent_json);assert(!cJSON_GetObjectItem(response,"data"));cJSON_Delete(response); + req.uri="/api/v1/other/endpoint";assert(api_handler(&req,NULL)==ESP_OK);response=cJSON_Parse(sent_json);assert(send_status==200&&cJSON_GetObjectItem(response,"code")->valueint==TS_API_ERR_INTERNAL);assert(!strcmp(cJSON_GetObjectItem(response,"error")->valuestring,"fixture")&&!cJSON_GetObjectItem(response,"message"));/* generic failures carry the handler's details (e.g. the host-key fingerprint of ssh.exec) */assert(cJSON_IsTrue(cJSON_GetObjectItem(cJSON_GetObjectItem(response,"data"),"failure_details")));cJSON_Delete(response); req.uri="/api/v1/key/generate";cJSON_Hooks fail_request={always_fail,free};cJSON_InitHooks(&fail_request);assert(api_handler(&req,NULL)==ESP_OK);cJSON_InitHooks(NULL);assert(send_status==500&&find("new_priv")); r=(ts_api_result_t){.code=0,.data=cJSON_CreateObject()};cJSON_AddBoolToObject(r.data,"generated",true);cJSON_Hooks hooks={always_fail,free};cJSON_InitHooks(&hooks);send_key_generate_result(NULL,&r,"test",0);cJSON_InitHooks(NULL);assert(send_status==500&&find("new_priv")); } diff --git a/tests/runtime/test_ssh_fingerprint_wire.py b/tests/runtime/test_ssh_fingerprint_wire.py new file mode 100644 index 0000000..6e2b635 --- /dev/null +++ b/tests/runtime/test_ssh_fingerprint_wire.py @@ -0,0 +1,336 @@ +"""Production ssh.exec / ssh.test host-key results, serialized by the production HTTP response branches. + +The Web UI's "test connection" button asks the user to confirm a host fingerprint when the device answers with +code 1002 (new host) or 1001 (changed fingerprint) *and* sends the fingerprint in `data`. ssh.exec reports those +statuses together with a non-OK return value, which used to take the generic failure branch of the HTTP handler and +lose `data`. This suite runs the production handler functions and the production response chain (extracted from the +sources, not copied) with synthetic SSH/known-hosts back ends, checks the exact bytes that go on the wire, and keeps +them in tests/fixtures/ssh-fingerprint/. The browser suite replays those bytes into the real Web UI, so firmware and +UI cannot drift apart unnoticed. Regenerate with UPDATE_FIXTURES=1 after an intentional wire-format change. +""" +from pathlib import Path +import json +import os +import re +import subprocess +import sys + +build = Path('/tmp/tianshan-ssh-host-tests') +build.mkdir(exist_ok=True) +wire_dir = build / 'wire' +wire_dir.mkdir(exist_ok=True) +idf = Path(os.environ.get('IDF_PATH', '/Users/massif/esp/v5.5.2/esp-idf')) +fixtures = Path('tests/fixtures/ssh-fingerprint') +update = os.environ.get('UPDATE_FIXTURES') == '1' +CAPTURES = ['wire-new-host.json', 'wire-changed-host.json', 'wire-exec-ok.json', 'wire-generic-failure.json'] + + +def extract(path, name): + source = Path(path).read_text() + match = re.search(r'^(?:static )?[^\n;]+\b' + name + r'\([^;]+?\)\s*\{', source, re.M) + assert match, name + return source[match.start():source.index('\n}', match.start()) + 2] + '\n' + + +code = r''' +#define _POSIX_C_SOURCE 200809L +#include "platform.h" +#include "ts_api.h" +#include "ts_ssh_client.h" +#include "ts_ssh_hosts_config.h" +#include "ts_known_hosts.h" +#define TS_MALLOC_PSRAM malloc +#define TS_STRDUP_PSRAM strdup +#define TS_LOGI(...) ((void)0) +#define TS_LOGW(...) ((void)0) +#define TS_LOGE(...) ((void)0) +static const char *error_name(esp_err_t e) { + switch(e) { + case ESP_OK:return "ESP_OK"; + case ESP_ERR_INVALID_STATE:return "ESP_ERR_INVALID_STATE"; + case ESP_ERR_NO_MEM:return "ESP_ERR_NO_MEM"; + default:return "ESP_FAIL"; + } +} +#define esp_err_to_name error_name +#define TS_API_ERR_HOST_NEW 1002 +#define TS_API_ERR_HOST_MISMATCH 1001 +struct ts_ssh_session_s {ts_ssh_config_t config;}; +typedef struct {char *data;size_t len;} ssh_request_key_t; + +static ts_host_verify_result_t host_status = TS_HOST_VERIFY_OK; +static char observed_fp[80] = "SHA256:SYNTHETICobservedFINGERPRINT0123456789abcdefghi"; +static int sessions, host_saves, auth_attempts, exec_calls; + +esp_err_t ts_keystore_load_private_key(const char *id,char **data,size_t *len) { + *data=strdup("SYNTHETIC PRIVATE KEY");*len=strlen(*data);return ESP_OK; +} +esp_err_t ts_ssh_session_create(const ts_ssh_config_t *config,ts_ssh_session_t *out) { + *out=malloc(sizeof(**out));(*out)->config=*config;++sessions;return ESP_OK; +} +esp_err_t ts_ssh_session_destroy(ts_ssh_session_t s) {free(s);--sessions;return ESP_OK;} +esp_err_t ts_ssh_disconnect(ts_ssh_session_t s) {return ESP_OK;} +const char *ts_ssh_get_error(ts_ssh_session_t s) {return "SSH connection failed (code=259)";} +esp_err_t ts_known_hosts_verify(ts_ssh_session_t s,ts_host_verify_result_t *status,ts_known_host_t *info) { + *status=host_status; + if(info) { + strcpy(info->host,s->config.host);info->port=s->config.port; + strcpy(info->fingerprint,observed_fp); + } + return ESP_OK; +} +esp_err_t ts_known_hosts_add(ts_ssh_session_t s) {++host_saves;host_status=TS_HOST_VERIFY_OK;return ESP_OK;} +esp_err_t ts_known_hosts_get(const char *host,uint16_t port,ts_known_host_t *info) { + strcpy(info->fingerprint,"SHA256:SYNTHETICstoredFINGERPRINT0123456789abcdefghijkl");return ESP_OK; +} +esp_err_t ts_ssh_connect_with_verifier(ts_ssh_session_t s,ts_ssh_verify_cb_t verify,void *context) { + /* Same order as production: the host key is judged before any credential is sent. */ + esp_err_t ret=verify(s,context);if(ret!=ESP_OK)return ret; + ++auth_attempts;return ESP_OK; +} +esp_err_t ts_ssh_exec(ts_ssh_session_t s,const char *cmd,ts_ssh_exec_result_t *r) { + ++exec_calls;memset(r,0,sizeof(*r));r->stdout_data=strdup("TianshanOS SSH Test OK\n");return ESP_OK; +} +void ts_ssh_exec_result_free(ts_ssh_exec_result_t *r) {free(r->stdout_data);free(r->stderr_data);} +''' +for name in ['ts_api_result_free', 'ts_api_result_ok', 'ts_api_result_error']: + code += extract('components/ts_api/src/ts_api.c', name) +code += extract('components/ts_api/src/ts_api_ssh.c', 'cleanup_key_buffer') +code += extract('components/ts_api/src/ts_api_ssh.c', 'configure_ssh_from_params') +code += extract('components/ts_api/src/ts_api_ssh.c', 'verify_host_fingerprint') +source = Path('components/ts_api/src/ts_api_ssh.c').read_text() +start = source.index('typedef struct {\n const cJSON *params;') +code += source[start:source.index('} verify_context_t;', start) + len('} verify_context_t;')] + '\n' +for name in ['verify_before_auth', 'connect_verified', 'api_ssh_exec', 'api_ssh_test']: + code += extract('components/ts_api/src/ts_api_ssh.c', name) + +# The production response chain (success / API-not-found / failure) of api_handler, taken from the source. +web_source = Path('components/ts_webui/src/ts_webui_api.c').read_text() +start = web_source.index(' if (ret == ESP_OK || result.code == TS_API_OK) {') +end = web_source.index('\n}\n\nstatic esp_err_t login_handler', start) +code += r''' +static char *http_body; +static int http_error_status; +static char http_error_text[96]; +static esp_err_t ts_http_send_json(void *req,int status,const char *json) { + assert(status==200);free(http_body);http_body=strdup(json);return ESP_OK; +} +static esp_err_t ts_http_send_error(void *req,int status,const char *msg) { + http_error_status=status;snprintf(http_error_text,sizeof http_error_text,"%s",msg);return ESP_FAIL; +} +static esp_err_t send_result(esp_err_t ret,ts_api_result_t result) { + void *req=NULL; +''' + web_source[start:end] + '\n}\n' + +code += r''' +static void save(const char *name) { + const char *dir=getenv("WIRE_DIR");assert(dir); + char path[512];snprintf(path,sizeof path,"%s/%s",dir,name); + FILE *f=fopen(path,"w");assert(f);fputs(http_body,f);fclose(f); +} +static void reset(ts_host_verify_result_t status) { + host_status=status;host_saves=auth_attempts=exec_calls=0; + strcpy(observed_fp,"SHA256:SYNTHETICobservedFINGERPRINT0123456789abcdefghi"); +} +/* Exactly what the Web UI sends for "test connection". */ +static cJSON *page_params(void) { + return cJSON_Parse("{\"host\":\"192.0.2.8\",\"port\":22,\"user\":\"test\",\"keyid\":\"synthetic-key\"," + "\"command\":\"echo \\\"TianshanOS SSH Test OK\\\"\",\"trust_new\":false}"); +} +typedef esp_err_t (*handler_t)(const cJSON *,ts_api_result_t *); +/* Run a production handler, push its result through the production response chain, return the wire JSON. */ +static cJSON *wire_of(handler_t handler,cJSON *params,const char *capture,esp_err_t *ret_out,int *code_out) { + ts_api_result_t r={0};esp_err_t ret=handler(params,&r); + if(ret_out)*ret_out=ret; + if(code_out)*code_out=r.code; + int code=r.code; + assert(send_result(ret,r)==ESP_OK); + cJSON *wire=cJSON_Parse(http_body);assert(wire); + assert(cJSON_GetObjectItem(wire,"code")->valueint==code); + if(capture)save(capture); + return wire; +} +static const char *str(cJSON *o,const char *key) { + cJSON *i=cJSON_GetObjectItem(o,key);assert(cJSON_IsString(i));return i->valuestring; +} +static void expect_no_progress(void) {assert(!host_saves && !auth_attempts && !exec_calls && sessions==0);} + +static void new_host(void) { + reset(TS_HOST_VERIFY_NOT_FOUND); + esp_err_t ret;int code; + cJSON *p=page_params(); + cJSON *wire=wire_of(api_ssh_exec,p,"wire-new-host.json",&ret,&code); + assert(ret!=ESP_OK && code==TS_API_ERR_HOST_NEW); + /* THE regression: the fingerprint must reach the client even though ssh.exec returned a failure. */ + cJSON *data=cJSON_GetObjectItem(wire,"data");assert(cJSON_IsObject(data)); + assert(!strcmp(str(wire,"error"),"New host requires confirmation")); + assert(!strcmp(str(data,"status"),"new_host") && !strcmp(str(data,"host"),"192.0.2.8")); + assert(cJSON_GetObjectItem(data,"port")->valueint==22); + assert(!strcmp(str(data,"fingerprint"),observed_fp)); + expect_no_progress(); /* nothing trusted, no credentials sent, no command run */ + char shown[80];strcpy(shown,str(data,"fingerprint"));cJSON_Delete(wire); + + /* The user confirms exactly what was shown. */ + cJSON_AddStringToObject(p,"confirmed_fingerprint",shown); + cJSON_ReplaceItemInObject(p,"trust_new",cJSON_CreateTrue()); + cJSON_AddBoolToObject(p,"accept_changed",false); + wire=wire_of(api_ssh_exec,p,"wire-exec-ok.json",&ret,&code); + assert(ret==ESP_OK && code==TS_API_OK && host_saves==1 && auth_attempts==1 && exec_calls==1 && sessions==0); + assert(cJSON_GetObjectItem(cJSON_GetObjectItem(wire,"data"),"exit_code")->valueint==0); + cJSON_Delete(wire);cJSON_Delete(p); +} +static void new_host_other_key_on_confirm(void) { + /* The key presented changed between the dialog and the confirmation: confirming the old text must not trust it. */ + reset(TS_HOST_VERIFY_NOT_FOUND); + esp_err_t ret;int code; + cJSON *p=page_params(); + cJSON_AddStringToObject(p,"confirmed_fingerprint","SHA256:SYNTHETICsomethingTHEuserNEVERsaw000000000000000000"); + cJSON_ReplaceItemInObject(p,"trust_new",cJSON_CreateTrue()); + cJSON *wire=wire_of(api_ssh_exec,p,NULL,&ret,&code); + assert(ret!=ESP_OK && code==TS_API_ERR_HOST_NEW); + assert(!strcmp(str(cJSON_GetObjectItem(wire,"data"),"fingerprint"),observed_fp)); /* shows what is really presented */ + expect_no_progress(); + cJSON_Delete(wire);cJSON_Delete(p); +} +static void changed_host(void) { + reset(TS_HOST_VERIFY_MISMATCH); + esp_err_t ret;int code; + cJSON *p=page_params(); + cJSON *wire=wire_of(api_ssh_exec,p,"wire-changed-host.json",&ret,&code); + assert(ret!=ESP_OK && code==TS_API_ERR_HOST_MISMATCH); + cJSON *data=cJSON_GetObjectItem(wire,"data");assert(cJSON_IsObject(data)); + assert(!strcmp(str(wire,"error"),"Host key mismatch - possible MITM attack")); + assert(!strcmp(str(data,"status"),"mismatch")); + assert(!strcmp(str(data,"current_fingerprint"),observed_fp)); + assert(!strncmp(str(data,"stored_fingerprint"),"SHA256:SYNTHETICstored",22)); + expect_no_progress(); + char shown[80];strcpy(shown,str(data,"current_fingerprint"));cJSON_Delete(wire); + + /* accept_changed alone (no matching confirmation) is not enough. */ + cJSON_ReplaceItemInObject(p,"trust_new",cJSON_CreateFalse()); + cJSON_AddBoolToObject(p,"accept_changed",true); + wire=wire_of(api_ssh_exec,p,NULL,&ret,&code); + assert(ret!=ESP_OK && code==TS_API_ERR_HOST_MISMATCH);expect_no_progress();cJSON_Delete(wire); + + /* Confirmation of a different fingerprint is not enough either. */ + cJSON_AddStringToObject(p,"confirmed_fingerprint","SHA256:SYNTHETICsomethingELSE00000000000000000000000000000"); + wire=wire_of(api_ssh_exec,p,NULL,&ret,&code); + assert(ret!=ESP_OK && code==TS_API_ERR_HOST_MISMATCH);expect_no_progress();cJSON_Delete(wire); + + /* The fingerprint the user was shown, with accept_changed, is accepted exactly once. */ + cJSON_ReplaceItemInObject(p,"confirmed_fingerprint",cJSON_CreateString(shown)); + wire=wire_of(api_ssh_exec,p,NULL,&ret,&code); + assert(ret==ESP_OK && code==TS_API_OK && host_saves==1 && auth_attempts==1 && exec_calls==1 && sessions==0); + cJSON_Delete(wire);cJSON_Delete(p); +} +static void known_host(void) { + reset(TS_HOST_VERIFY_OK); + esp_err_t ret;int code; + cJSON *p=page_params(); + cJSON *wire=wire_of(api_ssh_exec,p,NULL,&ret,&code); + assert(ret==ESP_OK && code==TS_API_OK && host_saves==0 && auth_attempts==1 && exec_calls==1 && sessions==0); + cJSON_Delete(wire);cJSON_Delete(p); +} +static void ssh_test_endpoint(void) { + /* ssh.test reports the same statuses with an OK return value; it always carried the data. */ + reset(TS_HOST_VERIFY_NOT_FOUND); + esp_err_t ret;int code; + cJSON *p=page_params(); + cJSON *wire=wire_of(api_ssh_test,p,NULL,&ret,&code); + assert(ret==ESP_OK && code==TS_API_ERR_HOST_NEW); + assert(!strcmp(str(cJSON_GetObjectItem(wire,"data"),"fingerprint"),observed_fp)); + expect_no_progress();cJSON_Delete(wire);cJSON_Delete(p); +} +static void failures_without_details_keep_their_exact_shape(void) { + esp_err_t ret;int code; + cJSON *p=cJSON_Parse("{\"host\":\"192.0.2.8\",\"user\":\"test\",\"keyid\":\"k\"}"); /* no command */ + cJSON *wire=wire_of(api_ssh_exec,p,"wire-generic-failure.json",&ret,&code); + assert(ret!=ESP_OK && code==TS_API_ERR_INVALID_ARG); + assert(!strcmp(http_body,"{\"code\":1,\"error\":\"Missing 'command' parameter\"}")); + assert(!cJSON_GetObjectItem(wire,"data")); + cJSON_Delete(wire);cJSON_Delete(p); + /* A missing API stays an HTTP 404, not a business failure. */ + ts_api_result_t r={0};ts_api_result_error(&r,TS_API_ERR_NOT_FOUND,"API not found"); + http_error_status=0;assert(send_result(ESP_ERR_NOT_FOUND,r)==ESP_FAIL); + assert(http_error_status==404 && !strcmp(http_error_text,"API not found")); +} +static int alloc_calls, alloc_fail_at; +static void *counting_malloc(size_t n) {if(++alloc_calls==alloc_fail_at)return NULL;return malloc(n);} +static void failure_branch_under_allocation_failure(void) { + /* An allocation can fail at any point while the failure response is built. The branch must then send either the + * complete response (code, error and the full details) or HTTP 500 - never a truncated or corrupt document. */ + reset(TS_HOST_VERIFY_NOT_FOUND); + cJSON_Hooks counting={counting_malloc,free}; + int with_details=0,without_details=0,internal_error=0; + for(int k=1;k<=64;k++) { + cJSON *p=page_params();ts_api_result_t r={0}; + esp_err_t ret=api_ssh_exec(p,&r);cJSON_Delete(p); /* the real handler, normal allocator */ + assert(ret!=ESP_OK && r.code==TS_API_ERR_HOST_NEW && r.data); + free(http_body);http_body=NULL;http_error_status=0; + alloc_calls=0;alloc_fail_at=k;cJSON_InitHooks(&counting); /* only the response chain runs with injection */ + esp_err_t sent=send_result(ret,r); + cJSON_InitHooks(NULL); + if(http_body) { + assert(sent==ESP_OK && !http_error_status); + cJSON *wire=cJSON_Parse(http_body);assert(wire); + assert(cJSON_GetObjectItem(wire,"code")->valueint==TS_API_ERR_HOST_NEW); + assert(!strcmp(str(wire,"error"),"New host requires confirmation")); + cJSON *data=cJSON_GetObjectItem(wire,"data"); + if(data) { /* complete, never partial */ + assert(!strcmp(str(data,"status"),"new_host") && !strcmp(str(data,"host"),"192.0.2.8")); + assert(cJSON_GetObjectItem(data,"port")->valueint==22 && !strcmp(str(data,"fingerprint"),observed_fp)); + assert(cJSON_IsString(cJSON_GetObjectItem(data,"message"))); + ++with_details; + } else ++without_details; /* details dropped, error kept */ + cJSON_Delete(wire); + } else { + assert(sent!=ESP_OK && http_error_status==500);++internal_error; + } + if(alloc_calls=1 && without_details>=1 && internal_error>=1); +} +int main(void) { + new_host(); + new_host_other_key_on_confirm(); + changed_host(); + known_host(); + ssh_test_endpoint(); + failures_without_details_keep_their_exact_shape(); + failure_branch_under_allocation_failure(); + free(http_body); + puts("PASS production ssh.exec/ssh.test + HTTP response chain: fingerprint reaches the client for new/changed hosts; nothing trusted, authenticated or executed before an exact confirmation; wrong or missing confirmation rejected; plain failures keep {code,error}; allocation failure at every point gives the complete response or HTTP 500"); +} +''' +includes = ['tests/certificate/stubs', 'components/ts_security/include', + 'components/ts_api/include', 'components/ch405labs_esp_libssh2/libssh2/include', + str(idf / 'components/json/cJSON')] +env = {**os.environ, 'DEVELOPER_DIR': '/Library/Developer/CommandLineTools', 'WIRE_DIR': str(wire_dir)} +for stale in wire_dir.glob('*.json'): + stale.unlink() +file = build / 'ssh_fingerprint_wire.c' +file.write_text(code) +subprocess.run(['cc', '-std=c11', '-g', '-Wno-deprecated-declarations', '-fsanitize=address,undefined', + '-include', 'ts_ssh_probe.h', *[f'-I{x}' for x in includes], str(file), + 'components/ts_security/src/ts_ssh_probe.c', str(idf / 'components/json/cJSON/cJSON.c'), + '-o', str(build / 'ssh_fingerprint_wire')], check=True, env=env) +subprocess.run([str(build / 'ssh_fingerprint_wire')], check=True, env=env) + +# The bytes the device sends are contract fixtures for the Web UI tests. +status = 0 +fixtures.mkdir(parents=True, exist_ok=True) +for name in CAPTURES: + produced = (wire_dir / name).read_text() + target = fixtures / name + if update: + target.write_text(produced) + print('updated', target) + elif not target.exists() or json.loads(target.read_text()) != json.loads(produced): + print(f'FIXTURE DRIFT: {target} no longer matches what the production code sends.') + print(' produced:', produced) + print(' committed:', target.read_text() if target.exists() else '(missing)') + print(' If the wire change is intentional: UPDATE_FIXTURES=1 python3 tests/runtime/test_ssh_fingerprint_wire.py') + status = 1 +if status: + sys.exit(status) +print('PASS wire fixtures match: ' + ', '.join(CAPTURES)) From 7b899a07fbb61474def5f1e736293db01aedd1b5 Mon Sep 17 00:00:00 2001 From: Alfred Date: Fri, 9 Oct 2026 19:36:27 +0800 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20=E6=8C=87=E7=BA=B9=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E6=97=B6=E7=9A=84=E7=A1=AE=E8=AE=A4=E6=A1=86=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=98=8E=E7=A1=AE=E7=9A=84=E5=AE=89=E5=85=A8=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=EF=BC=88=E5=8F=AF=E9=80=89=EF=BC=8C=E5=8D=95=E7=8B=AC?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复「测试连接」确认框之后,指纹变化(1001)和新主机(1002)弹出的是同一个框: 不说指纹变了,也不显示已登记的旧指纹,用户容易当成普通确认点过去。 - 指纹变化时:标题「安全警告:主机指纹不匹配」,说明可能原因(中间人攻击、服务器重装、 IP 被分配给了别的服务器),同时显示已登记指纹和当前指纹,确认按钮为「更新主机密钥」, 默认焦点在「取消」,回车等于取消。 - 新主机的确认框不变。 - 文案沿用已有的 securityPage.* 语言包条目,没有新增翻译。 - index.html 中 app.js 的缓存版本串提升为 0.6.1-lpmu6-keygen2-fp1。 - 浏览器测试补充:标题、按钮文字、两个指纹、默认焦点、回车不接受;去掉本提交后这些断言失败。 验证(未接触真机):静态 122 项、浏览器 249 项、运行时 41 项、发布检查通过。 Co-Authored-By: Claude Sonnet 5.5 --- components/ts_webui/web/index.html | 2 +- components/ts_webui/web/js/app.js | 16 ++++++++--- docs/repair/ssh-fingerprint/RESULT.md | 8 ++++++ .../prompts/ssh-fingerprint.browser.test.cjs | 27 ++++++++++++++++--- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index d93c320..078b65f 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -261,6 +261,6 @@ - + diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 7914498..e92ddcc 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -10994,11 +10994,21 @@ async function testSshHostByIndex(index) { }; let execResult = await api.call('ssh.exec', params); if ([1001, 1002].includes(execResult.code)) { + const changed = execResult.code === 1001; const fingerprint = execResult.data?.fingerprint || execResult.data?.current_fingerprint; - if (fingerprint && await confirmSheet({ + const mono = text => `
${escapeHtml(text)}
`; + // A changed key can mean a man-in-the-middle attack: say so, show both fingerprints, default to Cancel. + const sheetOptions = changed ? { + title: t('securityPage.mismatchTitle'), + bodyHtml: `${escapeHtml(t('securityPage.hostKeyChangedWarning'))}
• ${escapeHtml(t('securityPage.mitmAttack'))}
• ${escapeHtml(t('securityPage.serverReinstalled'))}
• ${escapeHtml(t('securityPage.ipReassigned'))}` + + mono(`${host.host}:${host.port}\n\n${t('securityPage.storedFingerprint')}\n${execResult.data?.stored_fingerprint || '-'}\n\n${t('securityPage.currentFingerprint')}\n${fingerprint}`), + primary: t('securityPage.updateHostKey'), tone: 'danger' + } : { title: t('ui.fingerprintTitle'), - bodyHtml: `${escapeHtml(runtimeText('confirmFingerprint'))}
${escapeHtml(host.host + ':' + host.port)}\n${escapeHtml(fingerprint)}
`, - primary: t('ui.trustConnect') })) { + bodyHtml: `${escapeHtml(runtimeText('confirmFingerprint'))}${mono(`${host.host}:${host.port}\n${fingerprint}`)}`, + primary: t('ui.trustConnect') + }; + if (fingerprint && await confirmSheet(sheetOptions)) { params.confirmed_fingerprint = fingerprint; params.trust_new = execResult.code === 1002; params.accept_changed = execResult.code === 1001; diff --git a/docs/repair/ssh-fingerprint/RESULT.md b/docs/repair/ssh-fingerprint/RESULT.md index 40d9dbd..5b47574 100644 --- a/docs/repair/ssh-fingerprint/RESULT.md +++ b/docs/repair/ssh-fingerprint/RESULT.md @@ -37,3 +37,11 @@ SSH 主机页点「测试连接」:目标主机是新的(还没登记指纹 ## 尚未验证 真机。建议刷机后检查:(1) 对一台从未连接过的主机点「测试连接」→ 出现确认框;取消 → 不登记;再点并确认 → 连接成功且已登记。(2) 把已登记主机换一把 SSH 主机密钥后点「测试连接」→ 出现确认框并显示新指纹。 + +## 附:指纹变化时的确认框(单独提交,可选) + +修好之后「指纹变化」也会弹确认框,但原来的框和「新主机」完全一样:不说指纹变了,也不显示已登记的旧指纹。指纹变化可能是服务器重装,也可能是中间人攻击,用户很容易当成普通确认点过去。 + +单独的一个提交把它改成安全警告:标题「安全警告:主机指纹不匹配」,说明可能的原因,同时显示已登记的指纹和当前指纹,确认按钮写「更新主机密钥」,默认焦点落在「取消」(按回车等于取消)。文案全部沿用已有的 `securityPage.*` 语言包条目(原先那个再也打不开的旧弹窗用过),没有新增翻译;新主机的确认框不变。`index.html` 里 `app.js` 的缓存版本串同步提升为 `0.6.1-lpmu6-keygen2-fp1`。 + +如果不想要这部分,丢掉该提交即可,前一个提交(修复本身)可独立使用。 diff --git a/tests/prompts/ssh-fingerprint.browser.test.cjs b/tests/prompts/ssh-fingerprint.browser.test.cjs index 2e479cf..8307f82 100644 --- a/tests/prompts/ssh-fingerprint.browser.test.cjs +++ b/tests/prompts/ssh-fingerprint.browser.test.cjs @@ -76,9 +76,11 @@ const toastKind = (page, kind) => page.waitForFunction(kind => document.getEleme const settle = page => page.waitForTimeout(400); // a background retry would show up as an extra request for (const language of ['zh-CN', 'en-US']) { - for (const [name, wire, shownKey, code, flags] of [ - ['new host', NEW_HOST, 'fingerprint', 1002, {trust_new: true, accept_changed: false}], - ['changed fingerprint', CHANGED_HOST, 'current_fingerprint', 1001, {trust_new: false, accept_changed: true}], + for (const [name, wire, shownKey, code, flags, dialog] of [ + ['new host', NEW_HOST, 'fingerprint', 1002, {trust_new: true, accept_changed: false}, + {title: 'ui.fingerprintTitle', primary: 'ui.trustConnect', focus: '1'}], + ['changed fingerprint', CHANGED_HOST, 'current_fingerprint', 1001, {trust_new: false, accept_changed: true}, + {title: 'securityPage.mismatchTitle', primary: 'securityPage.updateHostKey', focus: '0'}], ]) { const fingerprint = wire.data?.[shownKey]; const needData = () => assert.equal(typeof fingerprint, 'string', @@ -96,7 +98,24 @@ for (const language of ['zh-CN', 'en-US']) { assert.equal(execRequests.length, 1); assert.equal(execRequests[0].trust_new, false); // nothing is trusted before the user decides assert.equal('confirmed_fingerprint' in execRequests[0], false); - await sheet(page).locator('button[data-r="0"]').click(); + // The sheet says what is happening and what the buttons do. + const text = await sheet(page).textContent(); + const label = key => page.evaluate(key => t(key), key); + assert(text.includes(await label(dialog.title)), 'title'); + assert(text.includes(await label(dialog.primary)), 'confirm button'); + assert.equal(await page.evaluate(() => document.activeElement?.dataset?.r), dialog.focus); + if (code === 1001) { + // A changed key is a security warning: both fingerprints are shown, the default button is Cancel, + // and Enter (which activates the focused button) therefore cancels instead of accepting. + assert(shown.includes(wire.data.stored_fingerprint), 'stored fingerprint'); + assert(text.includes(await label('securityPage.hostKeyChangedWarning')), 'warning'); + await page.keyboard.press('Enter'); + await sheet(page).waitFor({state: 'detached', timeout: 5000}); + await settle(page); + assert.equal(execRequests.length, 1); + } else { + await sheet(page).locator('button[data-r="0"]').click(); + } }); }); From 85d84740bcd1faa8488880bc71444f40df1bb62a Mon Sep 17 00:00:00 2001 From: Alfred Date: Fri, 9 Oct 2026 09:11:34 +0800 Subject: [PATCH 03/29] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=2050=20?= =?UTF-8?q?=E4=B8=AA=E6=97=A0=E5=85=A5=E5=8F=A3=E7=9A=84=E6=97=A7=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=87=BD=E6=95=B0=E5=8F=8A=E5=85=B6=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.js 删除确认没有任何入口的旧函数 50 个(约 900 行):LED 旧控制与滤镜、 旧 SSH 主机管理、旧自动化变量页、旧动作行等;同时删除它们的 27 条 window 导出、因此失去用途的 selectedFilter / allVariables 变量,以及再也打不开的 「主机指纹不匹配」旧弹窗及其处理函数。app.js 减少约 47 KB(gzip 约 8 KB)。 确认方式:语法树可达性分析加全文搜索(含字符串里的函数名、window.xxx、 模板里的 onclick);删除后重新分析无人调用函数为 0;静态 122 项、浏览器 模拟 232 项与删除前一致。handleModalLogMessage 经 window 在运行时被调用, 不在删除范围内。 input-repair 浏览器测试原先直接调用旧矩阵页的 applySelectedFilter, 改为在现行的弹窗滤镜路径上验证同样的零值保留。macOS 27 对照清单与说明 文档同步。 Co-Authored-By: Claude Sonnet 5.5 --- components/ts_webui/web/js/app.js | 1074 +------------------ docs/macos27/ACCEPTANCE.md | 4 + tests/macos27-r2/dialogs.json | 7 - tests/macos27-r2/report.py | 1 - tests/prompts/input-repair.browser.test.cjs | 6 +- 5 files changed, 8 insertions(+), 1084 deletions(-) diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index e92ddcc..5e24f50 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -2318,14 +2318,6 @@ async function unbindTempVariable() { } } -/** - * 保存 AGX 服务器配置 (保留用于兼容) - */ -async function saveAgxConfig() { - showToast((typeof t === 'function' ? t('toast.agxMovedToBinding') : 'AGX 配置已移至变量绑定'), 'info'); - await loadVariableBindStatus(); -} - /** * 应用风扇曲线 */ @@ -2987,29 +2979,6 @@ function evaluateExpression(expression, variables) { } } -/** - * 格式化显示值 - * @param {any} value - 原始值 - * @param {object} config - 格式化配置 { decimals, prefix, suffix, format } - */ -function formatDisplayValue(value, config) { - if (value === null || value === undefined) return '-'; - - const { decimals = 1, prefix = '', suffix = '', format } = config || {}; - - // 自定义格式 - if (format) { - return format.replace('{value}', value).replace('{prefix}', prefix).replace('{suffix}', suffix); - } - - // 数字格式化 - if (typeof value === 'number') { - return prefix + value.toFixed(decimals) + suffix; - } - - return prefix + String(value) + suffix; -} - /** * 校验并安全化组件图标 HTML * 防止损坏的 标签吞噬后续文本(如标签名称) @@ -4503,19 +4472,6 @@ async function syncTimeFromBrowser(silent = false) { } } -async function forceNtpSync() { - try { - showToast(t('toast.ntpSyncing'), 'info'); - const result = await api.timeForceSync(); - if (result.data?.syncing) { - showToast(t('toast.ntpStarted'), 'success'); - setTimeout(refreshSystemPage, 3000); - } - } catch (e) { - showToast(t('toast.ntpFailed') + ': ' + e.message, 'error'); - } -} - function showTimezoneModal() { let modal = document.getElementById('timezone-modal'); if (!modal) { @@ -4573,80 +4529,6 @@ async function applyTimezone() { let ledDevices = {}; let ledEffects = []; -async function loadLedPage() { - clearInterval(refreshInterval); - - // 取消系统页面的订阅 - - const content = document.getElementById('page-content'); - content.innerHTML = ` -
-
-

${t('nav.led')}

-
- - - -
-
-
-
${typeof t === 'function' ? t('ledPage.loadingDevices') : '加载设备中...'}
-
-
- `; - - await refreshLedPage(); -} - -async function refreshLedPage() { - const container = document.getElementById('led-devices-grid'); - - try { - const result = await api.ledList(); - - if (result.data && result.data.devices && result.data.devices.length > 0) { - // 存储设备信息 - result.data.devices.forEach(dev => { - ledDevices[dev.name] = dev; - if (dev.current && dev.current.animation) { - selectedEffects[dev.name] = dev.current.animation; - } - // 初始化 LED 状态 - if (dev.current) { - ledStates[dev.name] = dev.current.on || false; - } - }); - - window.ledDevicesCache = result.data.devices; - - // 渲染设备卡片 - container.innerHTML = result.data.devices.map(dev => generateLedDeviceCard(dev)).join(''); - - // 加载字体列表 & 显示色彩校正按钮 - if (result.data.devices.some(d => d.name === 'matrix' || d.layout === 'matrix')) { - loadFontList(); - const ccBtn = document.getElementById('led-page-cc-btn'); - if (ccBtn) ccBtn.style.display = ''; - } - } else { - container.innerHTML = ` -
-
-

${t('ledPage.ledNotFound')}

-

${t('ledPage.ledNotStartedHint')}

-
    -
  • ${t('promptRepair.ledServiceStatus')} (service --status)
  • -
  • ${t('ledPage.checkGpioConfig')}
  • -
-
- `; - } - } catch (e) { - console.error('LED list error:', e); - container.innerHTML = `
${escapeHtml(typeof t === 'function' ? t('common.loadFailedMsg', { msg: e.message }) : '加载失败: ' + e.message)}
`; - } -} - // 色块:[显示色, 下发给灯的颜色, 名称 key 后缀] const LED_PRESETS = [['#ff3b30', '#ff0000', 'Red'], ['#ff6b00', '#ff6600', 'Orange'], ['#ffd60a', '#ffd700', 'Yellow'], ['#00c766', '#00d26a', 'Green'], ['#00e5ff', '#00ffff', 'Cyan'], ['#2d7dff', '#2d7dff', 'Blue'], ['#d630ff', '#d630ff', 'Purple'], ['#ffffff', '#ffffff', 'White'], ['#ffcccc', '#ffcccc', 'Pink'], ['#e0e0e0', '#e0e0e0', 'Gray']]; @@ -4742,18 +4624,6 @@ async function quickFillColor(device, color) { } } -async function quickStartEffect(device, effect) { - try { - requireApiSuccess(await api.ledEffectStart(device, effect, { speed: 50 }), 'ledEffectStart'); - selectedEffects[device] = effect; - ledStates[device] = true; - updateLedCardState(device, true, effect); - showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动失败: ${e.message}`, 'error'); - } -} - async function allLedsOff() { const devices = window.ledDevicesCache || []; let success = 0; const failures = []; @@ -4796,90 +4666,6 @@ function updateLedCardState(device, isOn, effect = undefined) { }); } -// 颜色选择模态框 -function openColorModal(device) { - const deviceData = window.ledDevicesCache?.find(d => d.name === device); - const current = deviceData?.current || {}; - const currentColor = current.color || {r: 255, g: 0, b: 0}; - const colorHex = '#' + - currentColor.r.toString(16).padStart(2, '0') + - currentColor.g.toString(16).padStart(2, '0') + - currentColor.b.toString(16).padStart(2, '0'); - - const modal = document.getElementById('led-modal'); - const title = document.getElementById('led-modal-title'); - const body = document.getElementById('led-modal-body'); - - title.textContent = `${device} - ${typeof t === 'function' ? t('ui.colorSettings') : '颜色设置'}`; - body.innerHTML = ` - - `; - - modal.classList.remove('hidden'); -} - -async function applyColorFromModal(device) { - const color = document.getElementById(`modal-color-picker-${device}`)?.value || '#ffffff'; - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - ledStates[device] = true; - updateToggleButton(device, true); - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} 已填充 ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); - } -} - -async function quickFillFromModal(device, color) { - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - ledStates[device] = true; - updateToggleButton(device, true); - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} → ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); - } -} - -// 更新开关按钮状态 -function updateToggleButton(device, isOn) { - // 更新新版卡片 - updateLedCardState(device, isOn); - - // 旧版兼容 - const btn = document.getElementById(`toggle-${device}`); - if (btn && !btn.classList.contains('led-power-btn')) { - if (isOn) { - btn.classList.add('on'); - btn.innerHTML = ' ' + (typeof t === 'function' ? t('ledPage.on') : '已开启'); - } else { - btn.classList.remove('on'); - btn.innerHTML = ' ' + (typeof t === 'function' ? t('ledPage.off') : '已关闭'); - } - } -} - // 生成 LED 模态框内容 // LED 弹窗小控件:滑块(--p 由 oninput 同步)、颜色色块、分段控件(选中值写进同名隐藏 input) const ledSlider = (id, min, max, val, on) => `
`; @@ -5614,26 +5400,11 @@ async function ccImport() { } } -function getDeviceIcon(name) { - const icons = { - 'touch': '', - 'board': '', - 'matrix': '' - }; - return icons[name.toLowerCase()] || ''; -} - function getDeviceDescription(name) { const keys = { 'touch': 'ledPage.deviceTouch', 'board': 'ledPage.deviceBoard', 'matrix': 'ledPage.deviceMatrix' }; return t(keys[name.toLowerCase()] || 'ledPage.deviceDefault'); } -/** 程序动画模态框内使用的 RemixIcon(无 emoji,用已纳入 minimal 字体的 ri-play-line) */ -function getEffectIconRemix(name) { - if (!name) return ''; - return ' '; -} - /** 动画名称首字母大写(用于更多动画弹窗展示) */ function filterDisplayName(name) { const keys = {"pulse": "ledPage.filterPulse", "breathing": "ledPage.filterBreathing", "blink": "ledPage.filterBlink", "wave": "ledPage.filterWave", "scanline": "ledPage.filterScanline", "glitch": "ledPage.filterGlitch", "rainbow": "ledPage.filterRainbow", "sparkle": "ledPage.filterSparkle", "plasma": "ledPage.filterPlasma", "sepia": "ledPage.filterSepia", "posterize": "ledPage.filterPosterize", "contrast": "ledPage.filterContrast", "invert": "ledPage.filterInvert", "grayscale": "ledPage.filterGrayscale"}; @@ -5651,63 +5422,6 @@ const selectedEffects = {}; // 支持颜色参数的动画 const colorSupportedEffects = ['breathing', 'solid', 'rain']; -// 选择动画(旧版兼容,保留) -function selectEffect(device, effect, btn) { - selectedEffects[device] = effect; - - // 更新按钮状态 - const panel = btn.closest('.led-panel'); - panel.querySelectorAll('.effect-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); -} - -// 旧版 showEffectConfig 保持兼容 -function showEffectConfig(device, effect) { - selectedEffects[device] = effect; -} - -async function applyEffect(device) { - const effect = selectedEffects[device]; - if (!effect) { - showToast((typeof t === 'function' ? t('toast.selectAnimation') : '请先选择一个动画'), 'warning'); - return; - } - - const speed = parseInt(document.getElementById(`effect-speed-${device}`)?.value || '50'); - const color = document.getElementById(`effect-color-${device}`)?.value || '#ff0000'; - - try { - const params = { speed }; - // 只有支持颜色的动画才传递颜色参数 - if (colorSupportedEffects.includes(effect)) { - params.color = color; - } - requireApiSuccess(await api.ledEffectStart(device, effect, params), 'ledEffectStart'); - - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - const icon = btn.querySelector('.power-icon'); - if (icon) icon.innerHTML = ''; - } - - // 更新顶部当前动画显示 - const currentAnim = document.getElementById(`current-anim-${device}`); - if (currentAnim) currentAnim.textContent = `▶ ${effectDisplayName(effect)}`; - - showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect} 已启动`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动动画失败: ${e.message}`, 'error'); - } -} - -function updateBrightnessLabel(device, value) { - const label = document.getElementById(`brightness-val-${device}`); - if (label) label.textContent = value; -} - async function setBrightness(device, value) { try { requireApiSuccess(await api.ledBrightness(device, parseInt(value)), 'ledBrightness'); @@ -5742,87 +5456,6 @@ async function toggleLed(device) { } } -async function ledOn(device, color = '#ffffff') { - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - ledStates[device] = true; - updateToggleButton(device, true); - showToast(typeof t === 'function' ? t('toast.ledTurnedOn', { device: getDeviceDescription(device) }) : `${device} 已开启`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledOnFailed') + ': ' + e.message : `开启失败: ${e.message}`, 'error'); - } -} - -async function fillColor(device) { - const color = document.getElementById(`color-${device}`).value; - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOffLight') : '关灯'; - } - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} 已填充 ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `${device} 填充失败: ${e.message}`, 'error'); - } -} - -async function quickFill(device, color) { - document.getElementById(`color-${device}`).value = color; - try { - requireApiSuccess(await api.ledFill(device, color), 'ledFill'); - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOffLight') : '关灯'; - } - showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} → ${color}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); - } -} - -async function clearLed(device) { - try { - requireApiSuccess(await api.ledClear(device), 'ledClear'); - // 更新状态为关闭 - ledStates[device] = false; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.remove('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOnLight') : '开灯'; - } - showToast(typeof t === 'function' ? t('toast.ledTurnedOff', { device: getDeviceDescription(device) }) : `${device} 已关闭`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledOffFailed') + ': ' + e.message : `关闭失败: ${e.message}`, 'error'); - } -} - -async function startEffect(device, effect) { - try { - requireApiSuccess(await api.ledEffectStart(device, effect), 'ledEffectStart'); - // 更新状态为开启 - ledStates[device] = true; - const btn = document.getElementById(`toggle-${device}`); - if (btn) { - btn.classList.add('on'); - btn.querySelector('.toggle-icon').innerHTML = ''; - btn.querySelector('.toggle-text').textContent = typeof t === 'function' ? t('ui.turnOffLight') : '关灯'; - } - showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect} 已启动`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动动画失败: ${e.message}`, 'error'); - } -} - async function stopEffect(device) { try { requireApiSuccess(await api.ledEffectStop(device), 'ledEffectStop'); @@ -5982,26 +5615,6 @@ function filePickerItemClick(element, path, isDir) { } } -// 文件选择器项目双击 -async function filePickerItemDblClick(path, isDir) { - if (isDir) { - await loadFilePickerDirectory(path); - } else { - // 双击文件直接确认 - filePickerSelectedFile = path; - confirmFilePicker(); - } -} - -// 文件选择器上级目录 -async function filePickerGoUp() { - if (filePickerCurrentPath === '/sdcard' || filePickerCurrentPath === '/') { - return; - } - const parentPath = filePickerCurrentPath.substring(0, filePickerCurrentPath.lastIndexOf('/')) || '/sdcard'; - await loadFilePickerDirectory(parentPath); -} - // 关闭文件选择器 function closeFilePicker() { document.getElementById('file-picker-modal').classList.add('hidden'); @@ -6017,62 +5630,6 @@ function confirmFilePicker() { closeFilePicker(); } -// 显示图像 -async function displayImage() { - const pathInput = document.getElementById('matrix-image-path'); - const centerCheckbox = document.getElementById('matrix-image-center'); - - const path = pathInput.value.trim(); - if (!path) { - showToast((typeof t === 'function' ? t('toast.enterImagePath') : '请输入图像路径'), 'error'); - return; - } - - try { - const result = requireApiSuccess(await api.ledImage(path, 'matrix', centerCheckbox.checked), 'ledImage'); - showToast(typeof t === 'function' ? t('toast.imageDisplayed') : '图像显示成功', 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledImageFailed') + ': ' + e.message : `显示图像失败: ${e.message}`, 'error'); - } -} - -// 生成 QR 码 -async function generateQrCode() { - const textInput = document.getElementById('matrix-qr-text'); - const eccSelect = document.getElementById('matrix-qr-ecc'); - const fgColor = document.getElementById('matrix-qr-fg'); - const bgImageInput = document.getElementById('matrix-qr-bg-image'); - - const text = textInput.value.trim(); - if (!text) { - showToast((typeof t === 'function' ? t('toast.enterQrContent') : '请输入 QR 码内容'), 'error'); - return; - } - - const params = { - ecc: eccSelect.value, - color: fgColor.value - }; - - // 添加背景图(如果有) - const bgImage = bgImageInput.value.trim(); - if (bgImage) { - params.bg_image = bgImage; - } - - try { - const result = requireApiSuccess(await api.ledQrcode(text, params), 'ledQrcode'); - showToast(typeof t === 'function' ? t('toast.qrGenerated') : 'QR 码生成成功', 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledQrFailed') + ': ' + e.message : `生成 QR 码失败: ${e.message}`, 'error'); - } -} - -// 清除 QR 码背景图 -function clearQrBgImage() { - document.getElementById('matrix-qr-bg-image').value = ''; -} - // 加载字体列表 async function loadFontList() { const fontSelect = document.getElementById('matrix-text-font'); @@ -6122,59 +5679,6 @@ async function loadFontList() { } } -// 显示文本 -async function displayText() { - const textInput = document.getElementById('matrix-text-content'); - const fontSelect = document.getElementById('matrix-text-font'); - const alignSelect = document.getElementById('matrix-text-align'); - const colorInput = document.getElementById('matrix-text-color'); - const xInput = document.getElementById('matrix-text-x'); - const yInput = document.getElementById('matrix-text-y'); - const autoPos = document.getElementById('matrix-text-auto-pos'); - const scrollSelect = document.getElementById('matrix-text-scroll'); - const speedInput = document.getElementById('matrix-text-speed'); - const loopCheckbox = document.getElementById('matrix-text-loop'); - - const text = textInput.value.trim(); - if (!text) { - showToast((typeof t === 'function' ? t('toast.enterDisplayText') : '请输入显示文本'), 'error'); - return; - } - - const params = { - device: 'matrix', - font: fontSelect.value, - align: alignSelect.value, - color: colorInput.value, - scroll: scrollSelect.value, // 滚动方向:none/left/right/up/down - speed: parseInt(speedInput.value), - loop: loopCheckbox.checked - }; - - // 添加坐标(如果不是自动定位) - if (!autoPos.checked) { - params.x = parseInt(xInput.value) || 0; - params.y = parseInt(yInput.value) || 0; - } - - try { - const result = requireApiSuccess(await api.ledText(text, params), 'ledText'); - showToast(typeof t === 'function' ? t('toast.textDisplayed') : '文本显示成功', 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextFailed') + ': ' + e.message : `显示文本失败: ${e.message}`, 'error'); - } -} - -// 停止文本 -async function stopText() { - try { - requireApiSuccess(await api.ledTextStop('matrix'), 'ledTextStop'); - showToast((typeof t === 'function' ? t('toast.textStopped') : '文本已停止'), 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextStopFailed') + ': ' + e.message : `停止失败: ${e.message}`, 'error'); - } -} - // 滤镜配置:每个滤镜的参数列表和默认值 const filterConfig = { 'pulse': { params: ['speed'], defaults: { speed: 50 } }, @@ -6214,146 +5718,12 @@ const paramLabels = { 'amount': { get label() { return t('ledPage.paramAmount'); }, min: 0, max: 100, unit: '%' } }; -let selectedFilter = null; - function getParamLabel(paramKey) { const keyMap = { speed: 'ledPage.paramSpeed', intensity: 'ledPage.paramIntensity', wavelength: 'ledPage.paramWavelength', amplitude: 'ledPage.paramAmplitude', direction: 'ledPage.paramDirection', angle: 'ledPage.paramAngle', width: 'ledPage.paramWidth', frequency: 'ledPage.paramFrequency', saturation: 'ledPage.paramSaturation', density: 'ledPage.paramDensity', decay: 'ledPage.paramDecay', scale: 'ledPage.paramScale', levels: 'ledPage.paramLevels', amount: 'ledPage.paramAmount' }; const key = keyMap[paramKey]; return (typeof t === 'function' && key) ? t(key) : (paramLabels[paramKey]?.label || paramKey); } -// 选择滤镜 -function selectFilter(filterName, btnElement) { - selectedFilter = filterName; - - // 高亮当前选中的按钮 - document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('selected')); - if (btnElement) btnElement.classList.add('selected'); - - // 更新显示的滤镜名称 - const nameSpan = document.getElementById('selected-filter-name'); - if (nameSpan) nameSpan.textContent = typeof t === 'function' ? t('led.selectedFilter', { filter: filterDisplayName(filterName) }) : `已选择: ${filterName}`; - - // 启用应用按钮 - const applyBtn = document.getElementById('apply-filter-btn'); - if (applyBtn) applyBtn.disabled = false; - - // 动态生成参数控件 - const paramsDiv = document.getElementById('filter-params'); - const config = filterConfig[filterName]; - - if (config && config.params && config.params.length > 0) { - paramsDiv.style.display = 'block'; - paramsDiv.innerHTML = ''; // 清空现有控件 - - config.params.forEach(param => { - const paramInfo = paramLabels[param]; - const defaultValue = config.defaults[param] ?? 50; - - const row = document.createElement('div'); - row.className = 'config-row'; - row.style.cssText = 'display: flex; align-items: center; gap: 10px; margin-bottom: 10px;'; - - const label = document.createElement('label'); - label.textContent = getParamLabel(param); - label.style.minWidth = '60px'; - - const slider = document.createElement('input'); - slider.type = 'range'; - slider.id = `filter-${param}`; - slider.min = paramInfo.min; - slider.max = paramInfo.max; - slider.value = defaultValue; - slider.style.flex = '1'; - - const valueSpan = document.createElement('span'); - valueSpan.id = `filter-${param}-val`; - valueSpan.textContent = defaultValue + paramInfo.unit; - valueSpan.style.minWidth = '50px'; - valueSpan.style.textAlign = 'right'; - - slider.oninput = () => { - valueSpan.textContent = slider.value + paramInfo.unit; - }; - - row.appendChild(label); - row.appendChild(slider); - row.appendChild(valueSpan); - paramsDiv.appendChild(row); - }); - } else { - paramsDiv.style.display = 'none'; - paramsDiv.innerHTML = ''; - } -} - -// 应用选中的滤镜 -async function applySelectedFilter() { - if (!selectedFilter) { - showToast((typeof t === 'function' ? t('toast.selectFilter') : '请先选择滤镜'), 'error'); - return; - } - - // 收集所有参数 - const params = { device: 'matrix', filter: selectedFilter }; - const config = filterConfig[selectedFilter]; - - if (config && config.params) { - config.params.forEach(param => { - const input = document.getElementById(`filter-${param}`); - if (input) { - let value = parseInt(input.value); - // 根据参数类型转换值 - if (param === 'saturation' || param === 'frequency') { - value = Math.round(value * 2.55); // 0-100 转 0-255 - - } else if (param === 'amount') { - value = value - 50; // 0-100 转 -50 to +50 - } - params[param] = value; - } - }); - } - - try { - requireApiSuccess(await api.call('led.filter.start', params), 'call'); - showToast(typeof t === 'function' ? t('toast.filterApplied', { filter: filterDisplayName(selectedFilter) }) : `已应用滤镜: ${selectedFilter}`, 'success'); - } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterApplyFailed') + ': ' + e.message : `应用滤镜失败: ${e.message}`, 'error'); - } -} - -// 应用滤镜(兼容旧接口) -async function applyFilter(filterName, btnElement) { - selectFilter(filterName, btnElement); - await applySelectedFilter(); -} - -// 停止滤镜 -async function stopFilter() { - try { - requireApiSuccess(await api.ledFilterStop('matrix'), 'ledFilterStop'); - showToast((typeof t === 'function' ? t('toast.filterStopped') : '滤镜已停止'), 'success'); - - // 移除滤镜按钮高亮和选中状态 - document.querySelectorAll('.filter-btn').forEach(btn => { - btn.classList.remove('active'); - btn.classList.remove('selected'); - }); - selectedFilter = null; - - // 重置 UI - const nameSpan = document.getElementById('selected-filter-name'); - if (nameSpan) nameSpan.textContent = typeof t === 'function' ? t('ui.noFilterSelected') : '未选择滤镜'; - const applyBtn = document.getElementById('apply-filter-btn'); - if (applyBtn) applyBtn.disabled = true; - const paramsDiv = document.getElementById('filter-params'); - if (paramsDiv) paramsDiv.style.display = 'none'; - } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterStopFailed') + ': ' + e.message : `停止滤镜失败: ${e.message}`, 'error'); - } -} - // ========================================================================= // 网络页面 // ========================================================================= @@ -7160,10 +6530,6 @@ function hideWifiScan() { document.getElementById('wifi-scan-section').classList.add('hidden'); } -function getSignalIcon(rssi) { - return ''; -} - function escapeHtml(str) { return String(str ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } @@ -7816,10 +7182,6 @@ function navigateToPath(path) { loadDirectory(path); } -function getFileIcon(name) { - return ''; -} - function formatFileSize(bytes) { if (bytes === 0) return '0 B'; if (bytes === undefined) return '-'; @@ -8287,17 +7649,6 @@ async function deleteSshCommandFromBackend(cmdId) { requireApiSuccess(await api.call('ssh.commands.remove', { id: cmdId }), 'call'); } -/** - * 预创建 SSH 命令相关的变量(保留用于兼容,实际由后端处理) - * 后端在保存指令时已自动创建变量 - * @param {string} varName - 变量名前缀(如 "ping_test") - */ -async function preCreateCommandVariables(varName) { - // 后端 ssh.commands.add API 在保存时已自动创建变量 - // 此函数保留作为兼容占位符 - console.debug(`Variables for ${escapeHtml(varName)}.* are managed by backend`); -} - /** * 确保所有已保存指令的变量都已创建(保留用于兼容,实际由后端处理) * 后端在 ESP32 启动时会自动预创建所有命令变量 @@ -8618,15 +7969,6 @@ async function updateServiceStatusInList() { if (document.querySelector('.service-mode-status')) startServiceStatusRefresh(); } -/** - * 获取服务状态显示文本 - */ -function getServiceStatusLabel(status) { - const keyMap = { 'ready': 'sshPage.statusReady', 'checking': 'sshPage.statusChecking', 'timeout': 'sshPage.statusTimeout', 'failed': 'sshPage.statusFailed', 'idle': 'sshPage.statusIdle', 'stopped': 'sshPage.statusStopped' }; - const key = keyMap[status]; - return key ? t(key) : t('common.unknown'); -} - function showAddCommandModal() { if (!selectedHostId) { showToast((typeof t === 'function' ? t('ssh.selectHostFirst') : '请先选择一个主机'), 'warning'); @@ -10565,18 +9907,7 @@ async function loadSecurityPage() {
${t('securityPage.revokeWarning')}
`, ``)} - - - - + '; ++ (isRoot ? '' : '') + ''; +--- a/components/ts_webui/web/index.html ++++ b/components/ts_webui/web/index.html +@@ -91 +91 @@ +- window.TS_ASSET_VERSION = '0.6.1-lpmu6-keygen1-delete1'; ++ window.TS_ASSET_VERSION = '0.6.1-lpmu6-keygen1-delete2'; +@@ -264 +264 @@ +- ++ +--- a/components/ts_webui/web/js/lang/zh-CN.js ++++ b/components/ts_webui/web/js/lang/zh-CN.js +@@ -39 +39,4 @@ +- "loadingDetails": "如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。" ++ "loadingDetails": "如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。", ++ "quickLoadingNeedRoot": "如果长时间没有变化,请联系拥有 root 权限的用户查看原因。", ++ "quickRecoveryNeedRoot": "请联系拥有 root 权限的用户处理。", ++ "quickEmptyNeedRoot": "如需添加快捷操作,请联系拥有 root 权限的用户。" +--- a/components/ts_webui/web/js/lang/en-US.js ++++ b/components/ts_webui/web/js/lang/en-US.js +@@ -39 +39,4 @@ +- "loadingDetails": "If nothing changes for a long time, open “System Logs” on the Terminal page to see why." ++ "loadingDetails": "If nothing changes for a long time, open “System Logs” on the Terminal page to see why.", ++ "quickLoadingNeedRoot": "If nothing changes for a long time, contact a user with root access to find out why.", ++ "quickRecoveryNeedRoot": "Contact a user with root access to resolve this.", ++ "quickEmptyNeedRoot": "To add quick actions, contact a user with root access." +--- a/tests/prompts/static.test.cjs ++++ b/tests/prompts/static.test.cjs +@@ -14 +14 @@ +-for(const family of ['promptRepair','runtimeRepair'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); ++for(const family of ['promptRepair','runtimeRepair','deleteProtection'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); +--- a/tests/prompts/delete-reference.test.cjs ++++ b/tests/prompts/delete-reference.test.cjs +@@ -14 +14,2 @@ +- assert.equal(h.ctx.deleteServiceMessage({state:'future',busy:false}),'operationPending'); ++ assert.equal(h.ctx.deleteServiceMessage({state:'future',busy:false}),'unconfirmed'); ++ assert.equal(h.ctx.deleteServiceMessage({busy:false}),'unconfirmed'); +@@ -20,0 +22 @@ ++ assert.equal(h.ctx.runtimeSaveError({data:{error_code:'template_lookup_failed'}}),h.ctx.runtimeText('saveFailed')); +--- a/tests/prompts/delete-protection.test.cjs ++++ b/tests/prompts/delete-protection.test.cjs +@@ -49 +49,2 @@ +- assert(h.ctx.lastSheet.primary===h.ctx.t('deleteProtection.viewCommand')); ++ assert.equal(h.ctx.lastSheet.primary,h.ctx.t(entry==='command'?'common.close':'deleteProtection.viewCommand')); ++ assert.equal(h.ctx.lastSheet.secondary,entry==='command'?false:h.ctx.t('common.close')); +--- a/tests/prompts/delete-reference.browser.test.cjs ++++ b/tests/prompts/delete-reference.browser.test.cjs +@@ -11,2 +11,2 @@ +-async function pageFor(language,width,run){ +- const state={templateName:name,wire:'in-use',service:false,serviceState:'stopped',rules:[],loaded:true,recovery:false},calls=[],errors=[]; ++async function pageFor(language,width,run,level='root'){ ++ const state={templateName:name,wire:'in-use',service:false,serviceState:'stopped',rules:[],loaded:true,recovery:false,level,saveError:null},calls=[],errors=[]; +@@ -20 +20 @@ +- if(endpoint==='automation/rules/add')return send(fixture('action-missing')); ++ if(endpoint==='automation/rules/add')return send(state.saveError?{code:4,message:state.saveError,data:{error_code:state.saveError}}:fixture('action-missing')); +@@ -22 +22 @@ +- if(endpoint==='auth/status')data={valid:true,level:'root',username:'root'}; ++ if(endpoint==='auth/status')data={valid:true,level:state.level,username:state.level}; +@@ -40 +40 @@ +- await context.addInitScript(lang=>{localStorage.setItem('ts_language',lang);localStorage.setItem('ts_level','root');localStorage.setItem('ts_token','fixture');localStorage.setItem('ts_username','root');class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}}window.WebSocket=Socket;},language); ++ await context.addInitScript(({lang,level})=>{localStorage.setItem('ts_language',lang);localStorage.setItem('ts_level',level);localStorage.setItem('ts_token','fixture');localStorage.setItem('ts_username',level);class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}}window.WebSocket=Socket;},{lang:language,level}); +@@ -43 +43 @@ +- try{await page.goto(base);await page.waitForFunction(()=>i18n.isReady());await page.evaluate(()=>{closeLoginModal();location.hash='/automation';});await page.waitForSelector('#actions-list button[onclick]');await run({page,state,calls});assert.deepEqual(errors,[]);} ++ try{await page.goto(base);await page.waitForFunction(()=>i18n.isReady());await page.evaluate(root=>{closeLoginModal();if(root)location.hash='/automation';},level==='root');await page.waitForSelector(level==='root'?'#actions-list button[onclick]':'#quick-actions-grid');await run({page,state,calls});assert.deepEqual(errors,[]);} +@@ -111 +111,3 @@ +- if(scenario==='recovery')assert((await grid.textContent()).includes(await page.evaluate(()=>runtimeText('recovery_required')))); ++ if(scenario==='recovery'){assert((await grid.textContent()).includes(await page.evaluate(()=>runtimeText('recovery_required'))));assert((await grid.textContent()).includes(await page.evaluate(()=>t('deleteProtection.recoveryDetails'))));} ++ const bodyKey=scenario==='empty'?'automationPage.quickActionsHint':scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required'; ++ assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),bodyKey))); +@@ -121,0 +124,25 @@ ++ ++// One Chinese admin scene: both auth status and initial storage use the same non-root level. ++test('zh-CN: non-root homepage has approved guidance and no management entry',async()=>pageFor('zh-CN',390,async({page,state})=>{ ++ assert.equal(await page.evaluate(()=>api.getLevel()),'admin');assert.equal(await page.evaluate(()=>api.isRoot()),false); ++ for(const scenario of ['empty','loading','recovery']){ ++ state.loaded=scenario==='empty';state.recovery=scenario==='recovery';await page.evaluate(()=>refreshQuickActions()); ++ const grid=page.locator('#quick-actions-grid');assert.equal(await grid.count(),1);assert.equal(await grid.locator('button').count(),0); ++ const titleKey=scenario==='empty'?'automationPage.noQuickActions':scenario==='loading'?'deleteProtection.quickLoadingTitle':'deleteProtection.quickRecoveryTitle'; ++ assert.equal(await grid.locator('.t-body').textContent(),await page.evaluate(k=>t(k),titleKey)); ++ const newKey={empty:'deleteProtection.quickEmptyNeedRoot',loading:'deleteProtection.quickLoadingNeedRoot',recovery:'deleteProtection.quickRecoveryNeedRoot'}[scenario]; ++ assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),newKey))); ++ if(scenario!=='empty')assert.equal(await grid.locator('.t-note').first().textContent(),await page.evaluate(k=>t(k),scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required')); ++ for(const oldKey of ['deleteProtection.loadingDetails','deleteProtection.recoveryDetails','automationPage.quickActionsHint']) ++ assert(!(await grid.textContent()).includes(await page.evaluate(k=>t(k),oldKey))); ++ } ++},'admin')); ++test('zh-CN: template lookup failure uses saveFailed and retains the actual draft',async()=>pageFor('zh-CN',390,async({page,state,calls})=>{ ++ state.saveError='template_lookup_failed'; ++ await page.evaluate(async()=>{showAddRuleModal();await addActionTemplateRow('missing');}); ++ await page.locator('#rule-id').fill('new-rule');await page.locator('#rule-name').fill('Lookup draft remains');await page.locator('#rule-manual-only').check(); ++ await page.locator('#add-rule-modal button[onclick="submitAddRule()"] ').click(); ++ await page.waitForFunction(()=>document.getElementById('toast')?.textContent===t('runtimeRepair.saveFailed')); ++ assert.equal(await page.locator('#rule-name').inputValue(),'Lookup draft remains');assert.equal(await page.locator('#add-rule-modal').count(),1); ++ assert.equal(calls.filter(c=>c.endpoint==='automation/rules/add').length,1); ++})); +--- a/tests/prompts/delete-protection.browser.test.cjs ++++ b/tests/prompts/delete-protection.browser.test.cjs +@@ -35 +35 @@ +- await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};refreshCommandsList=()=>{};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); ++ await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};window.commandRefreshes=0;refreshCommandsList=()=>{window.commandRefreshes++;};window.deleteNavigationCalls=0;const navigate=router.navigate.bind(router);router.navigate=(...args)=>{window.deleteNavigationCalls++;return navigate(...args);};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); +@@ -37 +37,10 @@ +- if(state==='ready'){await page.locator('.confirm-sheet').waitFor();assert((await page.locator('.confirm-sheet').textContent()).includes('仍在运行'));await page.keyboard.press('Escape');} ++ if(state==='ready'){ ++ await page.locator('.confirm-sheet').waitFor();assert((await page.locator('.confirm-sheet').textContent()).includes('仍在运行')); ++ if(entry==='command'){ ++ assert.equal(await page.locator('.confirm-sheet button').count(),1); ++ assert.equal(await page.locator('.confirm-sheet button').textContent(),await page.evaluate(()=>t('common.close'))); ++ const before=await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})); ++ await page.locator('.confirm-sheet button').click();await deletion; ++ assert.deepEqual(await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})),before); ++ }else await page.keyboard.press('Escape'); ++ } +--- a/docs/repair/delete-protection/PLAN.md ++++ b/docs/repair/delete-protection/PLAN.md +@@ -1 +1 @@ +-# 删除保护与快捷操作提示:已确认的实施方案 v3 ++# 删除保护与快捷操作提示:已确认的实施方案 v4(第三轮方案 v2) +@@ -3 +3 @@ +-日期:2026-10-09。用户已明确回复“确认,按第二轮文案实施”。 ++日期:2026-10-10。用户已明确回复“确认,按第二轮文案实施”,并批准第三轮方案 v2 及三句非 root 首页说明。 +@@ -31,3 +31,3 @@ +-- runtimeSaveError 只增加 action_missing 的处理,原有原因结构保持;新文案在 deleteProtection 命名空间。 +-- 查看规则和查看指令只改变路由,不定位、不高亮、不自动编辑或删除。 +-- 首页 recovery > loading > 空状态,保留面板与前往自动化入口;不泛化提示用户检查 SD 卡。 ++- runtimeSaveError 保留 action_missing 的名称提示,将本轮新增原因 template_lookup_failed 映射到现有 saveFailed;不扩展其他原因。新文案在 deleteProtection 命名空间。 ++- 查看规则以及从模板阻断框查看指令只改变路由,不定位、不高亮、不自动编辑或删除;指令自身被阻断时只有关闭,不重载当前页。 ++- 首页 recovery > loading > 空状态,保留面板;root 原说明及前往自动化入口不变,所有非 root 无按钮,按状态使用 quickLoadingNeedRoot/quickRecoveryNeedRoot/quickEmptyNeedRoot 三句用户确认说明。不泛化提示用户检查 SD 卡。 +@@ -36 +36 @@ +-- app/api/语言包/CSS 资源查询标识含 delete1,发布版本仍为 0.6.1。 ++- app.js 查询标识及语言包使用的 TS_ASSET_VERSION 为 delete2;api.js/CSS 保持 delete1。xterm 地址随 TS_ASSET_VERSION 变化,内容未改;发布版本仍为 0.6.1。 +@@ -51 +51 @@ +-| 未来未知状态 | operationPending,保持拒绝 | ++| 未来未知或缺失状态 | unconfirmed,保持拒绝 | +--- a/docs/repair/delete-protection/COPY.md ++++ b/docs/repair/delete-protection/COPY.md +@@ -30,0 +31,3 @@ ++| deleteProtection.quickLoadingNeedRoot | 如果长时间没有变化,请联系拥有 root 权限的用户查看原因。 | If nothing changes for a long time, contact a user with root access to find out why. | ++| deleteProtection.quickRecoveryNeedRoot | 请联系拥有 root 权限的用户处理。 | Contact a user with root access to resolve this. | ++| deleteProtection.quickEmptyNeedRoot | 如需添加快捷操作,请联系拥有 root 权限的用户。 | To add quick actions, contact a user with root access. | +@@ -32,2 +35,2 @@ +-C 组不使用 goAutomation;只有首页三个状态使用该跳转入口。关闭复用 common.close。 +-空状态 quickActionsHint 已改为「在首页显示」,加载/恢复正文复用现有 runtimeRepair.configLoading/recovery_required;后端服务兜底复用 promptRepair.serviceDeleteProtected。 ++C 组不使用 goAutomation;首页三个状态仅 root 使用该跳转入口。指令自身的服务阻断框只有 common.close,模板场景保留 viewCommand。 ++root 空状态 quickActionsHint 已改为「在首页显示」,加载/恢复正文复用现有 runtimeRepair.configLoading/recovery_required;后端服务兜底复用 promptRepair.serviceDeleteProtected。非 root 加载/恢复的补充句与空状态正文使用上述三个 NeedRoot 新键,用户已确认定稿,无占位符,逐字采用。 +--- a/docs/repair/delete-protection/RESULT.md ++++ b/docs/repair/delete-protection/RESULT.md +@@ -34 +34 @@ +-## 验证 ++## 上一轮验证(第三轮实施前的源码) +@@ -50 +50 @@ +-## 资源与工件 ++## 上一轮资源与工件(不代表第三轮净增量或新构建) +@@ -60 +60 @@ +-## 文案与截图 ++## 文案与上一轮截图 +@@ -78,0 +79,26 @@ ++ ++ ++## 第三轮补记:2026-10-10,按第三轮方案 v2 完成 ++ ++用户已授权实施 R1–R5 并确认三句非 root 首页说明。完整方案在主目录 `output/review-delete-protection-copy/第三轮修复方案与交接-v2.md`,回执在同目录 `第三轮实施回执.md`。仍为原独立工作目录中的未提交修改。 ++ ++- R1:静态检查加入 deleteProtection,当前原24键加新增3键均由相同检查覆盖。 ++- R2:无未完成操作证据的未知/缺失服务状态使用 unconfirmed;真实 pending 分支及删除安全门保持。 ++- R3:template_lookup_failed 使用现有 saveFailed;action_missing 名称说明保持,保存失败草稿保留。 ++- R4:只有 root 显示自动化入口;三句已确认非 root 说明按场景使用,root 原正文/补充句保持。新 t() 使用字面量键。 ++- R5:指令自身的服务阻断框只有关闭,关闭不导航或重载;动作模板查看指令和查看规则继续只跳转。 ++- TS_ASSET_VERSION 和 app.js 查询串为 delete2;api.js/CSS 保持 delete1,xterm 地址随共享标识变化。没有改变发布版本。 ++ ++| 本轮实际命令(独立目录) | 结果 | 证据 | ++|---|---|---| ++| `node --test tests/prompts/static.test.cjs tests/prompts/delete-reference.test.cjs tests/prompts/delete-protection.test.cjs` | 33/33,通过 | [round3-unit.log](evidence/round3-unit.log) | ++| `node --test tests/prompts/delete-reference.browser.test.cjs tests/prompts/delete-protection.browser.test.cjs` | 38/38,通过 | [round3-browser.log](evidence/round3-browser.log) | ++| `git diff --check` | 通过,无空白错误 | [round3-verification.json](evidence/round3-verification.json) | ++ ++新增浏览器场景只有中文 admin 三状态和中文查询失败保留草稿;已有相关文件的语言/视口/服务覆盖照常执行,没有新增矩阵。admin 同时用于 auth/status 返回等级和初始化 localStorage,实际 api.getLevel 为 admin、isRoot 为 false。三状态无按钮、对应定稿出现、旧 root 管理操作提示不出现;加载/恢复标题与正文保持,空状态仅替换正文。 ++ ++本轮未运行固件测试、全量测试、ESP-IDF构建或真机测试;固件/HTTP未修改,用户明确限定验证范围。上方126/262结果、构建日志、截图、二进制及2807B资源统计均为上一轮历史证据,不能当作本轮固件工件或资源增量。没有重新生成 HTTP fixture、截图或二进制。 ++ ++[source-hashes.json](evidence/source-hashes.json)现对应本轮源码;[source-hashes-before-round3.json](evidence/source-hashes-before-round3.json)保留上一轮源码记录,旧工件哈希不变。复审可用[round3.patch](evidence/round3.patch)查看相对上一轮未提交实现的精确增量,而不只与 main 比较。补丁采用无上下文格式,避免空白上下文行被 Git 当作尾随空格;需要应用时使用 git apply --unidiff-zero。 ++ ++方案无实质偏差,没有需要用户另作决定的事项。固件、通用HTTP出口、锁顺序、committing及api.js/CSS等受保护文件与本轮开始时逐字相同;main源码未改。未提交、推送、开PR、合并或刷机,未修改其他分支/工作目录。 +--- a/docs/repair/delete-protection/evidence/source-hashes.json ++++ b/docs/repair/delete-protection/evidence/source-hashes.json +@@ -8 +8 @@ +- "components/ts_webui/web/index.html": "5b2413878fc9c7a7895811591233968c44acef69ddd88751520dc928c0a16097", ++ "components/ts_webui/web/index.html": "4fe61cb62fc0a0d82c8d8bc25a6989a3f83d51a07ae0b160719a51a0e8efe5c7", +@@ -10,3 +10,3 @@ +- "components/ts_webui/web/js/app.js": "fe704a6ebb90917ce154ed38504822f760de257623703ef6ba8a7c17fa6f119f", +- "components/ts_webui/web/js/lang/en-US.js": "a8ddfed891595514698b6aaf4212b39e035a644b10396f8015fd1dfdc38bd400", +- "components/ts_webui/web/js/lang/zh-CN.js": "f4872db0c2e94974d2541f5e1a04a15bea5a28ec058667a699ee98091c4c5ddf", ++ "components/ts_webui/web/js/app.js": "159dba2c7008282bbf3d291735a2547aa4546da905f1dee50870f4e85d852393", ++ "components/ts_webui/web/js/lang/en-US.js": "4efc90a760ea704ab0daf4eee7ef232e6e0c3d43bb4453f8119603d7b9e964f6", ++ "components/ts_webui/web/js/lang/zh-CN.js": "ff3881d4d344fee9109ab8ab185684289ea780e54b2696b54b2d971ffa44eb5f", +@@ -22,4 +22,4 @@ +- "tests/prompts/delete-protection.browser.test.cjs": "c0691c5d8bea3190720c2e7c6e8a83a897bb3f18ae0d8b5001148d0fc0bb10cb", +- "tests/prompts/delete-protection.test.cjs": "1ad5a34b98b7ba2ca99ac7c162e1076e8ec973131919dca601df90ec8cabfd56", +- "tests/prompts/delete-reference.browser.test.cjs": "d2eb6f4a04e93cef4ae0331f1c5d0207cc0d63905a2f88636caf52f0718c1d1b", +- "tests/prompts/delete-reference.test.cjs": "3b6b9db25ec1513afbfd443ab20fee3690176b0053337d099b8762a7514025eb", ++ "tests/prompts/delete-protection.browser.test.cjs": "2bc58639fcbeeffd1d25cf7f1785d992e6f76c56312248e3d91426b978626aec", ++ "tests/prompts/delete-protection.test.cjs": "40bb3dd4a9b08739b9f6e74e0f4a26734f21a37ff6cc30eabe9f897c9534347c", ++ "tests/prompts/delete-reference.browser.test.cjs": "230b0b27f29af1f4c2a8f84c810d81dda4086d6d5ae0953e09a6265da8bdf346", ++ "tests/prompts/delete-reference.test.cjs": "083b29086734e8195c1414ad0387fb8bbdf6f0f96d083eb4ed752a349504ce76", +@@ -31 +31,2 @@ +- "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c" ++ "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c", ++ "tests/prompts/static.test.cjs": "be2ea30d0e40ee116a19283add4973b7e4685faf65062f2ad903c6d37563ea94" diff --git a/docs/repair/delete-protection/evidence/runtime.log b/docs/repair/delete-protection/evidence/runtime.log new file mode 100644 index 0000000..7990a00 --- /dev/null +++ b/docs/repair/delete-protection/evidence/runtime.log @@ -0,0 +1,107 @@ +PASS actual log hook/parser: full 510..4096 output, bounded UTF-8 preview, ANSI, concurrent capture/drop, full own output, callback recursion, capture toggle +PASS rule codec: nine action types, conditions/repeats/explicit false, allocation failures, unsupported operator; rule=256 action=1024 +PASS actual rule persistence: NVS and SD fault injection, recovery boundaries, read-only rejection +PASS actual rule engine: revisions/no-op/allocation failures, stale-evaluation barrier, pinned old execution, retirement bound, enable/manual/allow matrix, recreate identity +PASS actual SSH client: trust gate, command deadlines, partial stdin/EAGAIN/EOF, input cancellation/errors, exact secret bytes without command/log exposure +PASS actual service registry + watcher: duplicate admission, task failure, immediate finish, stale handle, cooperative cancellation, late READY rejection, truthful remote stop/unknown +PASS service protocol: queued/active launch stop guards, retained-rule READY stop, stale launch completion, stale credentials, cancellation cleanup, deletion barrier/failure/reuse, 70 replacements, missing config protection, physical identity, page-free recovery, 3-attempt budget, zero-I/O cached reads +PASS production synchronous action completion: timeout then late result, success, queue rejection; semaphore lifetime balanced +PASS production snapshot -> sequencer -> submission -> executor: delay once, immutable binding, sync/async, fast/late completion, failures, cancellation, repeat, legacy routing; 50 cycles balanced +PASS production service API/executor/SSH/registry: zero-I/O admission, slow SSH with responsive cached API, engine-independent controls, literal vs expanded commands, overflow before I/O, retained-rule stop, 30s expiry, timeout evidence, JSON/entry/snapshot/queue/cancel cleanup, fast worker, 100 cycles +delete API: protected services report BUSY, denied deletes preserve files, missing/internal errors stay distinct +PASS production delete + rule commit + HTTP: H01-H14, leases, no side effects, historical multiplicity, committing cleared, 47/63-byte fields +PASS exact HTTP byte fixtures: 8 +PASS typed conditions: 16 rule pairs, 1792 unchanged comparison results, real codec encode/decode +PASS production expansion: 62/63 chars, exact fit, overflow rejects empty, unknown and Shell expressions preserved, long names, numeric values +PASS actual configuration wrappers: host edit vs query, immutable target, failed delete preserves registration, successful delete then same-name replacement +PASS production host NVS/import + service registry: orphan recovery, uninitialized/pinned/unknown guards, atomic admission, storage errors/full capacity, SD precedence/merge/failures, concurrent add backup, boot retains unrelated hosts, queued backup preserves SD input, list storage error propagation +PASS production copyid first-use: automatic trust/save, API defaults, known host, changed-key rejection before auth, explicit opt-out, save/auth failures, other SSH operations retain exact confirmation +PASS production ssh.copyid + HTTP serialization: actual deployment/verification/registration outcomes, default/custom IDs, busy/capacity/storage errors, partial data retained, no repeat or rollback +PASS copyid + production known hosts: full SHA-256 save/readback, repeat deployment, host/port isolation, final-byte mismatch preserved, read/write/commit failure blocks authentication +PASS copyid: API and CLI share actual builder; exact bytes, no command injection, bounded capacity, permissions +PASS actual LPMU task/API: task-owned SD initialization, lock-free file I/O, 1 KiB POSIX reads, truncation/incomplete UTF-8/read/seek/write/close failures, replaced-run rejection, cleanup-before-terminal publication, password stdin/wipe, upload, host gate, split markers, zero-exit unconfirmed, 100 KiB exact reconstruction and PSRAM-only read allocation failure +PASS input repair: real API/readback/export/import, NVS and SD reload, failed edit preservation, seven filter execution configurations, zero/units/legacy defaults, inline rule codec; filter params=28 LED action=784 action=1024 template=1232 +PASS production action store: 1264 save and 944 recovery I/O/crash boundaries, applied-rename errors, FAT refuses overwrites, JSON/pack/both/first-create, exact old-or-new recovery, multiple IDs, repeated saves, mirror preserves encrypted source, unrelated files retained +PASS actual action pack: complete export envelope -> SDK encrypted/plain priority -> real boot load entry and directory parser; identity/type/enabled/async/partial-zero parameters, flat legacy JSON, 63-byte ASCII/UTF-8 IDs, interrupted save recovery before load, unresolved recovery blocks stale fallback, malformed envelope rejection (crypto/NVS backup mocked) +PASS production template serializer: 258 serialization and 258 update allocation boundaries across six stored action types; complete JSON or failure, failed edits preserve active and stored versions +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:127:5: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 127 | sprintf(version, "%i.%i.%i", CJSON_VERSION_MAJOR, CJSON_VERSION_MINOR, CJSON_VERSION_PATCH); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:609:18: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 609 | length = sprintf((char*)number_buffer, "null"); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:613:18: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 613 | length = sprintf((char*)number_buffer, "%d", item->valueint); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:618:18: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 618 | length = sprintf((char*)number_buffer, "%1.15g", d); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:624:22: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 624 | length = sprintf((char*)number_buffer, "%1.17g", d); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:1058:21: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 1058 | sprintf((char*)output_pointer, "u%04x", *input_pointer); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +6 warnings generated. +PASS actual CSR API: reject wrong types/overlong inputs before key access, organization-only/empty ID use default, exact 63-byte ID, no credential changes +metadata PASS actual keystore regression + +index PASS actual keystore regression + +prefix PASS actual keystore regression + +matrix PASS actual keystore regression + +admission PASS actual keystore regression + +concurrency PASS actual keystore regression + +compatibility PASS actual keystore regression + +api PASS actual keystore regression + +allocations PASS actual keystore regression + +lifecycle PASS actual keystore regression + +PASS actual store -> current loader: normal/dot IDs, temporary files ignored, reload retains saved rule +PASS actual automation stop + engine: blocked action returns normally, leases/executing clear, async work prevents restart, independent remote ownership retained +PASS actual probe builder: quotes, spaces, leading dash, literal shell metacharacters, fail priority, exact tokens, overflow +PASS actual UI functions: bilingual states, one revisioned update, manual conditions/types, inline fields/repeat preservation, explicit false vs omitted, conflict/draft/uncertain write +PASS production bilingual SSH UI/API helpers: rendered ID/key, verification/registration partial outcomes, cache-preserving list errors, true empty list, old-firmware tuple confirmation, stale style/page cleanup, out-of-order list replies, no repeat deployment diff --git a/docs/repair/delete-protection/evidence/source-hashes-before-round3.json b/docs/repair/delete-protection/evidence/source-hashes-before-round3.json new file mode 100644 index 0000000..60f0cdd --- /dev/null +++ b/docs/repair/delete-protection/evidence/source-hashes-before-round3.json @@ -0,0 +1,32 @@ +{ + "components/ts_api/src/ts_api_automation.c": "96d92e3faf24b4fcfb7e00a9fb84d70dfcc1980abd91aa67fe9bfa55dc2a7736", + "components/ts_automation/include/ts_action_manager.h": "b76ba33d17c219283a4b362bbe3cb755adeb044fedc9f32ff6ddc3b5c8bffc86", + "components/ts_automation/include/ts_rule_engine.h": "38d58102d4ca2714768a06bdd8abe238d2853a68d8403aef31f00162fbf01209", + "components/ts_automation/src/ts_action_manager.c": "d415d2ced03795c147608167e5dbe51160abb7e5bc850e2552bd66eb56e53bd8", + "components/ts_automation/src/ts_rule_engine.c": "8b0a1a4374226591fee02211d65c363dfb6b383ed348cdd10d35051e9c5a4aaf", + "components/ts_webui/web/css/style.css": "5aef54b61efe7a5d9882a647cfcc6b88949a5f115897401f5293822653437b43", + "components/ts_webui/web/index.html": "5b2413878fc9c7a7895811591233968c44acef69ddd88751520dc928c0a16097", + "components/ts_webui/web/js/api.js": "f635a33081cbd9e022e5674467ac3ae995c21bdb125e64439b41d8d6b53394b4", + "components/ts_webui/web/js/app.js": "fe704a6ebb90917ce154ed38504822f760de257623703ef6ba8a7c17fa6f119f", + "components/ts_webui/web/js/lang/en-US.js": "a8ddfed891595514698b6aaf4212b39e035a644b10396f8015fd1dfdc38bd400", + "components/ts_webui/web/js/lang/zh-CN.js": "f4872db0c2e94974d2541f5e1a04a15bea5a28ec058667a699ee98091c4c5ddf", + "tests/fixtures/delete-reference/README.md": "a5206e573525982b7b179230b2a9b58e64710881448bb4942d7d8cf8965b81cf", + "tests/fixtures/delete-reference/action-missing.json": "e2c366ac3d512a4f8612fac9f69ba865989c3a2789b588bd08b403a32267f176", + "tests/fixtures/delete-reference/boundary.json": "d389136890bffb57e5c36714ebb10c1123b35da68ef3e0e621a7b9fa8e1afced", + "tests/fixtures/delete-reference/check-loading.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/check-recovery.json": "d3e676e11440911a72ad7559c4eb5552e77bb65f1cbc2ece368203a70c7214e8", + "tests/fixtures/delete-reference/check-uninitialized.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/in-use-no-details.json": "09add28b1ce8ab1c3059084284bffdfac77d31eba75d48acaf52a912fd5cd7ca", + "tests/fixtures/delete-reference/in-use.json": "7fb8a4b964090928d3d41274abf9621f5328a5cc792a5a831ca477db8f0b4e8b", + "tests/fixtures/delete-reference/service-protected.json": "a8fb774e32177dd88c8f91b92f13c3174fcb40966f68ecef9f2f218519d86c8c", + "tests/prompts/delete-protection.browser.test.cjs": "c0691c5d8bea3190720c2e7c6e8a83a897bb3f18ae0d8b5001148d0fc0bb10cb", + "tests/prompts/delete-protection.test.cjs": "1ad5a34b98b7ba2ca99ac7c162e1076e8ec973131919dca601df90ec8cabfd56", + "tests/prompts/delete-reference.browser.test.cjs": "d2eb6f4a04e93cef4ae0331f1c5d0207cc0d63905a2f88636caf52f0718c1d1b", + "tests/prompts/delete-reference.test.cjs": "3b6b9db25ec1513afbfd443ab20fee3690176b0053337d099b8762a7514025eb", + "tests/prompts/package.json": "e492525ec8752651b749045346095429634de8efa84205c2d57e969053176409", + "tests/runtime/extract_engine.py": "f7917c42055ae1301792c127f3eafe775cff03f457564e474110b2bf5e4e7daf", + "tests/runtime/run_all.sh": "a227fb5b280e120ec795dd851dffe0b5725b97c9724ef48fc67f4cd65feebb90", + "tests/runtime/test_delete_api.py": "e13035af2b482b2dbd27529d340bdf759ba575f8b2cd96ad9d523474a7e5b7f4", + "tests/runtime/test_delete_reference.py": "73ff90a36df5e17dcfff25944390e8d9421583f38cf600f107c5661cff8ea1d4", + "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c" +} diff --git a/docs/repair/delete-protection/evidence/source-hashes.json b/docs/repair/delete-protection/evidence/source-hashes.json new file mode 100644 index 0000000..1a96431 --- /dev/null +++ b/docs/repair/delete-protection/evidence/source-hashes.json @@ -0,0 +1,33 @@ +{ + "components/ts_api/src/ts_api_automation.c": "96d92e3faf24b4fcfb7e00a9fb84d70dfcc1980abd91aa67fe9bfa55dc2a7736", + "components/ts_automation/include/ts_action_manager.h": "b76ba33d17c219283a4b362bbe3cb755adeb044fedc9f32ff6ddc3b5c8bffc86", + "components/ts_automation/include/ts_rule_engine.h": "38d58102d4ca2714768a06bdd8abe238d2853a68d8403aef31f00162fbf01209", + "components/ts_automation/src/ts_action_manager.c": "d415d2ced03795c147608167e5dbe51160abb7e5bc850e2552bd66eb56e53bd8", + "components/ts_automation/src/ts_rule_engine.c": "8b0a1a4374226591fee02211d65c363dfb6b383ed348cdd10d35051e9c5a4aaf", + "components/ts_webui/web/css/style.css": "5aef54b61efe7a5d9882a647cfcc6b88949a5f115897401f5293822653437b43", + "components/ts_webui/web/index.html": "4fe61cb62fc0a0d82c8d8bc25a6989a3f83d51a07ae0b160719a51a0e8efe5c7", + "components/ts_webui/web/js/api.js": "f635a33081cbd9e022e5674467ac3ae995c21bdb125e64439b41d8d6b53394b4", + "components/ts_webui/web/js/app.js": "159dba2c7008282bbf3d291735a2547aa4546da905f1dee50870f4e85d852393", + "components/ts_webui/web/js/lang/en-US.js": "4efc90a760ea704ab0daf4eee7ef232e6e0c3d43bb4453f8119603d7b9e964f6", + "components/ts_webui/web/js/lang/zh-CN.js": "ff3881d4d344fee9109ab8ab185684289ea780e54b2696b54b2d971ffa44eb5f", + "tests/fixtures/delete-reference/README.md": "a5206e573525982b7b179230b2a9b58e64710881448bb4942d7d8cf8965b81cf", + "tests/fixtures/delete-reference/action-missing.json": "e2c366ac3d512a4f8612fac9f69ba865989c3a2789b588bd08b403a32267f176", + "tests/fixtures/delete-reference/boundary.json": "d389136890bffb57e5c36714ebb10c1123b35da68ef3e0e621a7b9fa8e1afced", + "tests/fixtures/delete-reference/check-loading.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/check-recovery.json": "d3e676e11440911a72ad7559c4eb5552e77bb65f1cbc2ece368203a70c7214e8", + "tests/fixtures/delete-reference/check-uninitialized.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/in-use-no-details.json": "09add28b1ce8ab1c3059084284bffdfac77d31eba75d48acaf52a912fd5cd7ca", + "tests/fixtures/delete-reference/in-use.json": "7fb8a4b964090928d3d41274abf9621f5328a5cc792a5a831ca477db8f0b4e8b", + "tests/fixtures/delete-reference/service-protected.json": "a8fb774e32177dd88c8f91b92f13c3174fcb40966f68ecef9f2f218519d86c8c", + "tests/prompts/delete-protection.browser.test.cjs": "2bc58639fcbeeffd1d25cf7f1785d992e6f76c56312248e3d91426b978626aec", + "tests/prompts/delete-protection.test.cjs": "40bb3dd4a9b08739b9f6e74e0f4a26734f21a37ff6cc30eabe9f897c9534347c", + "tests/prompts/delete-reference.browser.test.cjs": "230b0b27f29af1f4c2a8f84c810d81dda4086d6d5ae0953e09a6265da8bdf346", + "tests/prompts/delete-reference.test.cjs": "083b29086734e8195c1414ad0387fb8bbdf6f0f96d083eb4ed752a349504ce76", + "tests/prompts/package.json": "e492525ec8752651b749045346095429634de8efa84205c2d57e969053176409", + "tests/runtime/extract_engine.py": "f7917c42055ae1301792c127f3eafe775cff03f457564e474110b2bf5e4e7daf", + "tests/runtime/run_all.sh": "a227fb5b280e120ec795dd851dffe0b5725b97c9724ef48fc67f4cd65feebb90", + "tests/runtime/test_delete_api.py": "e13035af2b482b2dbd27529d340bdf759ba575f8b2cd96ad9d523474a7e5b7f4", + "tests/runtime/test_delete_reference.py": "73ff90a36df5e17dcfff25944390e8d9421583f38cf600f107c5661cff8ea1d4", + "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c", + "tests/prompts/static.test.cjs": "be2ea30d0e40ee116a19283add4973b7e4685faf65062f2ad903c6d37563ea94" +} diff --git a/docs/repair/delete-protection/evidence/unit.log b/docs/repair/delete-protection/evidence/unit.log new file mode 100644 index 0000000..1504247 --- /dev/null +++ b/docs/repair/delete-protection/evidence/unit.log @@ -0,0 +1,139 @@ + +> test +> cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs tests/prompts/delete-reference.test.cjs + +✔ zh-CN: protected delete explains service state rather than internal failure (111.646292ms) +✔ en-US: protected delete explains service state rather than internal failure (316.784417ms) +✔ action: unregistered service is verified stopped before deletion (27.704916ms) +✔ action: running never leads to delete or stop (10.940958ms) +✔ action: ready never leads to delete or stop (10.870875ms) +✔ action: unknown never leads to delete or stop (72.463542ms) +✔ action: failed verification never deletes (17.936458ms) +✔ action: ordinary command has no service verification (9.694667ms) +✔ action: repeated click submits only one verification and delete (9.179166ms) +✔ action: leaving the page during verification cancels subsequent deletion (261.855875ms) +✔ command: unregistered service is verified stopped before deletion (10.328916ms) +✔ command: running never leads to delete or stop (11.657958ms) +✔ command: ready never leads to delete or stop (16.468833ms) +✔ command: unknown never leads to delete or stop (18.851333ms) +✔ command: failed verification never deletes (10.566958ms) +✔ command: ordinary command has no service verification (172.129833ms) +✔ command: repeated click submits only one verification and delete (124.93675ms) +✔ command: leaving the page during verification cancels subsequent deletion (71.232333ms) +✔ switching hosts during verification removes the original command only (27.376166ms) +✔ action: newer start prevents stale deletion and status painting (8.438875ms) +✔ action: newer stop prevents stale deletion and status painting (103.85775ms) +✔ action: newer verify prevents stale deletion and status painting (9.195959ms) +✔ command: newer start prevents stale deletion and status painting (10.797ms) +✔ command: newer stop prevents stale deletion and status painting (65.694375ms) +✔ command: newer verify prevents stale deletion and status painting (7.602792ms) +✔ action: stale admission failure cannot replace newer start feedback (8.858125ms) +✔ command: stale admission failure cannot replace newer start feedback (5.215209ms) +✔ zh-CN: H15 nine states, busy and operation kind select an approved explanation (19.954459ms) +✔ en-US: H15 nine states, busy and operation kind select an approved explanation (6.084708ms) +✔ zh-CN: approved keys, placeholders and missing-template display label (6.379834ms) +✔ en-US: approved keys, placeholders and missing-template display label (6.066208ms) +✔ zh-CN rsa2048: delayed real handler gives persistent status, one POST, closes and refreshes (19.209291ms) +✔ zh-CN rsa4096: delayed real handler gives persistent status, one POST, closes and refreshes (5.800208ms) +✔ zh-CN ec256: delayed real handler gives persistent status, one POST, closes and refreshes (7.176459ms) +✔ zh-CN ec384: delayed real handler gives persistent status, one POST, closes and refreshes (216.374417ms) +✔ zh-CN: business failure keeps inputs, stage and cleanup, distinguishes memory/storage (53.867ms) +✔ zh-CN: timeout is unknown, one POST and at most one record check, no automatic retry (31.494291ms) +✔ zh-CN: network is unknown, one POST and at most one record check, no automatic retry (13.086041ms) +✔ zh-CN: http is unknown, one POST and at most one record check, no automatic retry (10.037541ms) +✔ zh-CN: body is unknown, one POST and at most one record check, no automatic retry (8.424459ms) +✔ zh-CN: json is unknown, one POST and at most one record check, no automatic retry (145.229625ms) +✔ zh-CN: string-code is unknown, one POST and at most one record check, no automatic retry (14.07975ms) +✔ zh-CN: missing is unknown, one POST and at most one record check, no automatic retry (84.878958ms) +✔ zh-CN: wrong-id is unknown, one POST and at most one record check, no automatic retry (20.008208ms) +✔ zh-CN: response-allocation is unknown, one POST and at most one record check, no automatic retry (9.388792ms) +✔ zh-CN: response-no-message is unknown, one POST and at most one record check, no automatic retry (8.022ms) +✔ zh-CN: a failed record check remains unknown and refresh failure cannot become generation failure (146.688417ms) +✔ zh-CN: close/reopen and navigation prevent stale modal updates; pending guard survives closure (31.772916ms) +✔ zh-CN: invalid IDs rejected before any POST (10.867958ms) +✔ zh-CN: actual key refresh rejects business/format failures, keeps HTTPS and resists out-of-order responses (10.914541ms) +✔ en-US rsa2048: delayed real handler gives persistent status, one POST, closes and refreshes (22.451625ms) +✔ en-US rsa4096: delayed real handler gives persistent status, one POST, closes and refreshes (26.440333ms) +✔ en-US ec256: delayed real handler gives persistent status, one POST, closes and refreshes (296.11825ms) +✔ en-US ec384: delayed real handler gives persistent status, one POST, closes and refreshes (21.856292ms) +✔ en-US: business failure keeps inputs, stage and cleanup, distinguishes memory/storage (177.397666ms) +✔ en-US: timeout is unknown, one POST and at most one record check, no automatic retry (13.3025ms) +✔ en-US: network is unknown, one POST and at most one record check, no automatic retry (12.612375ms) +✔ en-US: http is unknown, one POST and at most one record check, no automatic retry (7.694625ms) +✔ en-US: body is unknown, one POST and at most one record check, no automatic retry (11.804334ms) +✔ en-US: json is unknown, one POST and at most one record check, no automatic retry (18.288458ms) +✔ en-US: string-code is unknown, one POST and at most one record check, no automatic retry (14.833833ms) +✔ en-US: missing is unknown, one POST and at most one record check, no automatic retry (11.199625ms) +✔ en-US: wrong-id is unknown, one POST and at most one record check, no automatic retry (11.860166ms) +✔ en-US: response-allocation is unknown, one POST and at most one record check, no automatic retry (10.623041ms) +✔ en-US: response-no-message is unknown, one POST and at most one record check, no automatic retry (8.04125ms) +✔ en-US: a failed record check remains unknown and refresh failure cannot become generation failure (77.481125ms) +✔ en-US: close/reopen and navigation prevent stale modal updates; pending guard survives closure (8.431625ms) +✔ en-US: invalid IDs rejected before any POST (6.000208ms) +✔ en-US: actual key refresh rejects business/format failures, keeps HTTPS and resists out-of-order responses (6.524209ms) +✔ zh-CN: rebuilt page controls are released without stale errors; explicit success replaces a prior warning (8.918042ms) +✔ en-US: rebuilt page controls are released without stale errors; explicit success replaces a prior warning (6.390917ms) +✔ zh-CN: first deployment sends automatic first trust once, with no confirmation and factual registration result (128.278208ms) +✔ zh-CN: deployment honors explicit first-trust opt-out and does not change other SSH defaults (13.60675ms) +✔ en-US: first deployment sends automatic first trust once, with no confirmation and factual registration result (13.212083ms) +✔ en-US: deployment honors explicit first-trust opt-out and does not change other SSH defaults (12.756ms) +✔ zh-CN: actual inline translator, cold language, missing/objects/parameters and confirmations (9.09ms) +✔ zh-CN: REST business failure remains returned; strict operation rejects with original metadata (11.002083ms) +✔ zh-CN: timeout covers response body; result stays unknown for POST (107.090583ms) +✔ zh-CN: toast timer replacement, priority, safe multiline text (21.024ms) +✔ zh-CN: actual mutations do not report success or close dialogs on business failures (11.29375ms) +✔ zh-CN: batch deletion all / partial / no success with business errors (49.112334ms) +✔ zh-CN: downloads propagate actual initiation failures and retain batch details (64.562334ms) +✔ zh-CN: file names are text; upload/verification/apply remain separate (21.021417ms) +✔ zh-CN: SSH actual event handler honors terminal states and diagnoses conflicts (12.428833ms) +✔ zh-CN: background command timeout is unconfirmed and never resubmitted (68.600708ms) +✔ zh-CN: terminal restoration waits for acknowledgement; timeout/failure stay distinct (44.399417ms) +✔ zh-CN: ping results are reachability only; failed probe is unknown and no pulse replay (14.937625ms) +✔ zh-CN: websocket label matches color across language changes and power warnings persist (9.385625ms) +✔ en-US: actual inline translator, cold language, missing/objects/parameters and confirmations (11.712333ms) +✔ en-US: REST business failure remains returned; strict operation rejects with original metadata (235.909125ms) +✔ en-US: timeout covers response body; result stays unknown for POST (166.557583ms) +✔ en-US: toast timer replacement, priority, safe multiline text (13.122667ms) +✔ en-US: actual mutations do not report success or close dialogs on business failures (15.560291ms) +✔ en-US: batch deletion all / partial / no success with business errors (15.960375ms) +✔ en-US: downloads propagate actual initiation failures and retain batch details (253.058958ms) +✔ en-US: file names are text; upload/verification/apply remain separate (10.768042ms) +✔ en-US: SSH actual event handler honors terminal states and diagnoses conflicts (8.635416ms) +✔ en-US: background command timeout is unconfirmed and never resubmitted (10.518541ms) +✔ en-US: terminal restoration waits for acknowledgement; timeout/failure stay distinct (8.468791ms) +✔ en-US: ping results are reachability only; failed probe is unknown and no pulse replay (8.665416ms) +✔ en-US: websocket label matches color across language changes and power warnings persist (138.89825ms) +✔ zh-CN: upload and download aggregates cover all success and all failure (10.308333ms) +✔ zh-CN: key deployment requires successful business code and distinguishes verification (6.809042ms) +✔ zh-CN: real LED/status renderers localize finite built-in identifiers (5.819834ms) +✔ en-US: upload and download aggregates cover all success and all failure (7.869583ms) +✔ en-US: key deployment requires successful business code and distinguishes verification (7.737208ms) +✔ en-US: real LED/status renderers localize finite built-in identifiers (9.644458ms) +✔ zh-CN: widget dynamic families render real labels and literal braces in names remain valid (17.066208ms) +✔ zh-CN: unknown asynchronous stop never retries or escalates (7.332125ms) +✔ en-US: widget dynamic families render real labels and literal braces in names remain valid (6.817875ms) +✔ en-US: unknown asynchronous stop never retries or escalates (8.231833ms) +✔ zh-CN: J1 power alerts preserve local line, SSH ownership, transition guards and filtering (8.2145ms) +✔ en-US: J1 power alerts preserve local line, SSH ownership, transition guards and filtering (18.915542ms) +✔ a service display rejects prior SSH output and completion (23.094541ms) +✔ fast SSH messages are replayed only for the acknowledged session (12.618542ms) +✔ late SSH admission cannot replace a newer service display (11.152625ms) +✔ late log response and tail timer cannot survive display replacement (9.099375ms) +✔ page replacement and clear do not let old messages adopt the new panel (65.607958ms) +✔ expired and replaced service operations never trigger a retry (22.77075ms) +✔ clearing during explicit verification prevents display updates (15.492959ms) +Checked 3045 references including conditional and finite dynamic families. +✔ actual UI literal/conditional and finite dynamic keys exist in both languages with matching contracts (12.258292ms) +✔ production i18n is the inline entry; no second translator script is loaded (0.139417ms) +✔ ring: value uses an HTML overlay outside the SVG and retains update IDs (223.64275ms) +✔ gauge: value uses an HTML overlay outside the SVG and retains update IDs (29.341125ms) +✔ all widget types retain rendering, layout and edit hooks in the new UI (50.723375ms) +✔ stylesheet retains migrated bounds, overlay, wrapping and responsive span contracts (0.139958ms) +ℹ tests 126 +ℹ suites 0 +ℹ pass 126 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1772.461209 diff --git a/docs/repair/delete-protection/resource-deltas.json b/docs/repair/delete-protection/resource-deltas.json new file mode 100644 index 0000000..6aee993 --- /dev/null +++ b/docs/repair/delete-protection/resource-deltas.json @@ -0,0 +1,32 @@ +[ + { + "path": "components/ts_webui/web/js/app.js", + "raw_delta": 5834, + "gzip_delta": 1580 + }, + { + "path": "components/ts_webui/web/js/api.js", + "raw_delta": 34, + "gzip_delta": 7 + }, + { + "path": "components/ts_webui/web/js/lang/zh-CN.js", + "raw_delta": 2655, + "gzip_delta": 585 + }, + { + "path": "components/ts_webui/web/js/lang/en-US.js", + "raw_delta": 2904, + "gzip_delta": 606 + }, + { + "path": "components/ts_webui/web/css/style.css", + "raw_delta": 78, + "gzip_delta": 21 + }, + { + "path": "components/ts_webui/web/index.html", + "raw_delta": 40, + "gzip_delta": 8 + } +] diff --git a/docs/repair/delete-protection/target-sizes.txt b/docs/repair/delete-protection/target-sizes.txt new file mode 100644 index 0000000..95ed668 --- /dev/null +++ b/docs/repair/delete-protection/target-sizes.txt @@ -0,0 +1,9 @@ +From final ESP32-S3 ELF DWARF using xtensa-esp32s3-elf-gdb: +ts_action_template_reference_t = 112 B +ts_action_template_delete_result_t = 20 B +ts_rule_commit_result_t = 84 B (old scalar fields 20 B, added request-local ID 64 B) +ts_action_template_t = 1224 B +ts_auto_rule_t = 240 B (persistent rule layout unchanged) +Reference details: default32 *112 =3584 B temporary PSRAM; configured max64 *112 =7168 B. +New-reference check: one template buffer1224 B temporary PSRAM; freed on all exits. +No added resident task, per-rule field, partition, or network polling. diff --git a/tests/fixtures/delete-reference/README.md b/tests/fixtures/delete-reference/README.md new file mode 100644 index 0000000..4fa4b47 --- /dev/null +++ b/tests/fixtures/delete-reference/README.md @@ -0,0 +1,7 @@ +# 生产删除/规则提交的 HTTP 字节夹具 + +由 tests/runtime/test_delete_reference.py 提取当前生产处理函数、规则提交和模板删除边界,并经过当前生产 HTTP 响应分支生成。SSH/持久化/文件删除边界是合成替身;不访问设备。夹具不是 api.call 的伪造对象。 + +正常回归比较实际字节,不改夹具。只有合同经过审查改变时,用 UPDATE_FIXTURES=1 python3 tests/runtime/test_delete_reference.py 更新。 + +in-use:包含停用、重复引用去重和默认容量32条规则;in-use-no-details:引用已确认但细节分配失败;check-loading/uninitialized/recovery:配置状态;action-missing:新引用被拒绝且未保存;boundary:名称47字节和ID63字节;service-protected:后端最终服务保护。浏览器测试直接读取这些文件原样作为 HTTP 响应。 diff --git a/tests/fixtures/delete-reference/action-missing.json b/tests/fixtures/delete-reference/action-missing.json new file mode 100644 index 0000000..cfa1d92 --- /dev/null +++ b/tests/fixtures/delete-reference/action-missing.json @@ -0,0 +1 @@ +{"code":1,"message":"action_missing","data":{"applied":false,"durable":false,"mirror_synced":false,"revision":0,"error_code":"action_missing","missing_template_id":"missing"}} diff --git a/tests/fixtures/delete-reference/boundary.json b/tests/fixtures/delete-reference/boundary.json new file mode 100644 index 0000000..8395854 --- /dev/null +++ b/tests/fixtures/delete-reference/boundary.json @@ -0,0 +1 @@ +{"code":4,"message":"action_in_use","data":{"action_id":"template","references_confirmed":true,"rules":[{"id":"IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"}],"details_available":true}} diff --git a/tests/fixtures/delete-reference/check-loading.json b/tests/fixtures/delete-reference/check-loading.json new file mode 100644 index 0000000..3905d6f --- /dev/null +++ b/tests/fixtures/delete-reference/check-loading.json @@ -0,0 +1 @@ +{"code":4,"message":"action_reference_check_unavailable","data":{"action_id":"template","check_reason":"loading"}} diff --git a/tests/fixtures/delete-reference/check-recovery.json b/tests/fixtures/delete-reference/check-recovery.json new file mode 100644 index 0000000..2a3bcaf --- /dev/null +++ b/tests/fixtures/delete-reference/check-recovery.json @@ -0,0 +1 @@ +{"code":4,"message":"action_reference_check_unavailable","data":{"action_id":"template","check_reason":"recovery"}} diff --git a/tests/fixtures/delete-reference/check-uninitialized.json b/tests/fixtures/delete-reference/check-uninitialized.json new file mode 100644 index 0000000..3905d6f --- /dev/null +++ b/tests/fixtures/delete-reference/check-uninitialized.json @@ -0,0 +1 @@ +{"code":4,"message":"action_reference_check_unavailable","data":{"action_id":"template","check_reason":"loading"}} diff --git a/tests/fixtures/delete-reference/in-use-no-details.json b/tests/fixtures/delete-reference/in-use-no-details.json new file mode 100644 index 0000000..ba017c2 --- /dev/null +++ b/tests/fixtures/delete-reference/in-use-no-details.json @@ -0,0 +1 @@ +{"code":4,"message":"action_in_use","data":{"action_id":"template","references_confirmed":true,"details_available":false}} diff --git a/tests/fixtures/delete-reference/in-use.json b/tests/fixtures/delete-reference/in-use.json new file mode 100644 index 0000000..fd19886 --- /dev/null +++ b/tests/fixtures/delete-reference/in-use.json @@ -0,0 +1 @@ +{"code":4,"message":"action_in_use","data":{"action_id":"template","references_confirmed":true,"rules":[{"id":"rule-0","name":"Model \" < & > 中文"},{"id":"rule-1","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-2","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-3","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-4","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-5","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-6","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-7","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-8","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-9","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-10","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-11","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-12","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-13","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-14","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-15","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-16","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-17","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-18","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-19","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-20","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-21","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-22","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-23","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-24","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-25","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-26","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-27","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-28","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-29","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-30","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-31","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"}],"details_available":true}} diff --git a/tests/fixtures/delete-reference/service-protected.json b/tests/fixtures/delete-reference/service-protected.json new file mode 100644 index 0000000..076fa1f --- /dev/null +++ b/tests/fixtures/delete-reference/service-protected.json @@ -0,0 +1 @@ +{"code":4,"message":"service_delete_protected","data":{"action_id":"template"}} diff --git a/tests/prompts/delete-protection.browser.test.cjs b/tests/prompts/delete-protection.browser.test.cjs index 3c62911..de838d1 100644 --- a/tests/prompts/delete-protection.browser.test.cjs +++ b/tests/prompts/delete-protection.browser.test.cjs @@ -32,13 +32,24 @@ for(const entry of ['action','command'])for(const state of ['stopped','ready'])t }); try{ await page.goto(base);await page.waitForFunction(()=>i18n.isReady()&&typeof deleteAction==='function'); - await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};refreshCommandsList=()=>{};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); - await page.evaluate(entry=>entry==='action'?deleteAction('template'):deleteCommand(0),entry); + await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};window.commandRefreshes=0;refreshCommandsList=()=>{window.commandRefreshes++;};window.deleteNavigationCalls=0;const navigate=router.navigate.bind(router);router.navigate=(...args)=>{window.deleteNavigationCalls++;return navigate(...args);};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); + const deletion=page.evaluate(entry=>entry==='action'?deleteAction('template'):deleteCommand(0),entry); + if(state==='ready'){ + await page.locator('.confirm-sheet').waitFor();assert((await page.locator('.confirm-sheet').textContent()).includes('仍在运行')); + if(entry==='command'){ + assert.equal(await page.locator('.confirm-sheet button').count(),1); + assert.equal(await page.locator('.confirm-sheet button').textContent(),await page.evaluate(()=>t('common.close'))); + const before=await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})); + await page.locator('.confirm-sheet button').click();await deletion; + assert.deepEqual(await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})),before); + }else await page.keyboard.press('Escape'); + } + await deletion; const deletes=calls.filter(c=>c.endpoint===('action'===entry?'automation/actions/delete':'ssh/commands/remove')); assert.equal(deletes.length,state==='stopped'?1:0); const verify=calls.find(c=>c.endpoint==='automation/services/status'&&c.method==='POST');assert.equal(verify.params.verify,true);assert.equal(verify.params.command_id,'model'); assert(!calls.some(c=>c.endpoint==='automation/services/stop'||c.endpoint==='ssh/services/start')); - if(state==='ready')assert((await page.locator('#toast').textContent()).includes('尚未确认停止')); + if(state==='ready')assert.equal(await page.locator('.confirm-sheet').count(),0); }finally{await context.close();} }); for(const entry of ['action','command'])test(`${entry}: delayed verification cannot overwrite a newer real start`,async()=>{ diff --git a/tests/prompts/delete-protection.test.cjs b/tests/prompts/delete-protection.test.cjs index b335b01..46e158f 100644 --- a/tests/prompts/delete-protection.test.cjs +++ b/tests/prompts/delete-protection.test.cjs @@ -14,7 +14,8 @@ for(const lang of ['zh-CN','en-US'])test(`${lang}: protected delete explains ser }); async function deleteFixture(){ const h=harness();await h.ready();h.load();h.el('page-content'); - h.ctx.confirmSheet=async()=>true; + h.ctx.confirmSheet=async options=>{h.ctx.lastSheet=options;return false;}; + h.ctx.confirmAction=async()=>true; h.run(`refreshActions=async()=>{};refreshCommandsList=()=>{};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model',nohup:true,serviceMode:true}]};`); return h; } @@ -44,7 +45,9 @@ for(const entry of ['action','command']){ for(const state of ['running','ready','unknown'])test(`${entry}: ${state} never leads to delete or stop`,async()=>{ const h=await deleteFixture(),calls=responses(h,{state});await invoke(h); assert(!calls.some(c=>c.name.endsWith('.delete')||c.name.endsWith('.remove')||c.name.endsWith('.stop'))); - assert(h.el('toast').textContent.includes(h.ctx.t('promptRepair.serviceDeleteProtected'))); + assert(h.ctx.lastSheet.bodyHtml.includes(h.ctx.t('deleteProtection.'+(state==='unknown'?'unconfirmed':'running'),{command:'model'}))); + assert.equal(h.ctx.lastSheet.primary,h.ctx.t(entry==='command'?'common.close':'deleteProtection.viewCommand')); + assert.equal(h.ctx.lastSheet.secondary,entry==='command'?false:h.ctx.t('common.close')); }); test(`${entry}: failed verification never deletes`,async()=>{ const h=await deleteFixture(),calls=responses(h,{phase:'failed'});await invoke(h); diff --git a/tests/prompts/delete-reference.browser.test.cjs b/tests/prompts/delete-reference.browser.test.cjs new file mode 100644 index 0000000..fb5256a --- /dev/null +++ b/tests/prompts/delete-reference.browser.test.cjs @@ -0,0 +1,148 @@ +// Replay exact production HTTP bytes; do not replace api.call or the deletion handlers. +const {test,before,after}=require('node:test'); +const assert=require('node:assert/strict'); +const http=require('node:http'),fs=require('node:fs'),path=require('node:path'); +const {chromium}=require('playwright');const {root}=require('./harness.cjs'); +const fixture=key=>fs.readFileSync(path.resolve(__dirname,'../fixtures/delete-reference',key+'.json'),'utf8'); +let browser; +before(async()=>{browser=await chromium.launch({channel:'chrome',headless:true});}); +after(async()=>{await browser?.close();}); +const name='Model “quoted” < & > 中文'; +async function pageFor(language,width,run,level='root'){ + const state={templateName:name,wire:'in-use',service:false,serviceState:'stopped',rules:[],loaded:true,recovery:false,level,saveError:null},calls=[],errors=[]; + const server=http.createServer((req,res)=>{ + const url=new URL(req.url,'http://local'); + const send=body=>{res.setHeader('Content-Type','application/json');res.end(typeof body==='string'?body:JSON.stringify(body));}; + if(url.pathname.startsWith('/api/')){ + let text='';req.on('data',c=>text+=c);req.on('end',()=>{ + const endpoint=url.pathname.slice('/api/v1/'.length);calls.push({endpoint,method:req.method,body:text}); + if(endpoint==='automation/actions/delete')return send(fixture(state.wire)); + if(endpoint==='automation/rules/add')return send(state.saveError?{code:4,message:state.saveError,data:{error_code:state.saveError}}:fixture('action-missing')); + let data={}; + if(endpoint==='auth/status')data={valid:true,level:state.level,username:state.level}; + if(endpoint==='automation/actions/list')data={templates:[{id:'template',name:state.templateName,type:'log'},{id:'missing',name:'Selected human-readable model',type:'log'}]}; + if(endpoint==='automation/actions/get')data={id:'template',name:state.templateName,type:state.service?'ssh_cmd_ref':'log',ssh_ref:{cmd_id:'model'}}; + if(endpoint==='ssh/commands/get')data={id:'model',name:'Command < & >',nohup:true,serviceMode:true}; + if(endpoint==='automation/services/status')data=req.method==='POST'?{operation_id:31}:{operation_id:31,operation_phase:'succeeded',state:state.serviceState,busy:false}; + if(endpoint==='automation/rules/list')data={loaded:state.loaded,recovery_required:state.recovery,rules:state.rules}; + if(endpoint==='automation/sources/list')data={sources:[]}; + if(endpoint==='ssh/hosts/list')data={hosts:[]}; + if(endpoint==='ssh/commands/list')data={commands:[]}; + return send({code:0,data}); + });return; + } + const file=path.resolve(root,'.'+(url.pathname==='/'?'/index.html':url.pathname)); + if(!file.startsWith(root+'/'))return res.writeHead(403).end(); + fs.readFile(file,(error,body)=>{if(error)return res.writeHead(404).end();res.setHeader('Content-Type',({'.html':'text/html','.js':'application/javascript','.css':'text/css','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');res.end(body);}); + }); + await new Promise(r=>server.listen(0,'127.0.0.1',r));const base=`http://127.0.0.1:${server.address().port}`; + const context=await browser.newContext({viewport:{width,height:900},locale:language}); + await context.addInitScript(({lang,level})=>{localStorage.setItem('ts_language',lang);localStorage.setItem('ts_level',level);localStorage.setItem('ts_token','fixture');localStorage.setItem('ts_username',level);class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}}window.WebSocket=Socket;},{lang:language,level}); + const page=await context.newPage();page.setDefaultTimeout(5000);page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/*',async route=>{if(new URL(route.request().url()).origin!==base)return route.abort();return route.continue();}); + try{await page.goto(base);await page.waitForFunction(()=>i18n.isReady());await page.evaluate(root=>{closeLoginModal();if(root)location.hash='/automation';},level==='root');await page.waitForSelector(level==='root'?'#actions-list button[onclick]':'#quick-actions-grid');await run({page,state,calls});assert.deepEqual(errors,[]);} + finally{await context.close();await new Promise(r=>server.close(r));} +} +const confirm=async page=>{await page.locator('.confirm-sheet').waitFor();await page.locator('.confirm-sheet button[data-r="1"]').click();}; +const attempt=async page=>{await page.locator('button[onclick="deleteAction(\'template\')"]').click();await confirm(page);}; +const waitTitle=(page,key)=>page.waitForFunction(key=>document.querySelector('.confirm-sheet .st')?.textContent===t(key),key); +for(const language of ['zh-CN','en-US'])test(`${language}: navigation buttons open existing pages without positioning or deletion`,async()=>pageFor(language,390,async({page,state,calls})=>{ + const before=calls.filter(c=>c.endpoint==='automation/actions/list').length; + await attempt(page);await waitTitle(page,'deleteProtection.actionTitle');await page.keyboard.press('Enter'); + await page.waitForFunction(()=>!document.querySelector('.confirm-sheet'));await page.waitForSelector('#actions-list button[onclick]'); + assert(calls.filter(c=>c.endpoint==='automation/actions/list').length>before);assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1); + state.service=true;state.serviceState='ready';await attempt(page);await page.waitForFunction(()=>document.querySelector('.confirm-sheet .st')?.textContent.includes('quoted')); + await page.keyboard.press('Enter');await page.waitForFunction(()=>location.hash==='#/commands');await page.waitForFunction(()=>document.querySelector('.nav a[href="#/commands"]')?.classList.contains('active')); + assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1);assert(!calls.some(c=>c.endpoint==='automation/services/stop')); +})); +for(const language of ['zh-CN','en-US'])for(const width of [390,1440]){ + test(`${language} ${width}: 32 production references, escaping, design, close and navigation`,async()=>pageFor(language,width,async({page,calls})=>{ + await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + const sheet=page.locator('.confirm-sheet');assert.equal(await sheet.locator('.grp .row').count(),32); + assert.equal(await sheet.locator('.grp img,.grp script').count(),0);assert((await sheet.textContent()).includes('中文')); + const style=await sheet.locator('.sheet').evaluate(e=>{const s=getComputedStyle(e),title=getComputedStyle(e.querySelector('.st')),body=getComputedStyle(e.querySelector('.t-body')),r=e.getBoundingClientRect();return {radius:s.borderRadius,padding:s.padding,weight:title.fontWeight,title:title.fontSize,body:body.fontSize,width:r.width,right:r.right,inner:e.scrollWidth,client:e.clientWidth};}); + const children=await sheet.locator('.grp .row,.grp .rl,.grp .rc').evaluateAll(es=>es.map(e=>({scroll:e.scrollWidth,client:e.clientWidth,right:e.getBoundingClientRect().right,parent:e.parentElement.getBoundingClientRect().right}))); + assert(children.every(e=>e.scroll<=e.client+1&&e.right<=e.parent+1),JSON.stringify(children)); + assert.equal(await page.locator('#toast.show.toast-info').count(),0); + assert.equal(await page.locator('#toast').isVisible(),false); + assert.equal(style.title,'20px');assert.equal(style.body,'14px');assert.equal(style.weight,'600');assert(style.right<=width+1);assert(style.inner<=style.client+1); + assert.equal(style.radius,width===390?'32px 32px 0px 0px':'32px'); + if(process.env.DELETE_SCREENSHOTS==='1'){const out=path.resolve(__dirname,'../../output/playwright/delete-protection');fs.mkdirSync(out,{recursive:true});await page.screenshot({path:path.join(out,language+'-'+width+'.png')});fs.writeFileSync(path.join(out,language+'-'+width+'-style.json'),JSON.stringify(style,null,2));} + await page.keyboard.press('Escape');assert.equal(await sheet.count(),0);assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1); + await attempt(page);await waitTitle(page,'deleteProtection.actionTitle');await page.keyboard.press('Enter');await page.waitForFunction(()=>location.hash==='#/automation'); + assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,2); + })); + test(`${language} ${width}: 47-byte label and 63-byte ID remain readable`,async()=>pageFor(language,width,async({page,state})=>{ + state.wire='boundary';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + assert.equal((await page.locator('.confirm-sheet .grp .rc').textContent()).length,63);assert.equal((await page.locator('.confirm-sheet .grp .rl').textContent()).length,47); + const fields=await page.locator('.confirm-sheet .grp .rl,.confirm-sheet .grp .rc').evaluateAll(es=>es.map(e=>[e.scrollWidth,e.clientWidth]));assert(fields.every(([scroll,width])=>scroll<=width+1)); + const bounds=await page.locator('.confirm-sheet .sheet').evaluate(e=>[e.scrollWidth,e.clientWidth]);assert(bounds[0]<=bounds[1]+1);await page.keyboard.press('Escape'); + })); + test(`${language} ${width}: service-blocked 47-byte template title does not clip`,async()=>pageFor(language,width,async({page,state})=>{ + state.service=true;state.serviceState='ready';state.templateName='N'.repeat(47);await page.evaluate(()=>refreshActions());await attempt(page); + await page.waitForFunction(()=>document.querySelector('.confirm-sheet .st')?.textContent.includes('NNNNNNNN')); + const boxes=await page.locator('.confirm-sheet .st,.confirm-sheet .t-body').evaluateAll(es=>es.map(e=>({scroll:e.scrollWidth,width:e.clientWidth,right:e.getBoundingClientRect().right,parent:e.parentElement.getBoundingClientRect().right}))); + assert(boxes.every(e=>e.scroll<=e.width+1&&e.right<=e.parent+1),JSON.stringify(boxes));await page.keyboard.press('Escape'); + })); + test(`${language} ${width}: C group has only Close; missing list remains blocked`,async()=>pageFor(language,width,async({page,state,calls})=>{ + state.wire='check-loading';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle');assert.equal(await page.locator('.confirm-sheet button').count(),1); + const text=await page.locator('.confirm-sheet').textContent();assert(text.includes(await page.evaluate(()=>t('deleteProtection.checkLoading')))); + await page.keyboard.press('Enter');assert.equal(await page.locator('.confirm-sheet').count(),0); + state.wire='in-use-no-details';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + assert((await page.locator('.confirm-sheet').textContent()).includes(await page.evaluate(name=>t('deleteProtection.actionInUseWithoutDetails',{name}),name))); + await page.locator('.confirm-sheet button[data-r="0"]').click();assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,2); + })); + test(`${language} ${width}: successive service and reference blockers have no stale sheet`,async()=>pageFor(language,width,async({page,state,calls})=>{ + state.service=true;state.serviceState='ready';await attempt(page); + await page.waitForFunction(()=>document.querySelector('.confirm-sheet .st')?.textContent.startsWith(t('deleteProtection.templateTitle',{name:'Model'}).split('“')[0])); + assert((await page.locator('.confirm-sheet').textContent()).includes('Command < & >'));assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,0); + await page.locator('.confirm-sheet button[data-r="0"]').click();state.serviceState='stopped';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + assert.equal(await page.locator('.confirm-sheet').count(),1);assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1); + await page.keyboard.press('Escape'); + })); + test(`${language} ${width}: homepage empty/loading/recovery retains grid and entry`,async()=>pageFor(language,width,async({page,state})=>{ + await page.evaluate(()=>{document.getElementById('page-content').innerHTML='
';}); + for(const scenario of ['empty','loading','recovery']){ + state.loaded=scenario==='empty';state.recovery=scenario==='recovery';await page.evaluate(()=>refreshQuickActions()); + const grid=page.locator('#quick-actions-grid');assert.equal(await grid.count(),1);assert.equal(await grid.locator('button').count(),1); + const key=scenario==='empty'?'automationPage.noQuickActions':scenario==='loading'?'deleteProtection.quickLoadingTitle':'deleteProtection.quickRecoveryTitle'; + assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),key))); + if(scenario==='loading')assert((await grid.textContent()).includes(await page.evaluate(()=>t('deleteProtection.loadingDetails')))); + if(scenario==='recovery'){assert((await grid.textContent()).includes(await page.evaluate(()=>runtimeText('recovery_required'))));assert((await grid.textContent()).includes(await page.evaluate(()=>t('deleteProtection.recoveryDetails'))));} + const bodyKey=scenario==='empty'?'automationPage.quickActionsHint':scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required'; + assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),bodyKey))); + } + })); + test(`${language} ${width}: stale selection failure names the chosen option and retains draft`,async()=>pageFor(language,width,async({page,calls})=>{ + await page.evaluate(async()=>{showAddRuleModal();await addActionTemplateRow('missing');}); + await page.locator('#rule-id').fill('new-rule');await page.locator('#rule-name').fill('Draft remains');await page.locator('#rule-manual-only').check(); + await page.locator('#add-rule-modal button[onclick="submitAddRule()"] ').click(); + await page.waitForFunction(()=>document.getElementById('toast')?.textContent.includes('Selected human-readable model')); + assert.equal(await page.locator('#rule-name').inputValue(),'Draft remains');assert.equal(await page.locator('#add-rule-modal').count(),1);assert.equal(calls.filter(c=>c.endpoint==='automation/rules/add').length,1); + })); +} + +// One Chinese admin scene: both auth status and initial storage use the same non-root level. +test('zh-CN: non-root homepage has approved guidance and no management entry',async()=>pageFor('zh-CN',390,async({page,state})=>{ + assert.equal(await page.evaluate(()=>api.getLevel()),'admin');assert.equal(await page.evaluate(()=>api.isRoot()),false); + for(const scenario of ['empty','loading','recovery']){ + state.loaded=scenario==='empty';state.recovery=scenario==='recovery';await page.evaluate(()=>refreshQuickActions()); + const grid=page.locator('#quick-actions-grid');assert.equal(await grid.count(),1);assert.equal(await grid.locator('button').count(),0); + const titleKey=scenario==='empty'?'automationPage.noQuickActions':scenario==='loading'?'deleteProtection.quickLoadingTitle':'deleteProtection.quickRecoveryTitle'; + assert.equal(await grid.locator('.t-body').textContent(),await page.evaluate(k=>t(k),titleKey)); + const newKey={empty:'deleteProtection.quickEmptyNeedRoot',loading:'deleteProtection.quickLoadingNeedRoot',recovery:'deleteProtection.quickRecoveryNeedRoot'}[scenario]; + assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),newKey))); + if(scenario!=='empty')assert.equal(await grid.locator('.t-note').first().textContent(),await page.evaluate(k=>t(k),scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required')); + for(const oldKey of ['deleteProtection.loadingDetails','deleteProtection.recoveryDetails','automationPage.quickActionsHint']) + assert(!(await grid.textContent()).includes(await page.evaluate(k=>t(k),oldKey))); + } +},'admin')); +test('zh-CN: template lookup failure uses saveFailed and retains the actual draft',async()=>pageFor('zh-CN',390,async({page,state,calls})=>{ + state.saveError='template_lookup_failed'; + await page.evaluate(async()=>{showAddRuleModal();await addActionTemplateRow('missing');}); + await page.locator('#rule-id').fill('new-rule');await page.locator('#rule-name').fill('Lookup draft remains');await page.locator('#rule-manual-only').check(); + await page.locator('#add-rule-modal button[onclick="submitAddRule()"] ').click(); + await page.waitForFunction(()=>document.getElementById('toast')?.textContent===t('runtimeRepair.saveFailed')); + assert.equal(await page.locator('#rule-name').inputValue(),'Lookup draft remains');assert.equal(await page.locator('#add-rule-modal').count(),1); + assert.equal(calls.filter(c=>c.endpoint==='automation/rules/add').length,1); +})); diff --git a/tests/prompts/delete-reference.test.cjs b/tests/prompts/delete-reference.test.cjs new file mode 100644 index 0000000..cc08b1b --- /dev/null +++ b/tests/prompts/delete-reference.test.cjs @@ -0,0 +1,28 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {harness}=require('./harness.cjs'); +for(const language of ['zh-CN','en-US'])test(`${language}: H15 nine states, busy and operation kind select an approved explanation`,async()=>{ + const h=harness(language);await h.ready();h.load(); + const states={unknown:'unconfirmed',running:'running',ready:'running',checking:'running',starting:'starting',stopping:'stopping',stopped:null,failed:'unconfirmed',timeout:'unconfirmed'}; + for(const [state,expected] of Object.entries(states))for(const busy of [false,true])for(const kind of ['start','stop','verify','other'])for(const phase of ['succeeded','queued','executing']){ + const pending=busy||phase!=='succeeded';const operation={start:'starting',stop:'stopping',verify:'verifying'}; + const want=pending?(operation[kind]||'operationPending'):expected; + const got=h.ctx.deleteServiceMessage({state,busy,operation_kind:kind,operation_phase:phase}); + assert.equal(got,want,JSON.stringify({state,busy,kind,phase})); + if(got){const text=h.ctx.t('deleteProtection.'+got,{command:'Model'});assert(text&&!text.includes('deleteProtection.'));} + } + assert.equal(h.ctx.deleteServiceMessage({state:'future',busy:false}),'unconfirmed'); + assert.equal(h.ctx.deleteServiceMessage({busy:false}),'unconfirmed'); +}); +for(const language of ['zh-CN','en-US'])test(`${language}: approved keys, placeholders and missing-template display label`,async()=>{ + const h=harness(language);await h.ready();h.load(); + const missing=h.ctx.runtimeSaveError?h.ctx.runtimeSaveError({data:{error_code:'action_missing',missing_template_id:'missing'},missingTemplateName:'Human label'}):h.run("runtimeSaveError({data:{error_code:'action_missing',missing_template_id:'missing'},missingTemplateName:'Human label'})"); + assert(missing.includes('Human label')&&!missing.includes('action_missing')); + assert.equal(h.ctx.runtimeSaveError({rawMessage:'action_missing'}),h.ctx.runtimeText('saveFailed')); + assert.equal(h.ctx.runtimeSaveError({data:{error_code:'template_lookup_failed'}}),h.ctx.runtimeText('saveFailed')); + let sheet;h.ctx.confirmSheet=async options=>{sheet=options;return false;}; + await h.ctx.showDeleteProtection({code:4,message:'action_in_use'}, {type:'action',name:'Model'},()=>true); + assert(sheet.bodyHtml.includes(h.ctx.escapeHtml(h.ctx.t('deleteProtection.actionInUseWithoutDetails',{name:'Model'})))); + await h.ctx.showDeleteProtection({code:4,message:'action_reference_check_unavailable',data:{check_reason:'loading'}}, {type:'action',name:'Model'},()=>true); + assert.equal(sheet.secondary,false);assert.equal(sheet.primary,h.ctx.t('common.close')); +}); diff --git a/tests/prompts/package.json b/tests/prompts/package.json index 1f5604a..79f4a4a 100644 --- a/tests/prompts/package.json +++ b/tests/prompts/package.json @@ -5,8 +5,8 @@ "node": ">=22" }, "scripts": { - "test": "cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs", - "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs", + "test": "cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs tests/prompts/delete-reference.test.cjs", + "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs tests/prompts/delete-reference.browser.test.cjs", "test:xterm": "cd ../.. && node --test tests/prompts/xterm-vendor.test.cjs tests/prompts/xterm-offline.test.cjs" }, "devDependencies": { diff --git a/tests/prompts/static.test.cjs b/tests/prompts/static.test.cjs index 3a29c34..193b82a 100644 --- a/tests/prompts/static.test.cjs +++ b/tests/prompts/static.test.cjs @@ -11,7 +11,7 @@ const html=read('index.html'); for(const f of ['index.html','js/app.js','js/terminal.js'])for(const m of read(f).matchAll(/data-i18n(?:-title|-placeholder)?="([^"]+)"/g))if(!m[1].includes('${'))calls.push({f,key:m[1]}); for(const m of read('js/app.js').matchAll(/(?:labelKey|descKey): '([^']+)'/g))calls.push({f:'dynamic widget option',key:m[1]}); // Explicit finite dynamic families; unknown names use neutral runtime fallbacks. -for(const family of ['promptRepair','runtimeRepair'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); +for(const family of ['promptRepair','runtimeRepair','deleteProtection'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); test('actual UI literal/conditional and finite dynamic keys exist in both languages with matching contracts',()=>{ let failures=[]; for(const c of calls){const a=lookup('zh-CN',c.key),b=lookup('en-US',c.key); diff --git a/tests/runtime/extract_engine.py b/tests/runtime/extract_engine.py index 5cf8426..ec46fd2 100644 --- a/tests/runtime/extract_engine.py +++ b/tests/runtime/extract_engine.py @@ -3,7 +3,7 @@ s=Path('components/ts_automation/src/ts_rule_engine.c').read_text() a=s.index('typedef struct {\n ts_auto_rule_t *rules;');b=s.index('static void payload_free',a) parts=[s[a:b]] -for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','validate_new_template_refs','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name end=s.index('\n}',m.start())+2;parts.append(s[m.start():end]) Path('/tmp/tianshan-runtime-tests/engine.inc').write_text('\n'.join(parts)) diff --git a/tests/runtime/run_all.sh b/tests/runtime/run_all.sh index cba25f4..3da2949 100755 --- a/tests/runtime/run_all.sh +++ b/tests/runtime/run_all.sh @@ -9,6 +9,7 @@ python3 tests/runtime/test_completion.py python3 tests/runtime/test_action_submission.py python3 tests/runtime/test_service_control.py python3 tests/runtime/test_delete_api.py +python3 tests/runtime/test_delete_reference.py python3 tests/runtime/test_condition_roundtrip.py python3 tests/runtime/test_expansion.py python3 tests/runtime/test_configuration_protocol.py diff --git a/tests/runtime/test_delete_api.py b/tests/runtime/test_delete_api.py index 8ab768c..9b1a6a1 100644 --- a/tests/runtime/test_delete_api.py +++ b/tests/runtime/test_delete_api.py @@ -21,6 +21,7 @@ def handler(path, name): #include #include #include "ts_api.h" +#include "ts_action_manager.h" #define ACTIONS_SDCARD_DIR "/sdcard/config/actions" #define TAG "test" #define TS_LOGI(...) ((void)0) @@ -29,6 +30,10 @@ def handler(path, name): static int test_unlink(const char *path) { assert(strstr(path, "/fixture.")); ++unlinks; return 0; } #define unlink test_unlink esp_err_t ts_action_template_remove(const char *id) { assert(!strcmp(id,"fixture")); ++remove_calls; return outcome; } +esp_err_t ts_action_template_remove_checked(const char *id,ts_action_template_delete_result_t *detail){ + *detail=(ts_action_template_delete_result_t){0};if(outcome==ESP_ERR_INVALID_STATE)detail->reason="service_delete_protected"; + return ts_action_template_remove(id); +} esp_err_t ts_ssh_commands_config_remove(const char *id) { return ts_action_template_remove(id); } void ts_api_result_ok(ts_api_result_t *result, cJSON *data) { result->code=TS_API_OK; result->data=data; } void ts_api_result_error(ts_api_result_t *result, ts_api_result_code_t code, const char *message) { result->code=code; result->message=strdup(message); } @@ -56,5 +61,5 @@ def handler(path, name): source=Path(tmp)/'delete.c';binary=Path(tmp)/'delete' source.write_text(code) env=dict(os.environ,DEVELOPER_DIR=os.environ.get('DEVELOPER_DIR','/Library/Developer/CommandLineTools')) - subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations','-Itests/runtime/stubs','-Itests/certificate/stubs','-Icomponents/ts_api/include','-I'+str(idf/'components/json/cJSON'),str(source),str(idf/'components/json/cJSON/cJSON.c'),'-lm','-o',str(binary)],check=True,env=env) + subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations','-Itests/runtime/stubs','-Itests/certificate/stubs','-Icomponents/ts_api/include','-Icomponents/ts_automation/include','-Icomponents/ts_security/include','-I'+str(idf/'components/json/cJSON'),str(source),str(idf/'components/json/cJSON/cJSON.c'),'-lm','-o',str(binary)],check=True,env=env) subprocess.run([str(binary)],check=True,env=env) diff --git a/tests/runtime/test_delete_reference.py b/tests/runtime/test_delete_reference.py new file mode 100644 index 0000000..c575130 --- /dev/null +++ b/tests/runtime/test_delete_reference.py @@ -0,0 +1,156 @@ +"""Production deletion/commit gates and HTTP bytes; synthetic storage, no device access.""" +from pathlib import Path +import json, os, re, subprocess, tempfile +root=Path(__file__).resolve().parents[2] +os.chdir(root) +idf=Path(os.environ.get('IDF_PATH','/Users/massif/esp/v5.5.2/esp-idf')) +def extract(file,name): + s=Path(file).read_text();m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name + return s[m.start():s.index('\n}',m.start())+2]+'\n' +engine=Path('components/ts_automation/src/ts_rule_engine.c').read_text() +a=engine.index('typedef struct {\n ts_auto_rule_t *rules;');b=engine.index('static void payload_free',a) +parts=[engine[a:b]] +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','validate_new_template_refs','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index','ts_rule_count','ts_rule_config_status']: + parts.append(extract('components/ts_automation/src/ts_rule_engine.c',name)) +prefix=Path('tests/runtime/test_engine.c').read_text().split('int main(void)')[0] +prefix=prefix.replace('#include "../../components/ts_automation/src/ts_rule_store.h"', '#include "'+str(root/'components/ts_automation/src/ts_rule_store.h')+'"') +prefix=prefix.replace('#define CONFIG_TS_AUTOMATION_MAX_RULES 4','#define CONFIG_TS_AUTOMATION_MAX_RULES 32') +prefix=prefix.replace('#include "engine.inc"','\n'.join(parts)) +prefix=prefix.replace('++commits;*result=', '++commits;barrier(3);*result=') +prefix=prefix.replace('if(template_missing)return ESP_ERR_NOT_FOUND;', 'if(template_missing || !strcmp(id,"missing"))return ESP_ERR_NOT_FOUND;') +code=prefix+r''' +#include "ts_api.h" +#define TS_STRDUP_PSRAM strdup +#define TS_LOGI(...) ((void)0) +#define ESP_LOGE(...) ((void)0) +static const char *TAG="audit"; +static int removals,unlinks; +static bool service_protected; +bool ts_rule_edit_begin(void){return s_rule_ctx.initialized&&pthread_mutex_trylock(s_rule_ctx.transaction)==0;} +void ts_rule_edit_end(void){pthread_mutex_unlock(s_rule_ctx.transaction);} +static bool template_binding_protected(const char *id,const ts_action_template_t *next){return service_protected;} +static esp_err_t template_remove_impl(const char *id){++removals;template_missing=true;barrier(4);return ESP_OK;} +''' +for name in ['template_references','ts_action_template_remove_checked','ts_action_template_remove']: + code+=extract('components/ts_automation/src/ts_action_manager.c',name) +code+=r''' +#define ACTIONS_SDCARD_DIR "/sdcard/config/actions" +static int fake_unlink(const char *path){assert(strstr(path,ACTIONS_SDCARD_DIR));++unlinks;return 0;} +#define unlink fake_unlink +''' +code+=extract('components/ts_api/src/ts_api_automation.c','api_automation_actions_delete') +code+=extract('components/ts_api/src/ts_api_automation.c','rule_commit_reply') +for name in ['ts_api_result_free','ts_api_result_ok','ts_api_result_error']: + code+=extract('components/ts_api/src/ts_api.c',name) +web=Path('components/ts_webui/src/ts_webui_api.c').read_text();a=web.index(' if (ret == ESP_OK || result.code == TS_API_OK) {');b=web.index('\n}\n',a) +code+=r''' +static char *http_body; +static esp_err_t ts_http_send_json(void *req,int status,const char *body){assert(status==200);free(http_body);http_body=strdup(body);return ESP_OK;} +static esp_err_t ts_http_send_error(void *req,int status,const char *message){return ESP_FAIL;} +static esp_err_t send_result(esp_err_t ret,ts_api_result_t result){void *req=NULL; +'''+web[a:b]+'\n}\n' +code+=r''' +static const char *out_dir; +static void capture(const char *name,ts_api_result_t result){ + assert(send_result(ESP_OK,result)==ESP_OK);cJSON *wire=cJSON_Parse(http_body);assert(wire);cJSON_Delete(wire); + char path[512];snprintf(path,sizeof(path),"%s/%s.json",out_dir,name);FILE*f=fopen(path,"w");assert(f);fputs(http_body,f);fputc('\n',f);fclose(f); +} +static void add(const char *id,const char *name,bool enabled,bool duplicate){ + ts_auto_rule_t r=candidate("fixture");strcpy(r.id,id);strcpy(r.name,name);r.enabled=enabled; + strcpy(r.actions[0].template_id,"template"); + if(duplicate){r.actions=realloc(r.actions,2*sizeof(*r.actions));r.actions[1]=r.actions[0];r.action_count=2;} + ts_rule_commit_result_t result;assert(ts_rule_commit(&r,id,0,&result)==ESP_OK);ts_rule_dispose(&r); +} +static void clear_rules(void){for(int i=0;i 中文";add("rule-0",label,false,true); + char id[64],name[48];memset(name,'N',47);name[47]=0; + for(int i=1;i<32;i++){snprintf(id,sizeof(id),"rule-%d",i);add(id,name,i%2,false);} + ts_action_template_delete_result_t d;int before=commits; + assert(ts_action_template_remove_checked("template",&d)==ESP_ERR_INVALID_STATE&&d.references_confirmed&&d.rule_count==32&&d.rules); + assert(!removals&&!unlinks&&commits==before);for(int i=0;i<32;i++){assert(!strcmp(d.rules[i].id,s_rule_ctx.rules[i].id));assert(!((rule_payload_t*)s_rule_ctx.rules[i].lease)->refs);}free(d.rules); + delete_api("in-use");assert(!removals&&!unlinks); + fail_alloc=0;delete_api("in-use-no-details");fail_alloc=-1;assert(!removals&&!unlinks); + // H14: no waiting, actual transaction contention and distinct loaded flag. + pthread_t worker;pthread_mutex_lock(&txn);s_rule_ctx.loaded=false;pthread_create(&worker,NULL,try_delete,NULL);pthread_join(worker,NULL);pthread_mutex_unlock(&txn); + delete_api("check-loading");s_rule_ctx.loaded=true; + pthread_mutex_lock(&txn);pthread_create(&worker,NULL,try_delete,NULL);pthread_join(worker,NULL);pthread_mutex_unlock(&txn); + s_rule_ctx.initialized=false;delete_api("check-uninitialized");s_rule_ctx.initialized=true; + s_rule_ctx.recovery_error=true;delete_api("check-recovery");s_rule_ctx.recovery_error=false; + // H03/H04 deletion first -> a new write cannot use the deleted template. + clear_rules();delete_api(NULL);assert(removals==1&&unlinks==2); + ts_auto_rule_t r=candidate("valid");strcpy(r.actions[0].template_id,"template");ts_rule_commit_result_t result; + before=commits;assert(ts_rule_commit(&r,r.id,0,&result)==ESP_ERR_NOT_FOUND&&!strcmp(result.error_code,"action_missing"));assert(!commits||commits==before);assert(s_rule_ctx.count==0);ts_rule_dispose(&r); + // H13: denied update clears committing; the previous valid rule still triggers and saves. + template_missing=false;r=candidate("valid");assert(ts_rule_commit(&r,r.id,0,&result)==ESP_OK);uint32_t revision=s_rule_ctx.rules[0].revision; + strcpy(r.actions[0].template_id,"missing");before=commits; + assert(ts_rule_commit(&r,r.id,revision,&result)==ESP_ERR_NOT_FOUND&&commits==before&&!s_rule_ctx.meta[0].committing); + assert(!strcmp(result.missing_template_id,"missing")&&s_rule_ctx.rules[0].revision==revision); + ts_api_result_t reply={0};rule_commit_reply(&reply,ESP_ERR_NOT_FOUND,&result);capture("action-missing",reply); + bool triggered=false;assert(execute_rule(r.id,true,&triggered)==ESP_OK&&triggered); + r.actions[0].template_id[0]=0;strcpy(r.name,"after failure");assert(ts_rule_commit(&r,r.id,revision,&result)==ESP_OK); + r.enabled=false;assert(ts_rule_commit(&r,r.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK);r.enabled=true;assert(ts_rule_commit(&r,r.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); + ts_rule_dispose(&r);clear_rules(); + // H09: old/offline missing binding may remain; multiplicity cannot increase. + template_missing=false;add("historic","Historic",true,false);template_missing=true; + assert(ts_rule_acquire("historic",&r)==ESP_OK);strcpy(r.name,"Rename historic");revision=r.revision;ts_rule_release(&r); + // Re-acquire a valid owned copy through codec, rather than retaining a released lease. + cJSON*j=ts_rule_encode(&s_rule_ctx.rules[0]);assert(ts_rule_decode(j,&r)==ESP_OK);cJSON_Delete(j);strcpy(r.name,"Rename historic"); + assert(ts_rule_commit(&r,r.id,revision,&result)==ESP_OK); + r.actions=realloc(r.actions,2*sizeof(*r.actions));r.actions[1]=r.actions[0];r.action_count=2; + assert(ts_rule_commit(&r,r.id,s_rule_ctx.rules[0].revision,&result)==ESP_ERR_NOT_FOUND&&!s_rule_ctx.meta[0].committing); + ts_rule_dispose(&r);clear_rules();template_missing=false; + // H02 byte boundaries are distinct: ID 63, name 47. + memset(id,'I',63);id[63]=0;add(id,name,false,true);delete_api("boundary");clear_rules(); + service_protected=true;delete_api("service-protected");assert(removals==1);service_protected=false; + + // Actual production gates: force both concurrent orders with persistent-operation barriers. + pthread_t first,second;template_missing=false;atomic_store(&writer_started,0); + wait_at(3,&first,save_concurrent); + assert(ts_action_template_remove_checked("template",&d)==ESP_ERR_INVALID_STATE&&!strcmp(d.check_reason,"busy"));free(d.rules); + release_at();pthread_join(first,NULL);block_phase=0;assert(concurrent_ret==ESP_OK); + assert(ts_action_template_remove_checked("template",&d)==ESP_ERR_INVALID_STATE&&d.references_confirmed);free(d.rules);clear_rules(); + wait_at(4,&first,delete_concurrent);atomic_store(&writer_started,0);pthread_create(&second,NULL,save_concurrent,NULL); + while(!atomic_load(&writer_started)){struct timespec delay={0,100000};nanosleep(&delay,NULL);} + release_at();pthread_join(first,NULL);pthread_join(second,NULL);block_phase=0; + assert(concurrent_ret==ESP_ERR_NOT_FOUND&&s_rule_ctx.count==0); + free(http_body);free(s_rule_ctx.rules);pthread_mutex_destroy(&txn);pthread_mutex_destroy(&lock); + puts("PASS production delete + rule commit + HTTP: H01-H14, leases, no side effects, historical multiplicity, committing cleared, 47/63-byte fields"); +} +''' +with tempfile.TemporaryDirectory(prefix='tianshan-delete-ref-') as tmp: + d=Path(tmp);f=d/'test.c';f.write_text(code);wire=d/'wire';wire.mkdir() + env={**os.environ,'DEVELOPER_DIR':os.environ.get('DEVELOPER_DIR','/Library/Developer/CommandLineTools')} + includes=['tests/runtime/stubs','tests/certificate/stubs','components/ts_automation/include','components/ts_security/include','components/ts_api/include',str(idf/'components/json/cJSON')] + subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations',*[f'-I{x}' for x in includes],str(f),'components/ts_automation/src/ts_rule_codec.c','components/ts_automation/src/ts_action_filter.c',str(idf/'components/json/cJSON/cJSON.c'),'-lpthread','-lm','-o',str(d/'test')],check=True,env=env) + subprocess.run([str(d/'test'),str(wire)],check=True,env=env) + fixtures=Path('tests/fixtures/delete-reference') + if os.environ.get('UPDATE_FIXTURES')=='1':fixtures.mkdir(parents=True,exist_ok=True) + for f in wire.glob('*.json'): + target=fixtures/f.name + if os.environ.get('UPDATE_FIXTURES')=='1':target.write_bytes(f.read_bytes()) + assert target.read_bytes()==f.read_bytes(),f'wire bytes drift: {f.name}' + assert json.loads(f.read_text())['code']!=0 + print('PASS exact HTTP byte fixtures:',len(list(wire.glob('*.json')))) diff --git a/tests/runtime/test_engine.c b/tests/runtime/test_engine.c index 13dcea9..4eee149 100644 --- a/tests/runtime/test_engine.c +++ b/tests/runtime/test_engine.c @@ -15,7 +15,7 @@ #define xSemaphoreTakeRecursive xSemaphoreTake #define xSemaphoreGiveRecursive xSemaphoreGive static int fail_alloc=-1, commits, action_calls; -static bool independent_service; +static bool independent_service, template_missing; static pthread_mutex_t binding=PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t control=PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t changed=PTHREAD_COND_INITIALIZER; @@ -32,7 +32,7 @@ void ts_action_snapshot_owner(const ts_auto_action_t *action,const char *rule){} bool ts_ssh_service_any_in_use(void){return independent_service;} bool ts_ssh_service_start_admissible(const char *id){return true;} bool ts_ssh_service_command_protected(const char *id){return false;} -esp_err_t ts_action_template_get(const char *id,ts_action_template_t *out){return ESP_ERR_NOT_FOUND;} +esp_err_t ts_action_template_get(const char *id,ts_action_template_t *out){if(template_missing)return ESP_ERR_NOT_FOUND;memset(out,0,sizeof(*out));out->action.type=TS_AUTO_ACT_LOG;return ESP_OK;} esp_err_t ts_ssh_commands_config_get(const char *id,ts_ssh_command_config_t *out){return ESP_ERR_NOT_FOUND;} static void barrier(int phase){pthread_mutex_lock(&control);if(block_phase==phase){reached=1;pthread_cond_broadcast(&changed);while(!proceed)pthread_cond_wait(&changed,&control);}pthread_mutex_unlock(&control);} esp_err_t ts_variable_get(const char *id,ts_auto_value_t *out){barrier(1);out->type=TS_AUTO_VAL_BOOL;out->bool_val=true;return ESP_OK;} @@ -62,6 +62,7 @@ int main(void){ uint32_t old_instance=s_rule_ctx.rules[0].instance;assert(ts_rule_commit(NULL,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK);assert(ts_rule_commit(&a,a.id,0,&result)==ESP_OK);assert(s_rule_ctx.rules[0].instance!=old_instance&&s_rule_ctx.rules[0].trigger_count==0); strcpy(a.actions[0].template_id,"missing"); assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); + template_missing=true; /* Emulate an imported/offline missing reference after a valid save. */ independent_service=true; /* action can be finished while remote run is still owned */ assert(!s_rule_ctx.meta[0].executing); assert(ts_rule_commit(NULL,a.id,s_rule_ctx.rules[0].revision,&result)!=ESP_OK); @@ -69,8 +70,8 @@ int main(void){ assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)!=ESP_OK); independent_service=false; assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); - strcpy(a.actions[0].template_id,"missing"); - assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); + strcpy(a.actions[0].template_id,"missing");template_missing=false; + assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK);template_missing=true; assert(ts_rule_commit(NULL,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); ts_rule_dispose(&a);free(s_rule_ctx.rules); puts("PASS actual rule engine: revisions/no-op/allocation failures, stale-evaluation barrier, pinned old execution, retirement bound, enable/manual/allow matrix, recreate identity"); From 15962f35d304b5690951c68bd66c5974aadb35a1 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 05:18:01 +0800 Subject: [PATCH 14/29] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E6=96=87=20README=20=E7=9A=84=20TianshanOS=20?= =?UTF-8?q?=E5=A4=B4=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- README_EN.md | 2 +- assets/tianshanos-banner-en.png | Bin 0 -> 96060 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 assets/tianshanos-banner-en.png diff --git a/README.md b/README.md index 2614d6b..b1376bc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

RMinte AI — AI that works. Right where you are. RM-01 product photograph.

+![TianshanOS](assets/tianshanos-banner-en.png) [English](README_EN.md) | [中文](README.md) diff --git a/README_EN.md b/README_EN.md index 11ae279..355fa58 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,4 +1,4 @@ -![TianshanOS](assets/tsintro.png) +![TianshanOS](assets/tianshanos-banner-en.png) [English](README_EN.md) | [中文](README.md) diff --git a/assets/tianshanos-banner-en.png b/assets/tianshanos-banner-en.png new file mode 100644 index 0000000000000000000000000000000000000000..fdd7e120318fc52bf2286aab639aad0054dddc0b GIT binary patch literal 96060 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 Date: Sat, 10 Oct 2026 05:19:33 +0800 Subject: [PATCH 15/29] fix: implement durable save-only rule pack import Persist complete versioned rule sets with one selector, exact-byte acceptance records, target-local revisions and restart-pending guards. Validate signing authority, recipient identity and dependencies without publishing imported rules at runtime. Fix datasource toggle routing and add production crypto, migration, fault-recovery, engine/API and bilingual browser coverage. --- components/ts_api/src/ts_api.c | 4 +- components/ts_api/src/ts_api_automation.c | 141 ++---- components/ts_automation/CMakeLists.txt | 2 + .../ts_automation/include/ts_action_manager.h | 2 + .../ts_automation/include/ts_rule_engine.h | 12 + .../ts_automation/include/ts_rule_pack.h | 9 + .../ts_automation/src/ts_action_manager.c | 76 ++- components/ts_automation/src/ts_automation.c | 2 + components/ts_automation/src/ts_rule_engine.c | 396 ++++++++++++++- components/ts_automation/src/ts_rule_pack.c | 85 ++++ .../ts_automation/src/ts_rule_store_set.c | 474 ++++++++++++++++++ .../ts_automation/src/ts_rule_store_set.h | 38 ++ components/ts_cert/include/ts_cert.h | 4 + components/ts_cert/src/ts_cert.c | 34 ++ components/ts_config_pack/CMakeLists.txt | 2 + components/ts_config_pack/Kconfig | 9 + .../ts_config_pack/include/ts_config_pack.h | 21 + .../ts_config_pack/src/ts_config_pack.c | 293 +++++++---- .../ts_config_pack/src/ts_config_pack_trust.c | 108 ++++ .../ts_config_pack/src/ts_config_pack_trust.h | 6 + .../include/ts_ssh_commands_config.h | 1 + .../ts_security/include/ts_ssh_hosts_config.h | 1 + components/ts_security/src/ts_crypto.c | 10 +- .../ts_security/src/ts_ssh_commands_config.c | 64 ++- .../ts_security/src/ts_ssh_hosts_config.c | 59 ++- components/ts_webui/CMakeLists.txt | 1 + components/ts_webui/src/ts_webui_api.c | 9 +- components/ts_webui/web/js/app.js | 207 ++++---- components/ts_webui/web/js/lang/en-US.js | 47 ++ components/ts_webui/web/js/lang/zh-CN.js | 47 ++ tests/certificate/test_material.c | 9 +- tests/prompts/rule-pack.browser.test.cjs | 174 +++++++ tests/runtime/extract_engine.py | 18 +- tests/runtime/rule_pack/stubs/esp_err.h | 15 + tests/runtime/rule_pack/stubs/esp_heap_caps.h | 6 + tests/runtime/rule_pack/stubs/esp_log.h | 5 + tests/runtime/rule_pack/stubs/nvs.h | 10 + .../rule_pack/stubs/ts_action_manager.h | 14 + tests/runtime/rule_pack/stubs/ts_core.h | 11 + tests/runtime/rule_pack/test_crypto.c | 139 +++++ tests/runtime/rule_pack/test_engine_pack.c | 137 +++++ tests/runtime/rule_pack/test_store_set.c | 242 +++++++++ tests/runtime/rule_pack_contract.txt | 42 ++ tests/runtime/run_all.sh | 1 + tests/runtime/run_rule_pack.sh | 9 + tests/runtime/test_action_pack.py | 6 + tests/runtime/test_action_submission.py | 2 +- tests/runtime/test_configuration_protocol.py | 2 + tests/runtime/test_keystore.py | 21 +- tests/runtime/test_rule_pack_crypto.py | 96 ++++ tests/runtime/test_service_control.py | 2 +- tests/runtime/test_ssh_hosts.py | 5 +- tests/runtime/test_store.c | 6 +- 53 files changed, 2786 insertions(+), 350 deletions(-) create mode 100644 components/ts_automation/include/ts_rule_pack.h create mode 100644 components/ts_automation/src/ts_rule_pack.c create mode 100644 components/ts_automation/src/ts_rule_store_set.c create mode 100644 components/ts_automation/src/ts_rule_store_set.h create mode 100644 components/ts_config_pack/src/ts_config_pack_trust.c create mode 100644 components/ts_config_pack/src/ts_config_pack_trust.h create mode 100644 tests/prompts/rule-pack.browser.test.cjs create mode 100644 tests/runtime/rule_pack/stubs/esp_err.h create mode 100644 tests/runtime/rule_pack/stubs/esp_heap_caps.h create mode 100644 tests/runtime/rule_pack/stubs/esp_log.h create mode 100644 tests/runtime/rule_pack/stubs/nvs.h create mode 100644 tests/runtime/rule_pack/stubs/ts_action_manager.h create mode 100644 tests/runtime/rule_pack/stubs/ts_core.h create mode 100644 tests/runtime/rule_pack/test_crypto.c create mode 100644 tests/runtime/rule_pack/test_engine_pack.c create mode 100644 tests/runtime/rule_pack/test_store_set.c create mode 100644 tests/runtime/rule_pack_contract.txt create mode 100755 tests/runtime/run_rule_pack.sh create mode 100644 tests/runtime/test_rule_pack_crypto.py diff --git a/components/ts_api/src/ts_api.c b/components/ts_api/src/ts_api.c index 0ff1f1a..9bf9b8b 100644 --- a/components/ts_api/src/ts_api.c +++ b/components/ts_api/src/ts_api.c @@ -8,6 +8,7 @@ */ #include "ts_api.h" +#include "ts_config_pack.h" #include "ts_core.h" /* TS_STRDUP_PSRAM, TS_CALLOC_PSRAM */ #include "ts_log.h" #include "freertos/FreeRTOS.h" @@ -316,7 +317,8 @@ esp_err_t ts_api_call_str(const char *name, const char *params_json, ts_api_resu cJSON *params = NULL; if (params_json && strlen(params_json) > 0) { - params = cJSON_Parse(params_json); + params = name&&!strcmp(name,"automation.rules.import") ? + ts_config_pack_parse_import_request(params_json,strlen(params_json)) : cJSON_Parse(params_json); if (params == NULL) { if (result) { ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid JSON"); diff --git a/components/ts_api/src/ts_api_automation.c b/components/ts_api/src/ts_api_automation.c index 96f8a0a..583184d 100644 --- a/components/ts_api/src/ts_api_automation.c +++ b/components/ts_api/src/ts_api_automation.c @@ -204,6 +204,10 @@ static esp_err_t api_automation_resume(const cJSON *params, ts_api_result_t *res */ static esp_err_t api_automation_reload(const cJSON *params, ts_api_result_t *result) { + if (ts_rule_restart_pending()) { + ts_api_result_error(result, TS_API_ERR_BUSY, "restart_pending"); + return ESP_OK; + } esp_err_t ret = ts_automation_reload(); if (ret == ESP_OK) { @@ -478,11 +482,20 @@ static esp_err_t api_automation_variables_set(const cJSON *params, ts_api_result */ static esp_err_t api_automation_rules_list(const cJSON *params, ts_api_result_t *result) { - result->data = cJSON_CreateObject(); bool loaded, recovery; + ts_rule_config_status(&loaded,&recovery); + bool view_gate=loaded||recovery; + if(view_gate&&!ts_rule_edit_begin()){ + ts_api_result_error(result,TS_API_ERR_INTERNAL,"config_loading");return ESP_OK; + } + /* One configuration admission boundary keeps row payloads and saved + * metadata in the same view while an import or ordinary edit is committing. */ + (void)ts_rule_refresh_saved(); + result->data = cJSON_CreateObject(); ts_rule_config_status(&loaded, &recovery); cJSON_AddBoolToObject(result->data, "loaded", loaded); cJSON_AddBoolToObject(result->data, "recovery_required", recovery); + cJSON_AddStringToObject(result->data,"load_error",ts_rule_load_error()); cJSON *rules_array = cJSON_AddArrayToObject(result->data, "rules"); // 遍历所有规则 @@ -507,13 +520,21 @@ static esp_err_t api_automation_rules_list(const cJSON *params, ts_api_result_t cJSON_AddNumberToObject(rule_obj, "cooldown_ms", rule.cooldown_ms); cJSON_AddNumberToObject(rule_obj, "conditions_count", rule.conditions.count); cJSON_AddNumberToObject(rule_obj, "actions_count", rule.action_count); + ts_rule_saved_status(rule.id, rule_obj); cJSON_AddItemToArray(rules_array, rule_obj); ts_rule_release(&rule); } - cJSON_AddNumberToObject(result->data, "count", count); + cJSON *pending=ts_rule_pending_list(); + if(!pending){if(view_gate)ts_rule_edit_end();ts_api_result_error(result,TS_API_ERR_INTERNAL,"recovery_required");return ESP_OK;} + while(cJSON_GetArraySize(pending)) + cJSON_AddItemToArray(rules_array,cJSON_DetachItemFromArray(pending,0)); + cJSON_Delete(pending); + cJSON_AddNumberToObject(result->data, "count", cJSON_GetArraySize(rules_array)); + cJSON_AddBoolToObject(result->data, "restart_required", ts_rule_restart_pending()); result->code = TS_API_OK; + if(view_gate)ts_rule_edit_end(); return ESP_OK; } @@ -717,6 +738,7 @@ static esp_err_t api_automation_rules_get(const cJSON *params, ts_api_result_t * } ts_rule_resolve_presentation(&rule); result->data = ts_rule_encode(&rule); + if(result->data)ts_rule_saved_status(rule.id,result->data); ts_rule_release(&rule); result->code = result->data ? TS_API_OK : TS_API_ERR_INTERNAL; return ESP_OK; @@ -3996,93 +4018,36 @@ static esp_err_t api_automation_rules_export(const cJSON *params, ts_api_result_ */ static esp_err_t api_automation_rules_import(const cJSON *params, ts_api_result_t *result) { - if (!params) { - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Missing parameters"); - return ESP_OK; - } - - const cJSON *tscfg = cJSON_GetObjectItem(params, "tscfg"); - const cJSON *filename = cJSON_GetObjectItem(params, "filename"); - if (!tscfg || !cJSON_IsString(tscfg) || !tscfg->valuestring[0]) { - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Missing 'tscfg' parameter"); - return ESP_OK; - } - - const cJSON *overwrite = cJSON_GetObjectItem(params, "overwrite"); - const cJSON *preview = cJSON_GetObjectItem(params, "preview"); - bool do_overwrite = overwrite && cJSON_IsTrue(overwrite); - bool do_preview = preview && cJSON_IsTrue(preview); - - // Lightweight verification - ts_config_pack_sig_info_t sig_info = {0}; - ts_config_pack_result_t pack_result = ts_config_pack_verify_mem( - tscfg->valuestring, strlen(tscfg->valuestring), &sig_info); - - if (pack_result != TS_CONFIG_PACK_OK) { - const char *err_msg = "Failed to verify config pack"; - if (pack_result == TS_CONFIG_PACK_ERR_RECIPIENT) { - err_msg = "This config pack is not for this device"; - } else if (pack_result == TS_CONFIG_PACK_ERR_SIGNATURE) { - err_msg = "Invalid signature"; - } - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, err_msg); - return ESP_OK; - } - - // Extract config ID from filename - char config_id[64] = {0}; - if (filename && cJSON_IsString(filename) && filename->valuestring[0]) { - const char *name = filename->valuestring; - if (strncmp(name, "rule_", 5) == 0) { - name += 5; - } - const char *dot = strrchr(name, '.'); - if (dot && strcmp(dot, ".tscfg") == 0) { - size_t len = dot - name; - if (len > sizeof(config_id) - 1) len = sizeof(config_id) - 1; - strncpy(config_id, name, len); - } else { - strncpy(config_id, name, sizeof(config_id) - 1); - } - } else { - snprintf(config_id, sizeof(config_id), "rule_%lld", (long long)time(NULL)); - } - - // Check if exists - char filepath[128]; - snprintf(filepath, sizeof(filepath), "%s/%s.tscfg", RULES_SDCARD_DIR, config_id); - struct stat st; - bool exists = (stat(filepath, &st) == 0); - if (!exists) { - snprintf(filepath, sizeof(filepath), "%s/%s.json", RULES_SDCARD_DIR, config_id); - exists = (stat(filepath, &st) == 0); - } - - // Preview mode - if (do_preview) { - cJSON *data = cJSON_CreateObject(); - cJSON_AddBoolToObject(data, "valid", true); - cJSON_AddStringToObject(data, "type", "automation_rule"); - cJSON_AddStringToObject(data, "id", config_id); - cJSON_AddBoolToObject(data, "exists", exists); - cJSON_AddStringToObject(data, "signer", sig_info.signer_cn[0] ? sig_info.signer_cn : "unknown"); - cJSON_AddBoolToObject(data, "official", sig_info.is_official); - cJSON_AddStringToObject(data, "note", "Content will be decrypted on system restart"); - ts_api_result_ok(result, data); - return ESP_OK; - } - - if (exists && !do_overwrite) { - cJSON *data = cJSON_CreateObject(); - cJSON_AddStringToObject(data, "id", config_id); - cJSON_AddBoolToObject(data, "exists", true); - cJSON_AddStringToObject(data, "message", "Rule config already exists, set overwrite=true to replace"); - ts_api_result_ok(result, data); - return ESP_OK; + const cJSON *bytes=cJSON_GetObjectItemCaseSensitive(params,"tscfg"); + bool preview=cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params,"preview")); + const cJSON *revision=cJSON_GetObjectItemCaseSensitive(params,"expected_revision"), + *generation=cJSON_GetObjectItemCaseSensitive(params,"expected_generation"), + *credential=cJSON_GetObjectItemCaseSensitive(params,"credential_generation"), + *digest=cJSON_GetObjectItemCaseSensitive(params,"package_digest"); + if(!cJSON_IsString(bytes)||!bytes->valuestring[0]) { + ts_api_result_error(result,TS_API_ERR_INVALID_ARG,"invalid_pack");return ESP_OK; + } + if(!preview&&(!cJSON_IsNumber(revision)||!cJSON_IsNumber(generation)||!cJSON_IsNumber(credential)|| + !cJSON_IsString(digest)||!isfinite(revision->valuedouble)||!isfinite(generation->valuedouble)|| + !isfinite(credential->valuedouble)||credential->valuedouble<0||credential->valuedouble>UINT32_MAX|| + credential->valuedouble!=(uint32_t)credential->valuedouble|| + revision->valuedouble<0||revision->valuedouble>UINT32_MAX|| + generation->valuedouble<0||generation->valuedouble>UINT32_MAX|| + revision->valuedouble!=(uint32_t)revision->valuedouble|| + generation->valuedouble!=(uint32_t)generation->valuedouble)) { + ts_api_result_error(result,TS_API_ERR_INVALID_ARG,"preview_required");return ESP_OK; + } + cJSON *data=NULL;const char *reason=NULL; + esp_err_t ret=ts_rule_import_pack(bytes->valuestring,strlen(bytes->valuestring),preview, + cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params,"overwrite")), + preview?0:(uint32_t)revision->valuedouble,preview?0:(uint32_t)generation->valuedouble, + preview?0:(uint32_t)credential->valuedouble,preview?NULL:digest->valuestring,&data,&reason); + if(ret==ESP_OK)ts_api_result_ok(result,data); + else { + ts_api_result_error(result,ret==ESP_ERR_NO_MEM?TS_API_ERR_NO_MEM:TS_API_ERR_INVALID_ARG, + reason?reason:"invalid_pack"); + result->data=data; } - - ts_api_result_error(result, TS_API_ERR_INVALID_ARG, - "source_read_only: encrypted rule import requires a recoverable writer"); return ESP_OK; } diff --git a/components/ts_automation/CMakeLists.txt b/components/ts_automation/CMakeLists.txt index efb6e72..fdffd59 100644 --- a/components/ts_automation/CMakeLists.txt +++ b/components/ts_automation/CMakeLists.txt @@ -6,6 +6,8 @@ idf_component_register( "src/ts_rule_engine.c" "src/ts_rule_codec.c" "src/ts_rule_store.c" + "src/ts_rule_store_set.c" + "src/ts_rule_pack.c" "src/ts_action_manager.c" "src/ts_action_filter.c" "src/ts_action_store.c" diff --git a/components/ts_automation/include/ts_action_manager.h b/components/ts_automation/include/ts_action_manager.h index 49bedcd..ba17668 100644 --- a/components/ts_automation/include/ts_action_manager.h +++ b/components/ts_automation/include/ts_action_manager.h @@ -161,6 +161,7 @@ esp_err_t ts_action_manager_resume(void); * @return true if initialized */ bool ts_action_manager_is_initialized(void); +bool ts_action_templates_ready(void); /*===========================================================================*/ /* SSH Host Management */ @@ -190,6 +191,7 @@ esp_err_t ts_action_unregister_ssh_host(const char *host_id); * @return ESP_OK if found */ esp_err_t ts_action_get_ssh_host(const char *host_id, ts_action_ssh_host_t *host_out); +esp_err_t ts_action_get_ssh_host_ex(const char *host_id, ts_action_ssh_host_t *host_out, bool *internal); /** * @brief Get number of registered SSH hosts diff --git a/components/ts_automation/include/ts_rule_engine.h b/components/ts_automation/include/ts_rule_engine.h index 43f555c..9188ccb 100644 --- a/components/ts_automation/include/ts_rule_engine.h +++ b/components/ts_automation/include/ts_rule_engine.h @@ -16,6 +16,7 @@ #include "ts_automation_types.h" #include "esp_err.h" +typedef struct cJSON cJSON; #include #include @@ -354,6 +355,17 @@ void ts_rule_resolve_presentation(ts_auto_rule_t *rule); bool ts_rule_edit_begin(void); void ts_rule_edit_end(void); void ts_rule_config_status(bool *loaded, bool *recovery); +bool ts_rule_restart_pending(void); +esp_err_t ts_rule_refresh_saved(void); +const char *ts_rule_load_error(void); +void ts_rule_saved_status(const char *id, cJSON *object); +cJSON *ts_rule_pending_list(void); +esp_err_t ts_rule_import_pack(const char *bytes, size_t length, bool preview, bool overwrite, + uint32_t expected_revision, uint32_t expected_generation, uint32_t expected_credential, + const char *expected_digest, cJSON **data, const char **reason); +typedef enum { TS_RULE_DEP_TEMPLATE, TS_RULE_DEP_COMMAND, TS_RULE_DEP_HOST, TS_RULE_DEP_ACTION_HOST } ts_rule_dependency_t; +/* Configuration writers already hold ts_ssh_binding_lock; no network/execution side effects. */ +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind, const char *id, const void *next); #ifdef __cplusplus } diff --git a/components/ts_automation/include/ts_rule_pack.h b/components/ts_automation/include/ts_rule_pack.h new file mode 100644 index 0000000..49fd714 --- /dev/null +++ b/components/ts_automation/include/ts_rule_pack.h @@ -0,0 +1,9 @@ +#pragma once +#include "ts_config_pack.h" +#include "ts_automation_types.h" + +/* Strict envelope adapter; plain JSON continues to use ts_rule_decode directly. */ +esp_err_t ts_rule_pack_decode(const char *content, size_t length, ts_auto_rule_t *rule); +const char *ts_rule_pack_error(ts_config_pack_result_t error); +/* Caller holds the SSH configuration binding lock. No execution, connection or pin. */ +esp_err_t ts_rule_pack_dependencies(const ts_auto_rule_t *rule, const char **reason, cJSON **warnings); diff --git a/components/ts_automation/src/ts_action_manager.c b/components/ts_automation/src/ts_action_manager.c index 127efed..723a0a5 100644 --- a/components/ts_automation/src/ts_action_manager.c +++ b/components/ts_automation/src/ts_action_manager.c @@ -16,6 +16,7 @@ */ #include "ts_action_manager.h" +#include "ts_rule_engine.h" #include "ts_action_filter.h" #include "ts_action_store.h" #include "ts_api.h" @@ -121,6 +122,7 @@ typedef struct { /* Initialization state */ bool initialized; + atomic_bool templates_ready; } action_manager_ctx_t; static action_manager_ctx_t *s_ctx = NULL; @@ -303,7 +305,7 @@ esp_err_t ts_action_manager_init(void) ); if (task_ret != pdPASS) { ESP_LOGW(TAG, "Failed to create deferred load task, loading synchronously"); - ts_action_templates_load(); + s_ctx->templates_ready = ts_action_templates_load() == ESP_OK; } ESP_LOGI(TAG, "Action manager initialized (loading deferred)"); @@ -337,14 +339,23 @@ void ts_action_deferred_load_task(void *arg) ESP_LOGI(TAG, "Deferred action template loading started"); esp_err_t ret = ts_action_templates_load(); + s_ctx->templates_ready = ret == ESP_OK; if (ret == ESP_OK) { ESP_LOGI(TAG, "Deferred action template loading complete: %d templates", s_ctx->template_count); } else ESP_LOGE(TAG, "Deferred action template loading failed: %s", esp_err_to_name(ret)); + while (ret != ESP_OK && s_ctx && s_ctx->initialized) { + vTaskDelay(pdMS_TO_TICKS(1000)); + if (!s_ctx || !s_ctx->initialized) break; + ret = ts_action_templates_load(); + if (ret == ESP_OK) ESP_LOGI(TAG, "Action templates ready after retry"); + } vTaskDelete(NULL); } +bool ts_action_templates_ready(void) { return s_ctx && s_ctx->initialized && s_ctx->templates_ready; } + esp_err_t ts_action_manager_deinit(void) { if (s_ctx == NULL) { @@ -391,7 +402,7 @@ bool ts_action_manager_is_initialized(void) /* SSH Host Management */ /*===========================================================================*/ -esp_err_t ts_action_register_ssh_host(const ts_action_ssh_host_t *host) +static esp_err_t register_ssh_host_impl(const ts_action_ssh_host_t *host) { if (!s_ctx || !host || !host->id[0]) { return ESP_ERR_INVALID_ARG; @@ -426,7 +437,7 @@ esp_err_t ts_action_register_ssh_host(const ts_action_ssh_host_t *host) return ESP_OK; } -esp_err_t ts_action_unregister_ssh_host(const char *host_id) +static esp_err_t unregister_ssh_host_impl(const char *host_id) { if (!s_ctx || !host_id) { return ESP_ERR_INVALID_ARG; @@ -453,8 +464,9 @@ esp_err_t ts_action_unregister_ssh_host(const char *host_id) return ESP_ERR_NOT_FOUND; } -esp_err_t ts_action_get_ssh_host(const char *host_id, ts_action_ssh_host_t *host_out) +esp_err_t ts_action_get_ssh_host_ex(const char *host_id, ts_action_ssh_host_t *host_out, bool *internal) { + if (internal) *internal=false; if (!s_ctx || !host_id || !host_out) { return ESP_ERR_INVALID_ARG; } @@ -465,6 +477,7 @@ esp_err_t ts_action_get_ssh_host(const char *host_id, ts_action_ssh_host_t *host for (int i = 0; i < s_ctx->ssh_host_count; i++) { if (strcmp(s_ctx->ssh_hosts[i].id, host_id) == 0) { memcpy(host_out, &s_ctx->ssh_hosts[i], sizeof(ts_action_ssh_host_t)); + if (internal) *internal=true; xSemaphoreGive(s_ctx->ssh_hosts_mutex); return ESP_OK; } @@ -494,6 +507,25 @@ esp_err_t ts_action_get_ssh_host(const char *host_id, ts_action_ssh_host_t *host return ESP_ERR_NOT_FOUND; } +esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *out) { + return ts_action_get_ssh_host_ex(id,out,NULL); +} +esp_err_t ts_action_register_ssh_host(const ts_action_ssh_host_t *host) { + if(!host)return ESP_ERR_INVALID_ARG; + ts_ssh_binding_lock(); + esp_err_t ret=ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,host->id,host); + if(ret==ESP_OK&&ts_ssh_service_host_protected(host->id))ret=ESP_ERR_INVALID_STATE; + if(ret==ESP_OK)ret=register_ssh_host_impl(host); + ts_ssh_binding_unlock();return ret; +} +esp_err_t ts_action_unregister_ssh_host(const char *id) { + if(!id)return ESP_ERR_INVALID_ARG; + ts_ssh_binding_lock(); + esp_err_t ret=ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,id,NULL); + if(ret==ESP_OK&&ts_ssh_service_host_protected(id))ret=ESP_ERR_INVALID_STATE; + if(ret==ESP_OK)ret=unregister_ssh_host_impl(id); + ts_ssh_binding_unlock();return ret; +} int ts_action_get_ssh_host_count(void) { if (!s_ctx) return 0; @@ -3091,7 +3123,7 @@ esp_err_t ts_action_templates_save(void) * Priority: SD card directory > SD card single file > NVS > empty * 当从 NVS 加载后,自动导出到 SD 卡(如果 SD 卡已挂载) */ -esp_err_t ts_action_templates_load(void) +static esp_err_t templates_load_impl(void) { if (!s_ctx) return ESP_ERR_INVALID_STATE; @@ -3212,7 +3244,7 @@ esp_err_t ts_action_templates_load(void) * * 支持 .tscfg 加密配置优先加载 */ -esp_err_t ts_action_templates_load_from_file(const char *filepath) +static esp_err_t templates_load_file_impl(const char *filepath) { if (!s_ctx || !filepath) return ESP_ERR_INVALID_ARG; @@ -3293,6 +3325,29 @@ esp_err_t ts_action_templates_load_from_file(const char *filepath) return ESP_OK; } +esp_err_t ts_action_templates_load(void) { + if (!s_ctx) return ESP_ERR_INVALID_STATE; + ts_ssh_binding_lock(); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,NULL,NULL); + if (ret == ESP_OK) { + ret = templates_load_impl(); + s_ctx->templates_ready = ret == ESP_OK; + } + ts_ssh_binding_unlock(); + return ret; +} +esp_err_t ts_action_templates_load_from_file(const char *path) { + if (!s_ctx || !path) return ESP_ERR_INVALID_ARG; + ts_ssh_binding_lock(); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,NULL,NULL); + if (ret == ESP_OK) { + ret = templates_load_file_impl(path); + s_ctx->templates_ready = ret == ESP_OK; + } + ts_ssh_binding_unlock(); + return ret; +} + static bool template_binding_protected(const char *id, const ts_action_template_t *next) { ts_action_template_t *old = heap_caps_malloc(sizeof(*old), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!old) @@ -3309,8 +3364,9 @@ esp_err_t ts_action_template_update(const char *id, const ts_action_template_t * if (!id || !next) return ESP_ERR_INVALID_ARG; ts_ssh_binding_lock(); - esp_err_t ret = template_binding_protected(id, next) ? ESP_ERR_INVALID_STATE - : template_update_impl(id, next); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,id,next); + if(ret==ESP_OK) ret = template_binding_protected(id, next) ? ESP_ERR_INVALID_STATE + : template_update_impl(id, next); ts_ssh_binding_unlock(); return ret; } @@ -3318,8 +3374,8 @@ esp_err_t ts_action_template_remove(const char *id) { if (!id) return ESP_ERR_INVALID_ARG; ts_ssh_binding_lock(); - esp_err_t ret = - template_binding_protected(id, NULL) ? ESP_ERR_INVALID_STATE : template_remove_impl(id); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,id,NULL); + if(ret==ESP_OK) ret = template_binding_protected(id, NULL) ? ESP_ERR_INVALID_STATE : template_remove_impl(id); ts_ssh_binding_unlock(); return ret; } diff --git a/components/ts_automation/src/ts_automation.c b/components/ts_automation/src/ts_automation.c index c700e27..1f6ccb2 100644 --- a/components/ts_automation/src/ts_automation.c +++ b/components/ts_automation/src/ts_automation.c @@ -196,6 +196,7 @@ esp_err_t ts_automation_deinit(void) if (s_ctx.state == TS_AUTO_STATE_UNINITIALIZED) { return ESP_OK; } + if (ts_rule_restart_pending()) return ESP_ERR_INVALID_STATE; ESP_LOGI(TAG, "Deinitializing automation engine"); @@ -485,6 +486,7 @@ static esp_err_t apply_default_config(void) esp_err_t ts_automation_reload(void) { + if (ts_rule_restart_pending()) return ESP_ERR_INVALID_STATE; if (s_ctx.state == TS_AUTO_STATE_UNINITIALIZED) { return ESP_ERR_INVALID_STATE; } diff --git a/components/ts_automation/src/ts_rule_engine.c b/components/ts_automation/src/ts_rule_engine.c index e7f9060..53e2321 100644 --- a/components/ts_automation/src/ts_rule_engine.c +++ b/components/ts_automation/src/ts_rule_engine.c @@ -1,5 +1,9 @@ #include "ts_rule_codec.h" #include "ts_rule_store.h" +#include "ts_rule_store_set.h" +#include "ts_rule_pack.h" +#include "ts_cert.h" +#include "ts_ssh_hosts_config.h" #include "ts_ssh_commands_config.h" #include "ts_ssh_service.h" /** @@ -43,6 +47,7 @@ #include #include #include +#include static const char *TAG = "ts_rule_engine"; @@ -53,6 +58,9 @@ static const char *TAG = "ts_rule_engine"; #ifndef CONFIG_TS_AUTOMATION_MAX_RULES #define CONFIG_TS_AUTOMATION_MAX_RULES 32 #endif +#ifndef CONFIG_TS_CONFIG_PACK_MAX_SIZE +#define CONFIG_TS_CONFIG_PACK_MAX_SIZE 65536 +#endif /** NVS namespace for rules */ #define NVS_NAMESPACE_RULES "auto_rules" @@ -77,6 +85,7 @@ typedef struct { SemaphoreHandle_t mutex; // 访问互斥锁 bool initialized; bool loaded, recovery_error; + const char *load_error; int source; uint32_t next_instance; unsigned retired; @@ -346,6 +355,8 @@ esp_err_t ts_rule_engine_init(void) memset(&s_rule_ctx.stats, 0, sizeof(s_rule_ctx.stats)); s_rule_ctx.count = 0; + s_rule_ctx.loaded = s_rule_ctx.recovery_error = false; + s_rule_ctx.load_error = "config_loading"; s_rule_ctx.initialized = true; // 延迟加载规则(等待 SD 卡挂载,避免栈溢出) @@ -385,8 +396,21 @@ void ts_rule_deferred_load_task(void *arg) } ESP_LOGI(TAG, "Deferred rule loading started"); - ts_rules_load(); - ESP_LOGI(TAG, "Deferred rule loading complete: %d rules", s_rule_ctx.count); + esp_err_t previous = ESP_OK; + while (s_rule_ctx.initialized) { + /* Readiness is explicit. The sampling interval is not evidence of ready. */ + if (!ts_action_templates_ready()) { vTaskDelay(pdMS_TO_TICKS(100)); continue; } + esp_err_t ret = ts_rules_load(); + if (ret == ESP_OK) { + ESP_LOGI(TAG, "Deferred rule loading complete: %d rules", s_rule_ctx.count); + break; + } + if (ret != previous) ESP_LOGE(TAG, "Rule loading failed: %s", esp_err_to_name(ret)); + previous = ret; + /* A later credential/time/material/dependency change can make loading possible. + * Do not publish a partial set or permanently abandon boot initialization. */ + vTaskDelay(pdMS_TO_TICKS(1000)); + } vTaskDelete(NULL); } @@ -395,7 +419,14 @@ esp_err_t ts_rule_engine_deinit(void) { if (!s_rule_ctx.initialized) return ESP_ERR_INVALID_STATE; + ts_ssh_binding_lock(); xSemaphoreTakeRecursive(s_rule_ctx.transaction, portMAX_DELAY); + /* Deinit/init is an in-process reload, not the required device restart. */ + if (s_rule_ctx.loaded && ts_rule_restart_pending()) { + xSemaphoreGiveRecursive(s_rule_ctx.transaction); + ts_ssh_binding_unlock(); + return ESP_ERR_INVALID_STATE; + } xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); bool busy = s_rule_ctx.retired != 0; for (int i = 0; i < s_rule_ctx.count; ++i) @@ -404,6 +435,7 @@ esp_err_t ts_rule_engine_deinit(void) if (busy) { xSemaphoreGive(s_rule_ctx.mutex); xSemaphoreGiveRecursive(s_rule_ctx.transaction); + ts_ssh_binding_unlock(); return ESP_ERR_INVALID_STATE; } s_rule_ctx.initialized = false; @@ -413,7 +445,9 @@ esp_err_t ts_rule_engine_deinit(void) free(s_rule_ctx.rules); s_rule_ctx.rules = NULL; s_rule_ctx.count = 0; + s_rule_ctx.loaded = false; xSemaphoreGiveRecursive(s_rule_ctx.transaction); + ts_ssh_binding_unlock(); /* Retain small mutexes for late rejected callers. */ return ESP_OK; } @@ -505,7 +539,12 @@ static bool protected_bindings(const ts_auto_rule_t *old, const ts_auto_rule_t * } return false; } -esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t expected, +static bool pending_id(const char *id, int active_index) { + ts_rule_saved_info_t info = {0}; + return ts_rule_store_set_available() && ts_rule_store_set_info(id, &info) == ESP_OK && + (active_index < 0 || info.revision != s_rule_ctx.rules[active_index].revision); +} +static esp_err_t rule_commit_impl(const ts_auto_rule_t *input, const char *id, uint32_t expected, ts_rule_commit_result_t *result) { ts_rule_commit_result_t local = {.error_code = "invalid_argument"}; if (!result) @@ -542,15 +581,18 @@ esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t e xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); int i = find_rule_index(id); esp_err_t ret = ESP_OK; - if (!s_rule_ctx.loaded || s_rule_ctx.recovery_error) { + if (!s_rule_ctx.initialized || !s_rule_ctx.loaded || s_rule_ctx.recovery_error) { ret = ESP_ERR_INVALID_STATE; result->error_code = "recovery_required"; + } else if (pending_id(id, i)) { + ret = ESP_ERR_INVALID_STATE; + result->error_code = "restart_pending"; } else if ((i < 0 && expected != 0) || (i >= 0 && expected != s_rule_ctx.rules[i].revision)) { ret = ESP_ERR_INVALID_STATE; result->error_code = "revision_conflict"; } else if (i < 0 && !input) { ret = ESP_ERR_NOT_FOUND; - } else if (i < 0 && s_rule_ctx.count == s_rule_ctx.capacity) { + } else if (i < 0 && (ts_rule_store_set_available() ? ts_rule_store_set_count() : s_rule_ctx.count) == s_rule_ctx.capacity) { ret = ESP_ERR_NO_MEM; result->error_code = "capacity"; } else if (i >= 0 && s_rule_ctx.meta[i].readonly) { @@ -586,9 +628,7 @@ esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t e } candidate.revision = i >= 0 ? s_rule_ctx.rules[i].revision + 1 : 1; xSemaphoreGive(s_rule_ctx.mutex); - ts_ssh_binding_lock(); if (i >= 0 && protected_bindings(&s_rule_ctx.rules[i], input ? &candidate : NULL)) { - ts_ssh_binding_unlock(); xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); s_rule_ctx.meta[i].committing = false; xSemaphoreGive(s_rule_ctx.mutex); @@ -603,7 +643,6 @@ esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t e MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) : NULL; if (s_rule_ctx.count && !persisted) { - ts_ssh_binding_unlock(); xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); if (i >= 0) s_rule_ctx.meta[i].committing = false; @@ -617,8 +656,12 @@ esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t e memcpy(persisted, s_rule_ctx.rules, s_rule_ctx.count * sizeof(*persisted)); xSemaphoreGive(s_rule_ctx.mutex); ts_rule_commit_t stored; - ret = ts_rule_store_commit(persisted, s_rule_ctx.count, input ? &candidate : NULL, id, - s_rule_ctx.source, &stored); + if (ts_rule_store_set_present()) + ret = ts_rule_store_set_commit(input ? &candidate : NULL, id, expected, + ts_rule_store_set_generation(), NULL, 0, NULL, &stored); + else + ret = ts_rule_store_commit(persisted, s_rule_ctx.count, input ? &candidate : NULL, id, + s_rule_ctx.source, &stored); free(persisted); result->applied = stored.applied; result->durable = stored.durable; @@ -661,12 +704,18 @@ esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t e s_rule_ctx.meta[i].committing = false; xSemaphoreGive(s_rule_ctx.mutex); payload_free(old); - ts_ssh_binding_unlock(); done: xSemaphoreGiveRecursive(s_rule_ctx.transaction); payload_free(candidate.lease); return ret; } +esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t expected, + ts_rule_commit_result_t *result) { + ts_ssh_binding_lock(); + esp_err_t ret = rule_commit_impl(input, id, expected, result); + ts_ssh_binding_unlock(); + return ret; +} esp_err_t ts_rule_register(const ts_auto_rule_t *r) { return r ? ts_rule_commit(r, r->id, r->revision, NULL) : ESP_ERR_INVALID_ARG; } @@ -1793,11 +1842,45 @@ static esp_err_t decode_text(const char *text, ts_auto_rule_t *r) { return e; } static esp_err_t load_one(const char *path, ts_auto_rule_t *r, bool *encrypted) { + char pack_path[192]; + size_t n = strlen(path); + if (n < 5 || n - 5 + 7 > sizeof pack_path) return ESP_ERR_INVALID_ARG; + memcpy(pack_path, path, n - 5); strcpy(pack_path + n - 5, ".tscfg"); + struct stat st; + if (stat(pack_path, &st) == 0) { + if (st.st_size <= 0 || st.st_size > CONFIG_TS_CONFIG_PACK_MAX_SIZE) return ESP_ERR_INVALID_SIZE; + FILE *f = fopen(pack_path, "rb"); if (!f) return ESP_FAIL; + char *bytes = heap_caps_malloc(st.st_size + 1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + bool ok = bytes && fread(bytes, 1, st.st_size, f) == (size_t)st.st_size && !ferror(f); + if (fclose(f)) ok = false; + if (!ok) { free(bytes); return ESP_FAIL; } + bytes[st.st_size] = 0; + ts_config_pack_t *pack = NULL; + ts_config_pack_acceptance_t accepted = {0}; + ts_config_pack_result_t got = ts_config_pack_load_verified_mem(bytes, st.st_size, NULL, + &pack, &accepted, NULL); + free(bytes); + if (got != TS_CONFIG_PACK_OK) { s_rule_ctx.load_error=ts_rule_pack_error(got); return ESP_ERR_INVALID_STATE; } + esp_err_t e = ts_rule_pack_decode(pack->content, pack->content_len, r); + ts_config_pack_free(pack); + if (e == ESP_OK) { + *encrypted = true; + e = ts_rule_store_set_seed(r, pack_path, true, &accepted); + if (e != ESP_OK) ts_rule_dispose(r); + } + return e; + } + if (errno != ENOENT) return ESP_FAIL; char *text = NULL; size_t len = 0; esp_err_t e = ts_config_pack_load_with_priority(path, &text, &len, encrypted); - if (e == ESP_OK) + if (e == ESP_OK) { e = decode_text(text, r); + if (e == ESP_OK) { + e = ts_rule_store_set_seed(r, path, false, NULL); + if (e != ESP_OK) ts_rule_dispose(r); + } + } free(text); return e; } @@ -1919,6 +2002,10 @@ static esp_err_t load_rules(const char *file) { if (!s_rule_ctx.initialized) return ESP_ERR_INVALID_STATE; xSemaphoreTakeRecursive(s_rule_ctx.transaction, portMAX_DELAY); + if (s_rule_ctx.loaded && ts_rule_restart_pending()) { + xSemaphoreGiveRecursive(s_rule_ctx.transaction); + return ESP_ERR_INVALID_STATE; + } xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); bool busy = s_rule_ctx.retired != 0; for (int i = 0; i < s_rule_ctx.count; ++i) @@ -1930,6 +2017,7 @@ static esp_err_t load_rules(const char *file) { return ESP_ERR_INVALID_STATE; } s_rule_ctx.loaded = false; + s_rule_ctx.load_error="recovery_required"; xSemaphoreGive(s_rule_ctx.mutex); ts_auto_rule_t *rules = heap_caps_calloc(s_rule_ctx.capacity, sizeof(*rules), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); @@ -1937,9 +2025,16 @@ static esp_err_t load_rules(const char *file) { int count = 0, source = TS_RULE_SOURCE_NVS; esp_err_t e = rules ? ESP_OK : ESP_ERR_NO_MEM; bool staging = false, require_sd = false; - if (e == ESP_OK) + bool managed = false; + if (e == ESP_OK && !file) { + e = ts_rule_store_set_load(ts_storage_sd_mounted(), rules, readonly, s_rule_ctx.capacity, &count); + managed = e == ESP_OK; + if(e!=ESP_OK&&e!=ESP_ERR_NOT_FOUND)s_rule_ctx.load_error=ts_rule_store_set_error(); + if (e == ESP_ERR_NOT_FOUND) e = ESP_OK; + } else if (e == ESP_OK) ts_rule_store_set_reset(); + if (e == ESP_OK && !managed) e = ts_rule_store_recover(ts_storage_sd_mounted(), &staging, &require_sd); - if (e == ESP_OK) { + if (e == ESP_OK && !managed) { if (file) { source = TS_RULE_SOURCE_READONLY; e = load_legacy_file(file, rules, &count); @@ -1970,6 +2065,19 @@ static esp_err_t load_rules(const char *file) { } } } + if (e == ESP_OK && !managed) + e = ts_rule_store_set_adopt(rules, count, source, readonly); + if (e == ESP_OK) { + ts_ssh_binding_lock(); + for (int i = 0; i < count && e == ESP_OK; ++i) { + const char *reason = NULL; + e = ts_rule_pack_dependencies(&rules[i], &reason, NULL); + if(e!=ESP_OK)s_rule_ctx.load_error=reason; + } + ts_ssh_binding_unlock(); + } + if (e == ESP_OK && !managed) + e = ts_rule_store_set_migrate(); if (e == ESP_OK) for (int i = 0; i < count; ++i) { for (int k = 0; k < i; ++k) @@ -1999,6 +2107,7 @@ static esp_err_t load_rules(const char *file) { for (int i = 0; i < count; ++i) s_rule_ctx.meta[i].readonly = readonly[i]; s_rule_ctx.loaded = true; + s_rule_ctx.load_error="ok"; s_rule_ctx.recovery_error = false; xSemaphoreGive(s_rule_ctx.mutex); for (int i = 0; i < oldcount; ++i) @@ -2018,19 +2127,25 @@ static esp_err_t load_rules(const char *file) { xSemaphoreGiveRecursive(s_rule_ctx.transaction); return e; } -esp_err_t ts_rules_load(void) { return load_rules(NULL); } +esp_err_t ts_rules_load(void) { + ts_ssh_binding_lock(); esp_err_t ret = load_rules(NULL); ts_ssh_binding_unlock(); return ret; +} esp_err_t ts_rules_load_from_file(const char *path) { - return path ? load_rules(path) : ESP_ERR_INVALID_ARG; + if (!path) return ESP_ERR_INVALID_ARG; + ts_ssh_binding_lock(); esp_err_t ret = load_rules(path); ts_ssh_binding_unlock(); return ret; } -/* Mutations persist through ts_rule_commit; reads/loads never rewrite all rules. */ +/* Ordinary mutations persist through ts_rule_commit; boot may migrate legacy authority. */ esp_err_t ts_rules_save(void) { return s_rule_ctx.loaded && !s_rule_ctx.recovery_error ? ESP_OK : ESP_ERR_INVALID_STATE; } bool ts_rule_edit_begin(void) { - return s_rule_ctx.initialized && xSemaphoreTakeRecursive(s_rule_ctx.transaction, 0) == pdTRUE; + if (!s_rule_ctx.initialized) return false; + ts_ssh_binding_lock(); + if (xSemaphoreTakeRecursive(s_rule_ctx.transaction, 0) == pdTRUE) return true; + ts_ssh_binding_unlock(); return false; } -void ts_rule_edit_end(void) { xSemaphoreGiveRecursive(s_rule_ctx.transaction); } +void ts_rule_edit_end(void) { xSemaphoreGiveRecursive(s_rule_ctx.transaction); ts_ssh_binding_unlock(); } void ts_rule_config_status(bool *loaded, bool *recovery) { if (!s_rule_ctx.initialized) { *loaded = false; @@ -2042,3 +2157,246 @@ void ts_rule_config_status(bool *loaded, bool *recovery) { *recovery = s_rule_ctx.recovery_error; xSemaphoreGive(s_rule_ctx.mutex); } + +bool ts_rule_restart_pending(void) { + if (!s_rule_ctx.initialized || !s_rule_ctx.loaded) return false; + xSemaphoreTakeRecursive(s_rule_ctx.transaction, portMAX_DELAY); + bool pending = false; + for (int i=0; i=0&&s_rule_ctx.meta[i].readonly); + xSemaphoreGive(s_rule_ctx.mutex); + cJSON_AddNumberToObject(object,"saved_revision",info.revision); + cJSON_AddNumberToObject(object,"saved_generation",info.generation); + cJSON_AddNumberToObject(object,"active_revision",active); + cJSON_AddBoolToObject(object,"runtime_active",i>=0); + cJSON_AddBoolToObject(object,"restart_required",active!=info.revision); + cJSON_AddBoolToObject(object,"readonly",readonly); + cJSON_AddStringToObject(object,"saved_source",info.source==TS_RULE_SET_SD_PACK?"encrypted_pack":info.source==TS_RULE_SET_SD_JSON?"sd_json":"nvs_json"); + cJSON_AddStringToObject(object,"package_digest",info.digest); + } + xSemaphoreGiveRecursive(s_rule_ctx.transaction); +} +cJSON *ts_rule_pending_list(void) { + cJSON *array=cJSON_CreateArray(); + if(!array || !s_rule_ctx.initialized) return array; + xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); + for(int i=0;icontent,pack->content_len,&rule); + ts_config_pack_free(pack); + if(ret!=ESP_OK){*reason="invalid_rule_pack";return ret;} + ts_ssh_binding_lock(); + xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); + cJSON *warnings=NULL; + ret=ts_rule_pack_dependencies(&rule,reason,&warnings); + if(ret!=ESP_OK)goto done; + if(!s_rule_ctx.initialized||!s_rule_ctx.loaded||s_rule_ctx.recovery_error){ + *reason="recovery_required";ret=ESP_ERR_INVALID_STATE;goto done; + } + if(!ts_rule_store_set_available()){*reason="source_read_only";ret=ESP_ERR_INVALID_STATE;goto done;} + ts_rule_saved_info_t old={0}; + bool exists=ts_rule_store_set_info(rule.id,&old)==ESP_OK; + if(!exists&&ts_rule_store_set_count()>=s_rule_ctx.capacity){*reason="capacity";ret=ESP_ERR_NO_MEM;goto done;} + xSemaphoreTake(s_rule_ctx.mutex,portMAX_DELAY); + int active=find_rule_index(rule.id); + xSemaphoreGive(s_rule_ctx.mutex); + if(active>=0&&protected_bindings(&s_rule_ctx.rules[active],&rule)){ + *reason="service_busy";ret=ESP_ERR_INVALID_STATE;goto done; + } + uint32_t saved_generation=ts_rule_store_set_generation(); + *data=cJSON_CreateObject(); + if(!*data){*reason="no_memory";ret=ESP_ERR_NO_MEM;goto done;} + cJSON *rule_json=ts_rule_encode(&rule); + bool reply_ok=rule_json&&cJSON_AddStringToObject(*data,"id",rule.id)&&cJSON_AddStringToObject(*data,"name",rule.name)&& + cJSON_AddStringToObject(*data,"type","automation_rule")&&cJSON_AddBoolToObject(*data,"valid",true)&& + cJSON_AddBoolToObject(*data,"trusted",true)&&cJSON_AddBoolToObject(*data,"target_matches",true)&& + cJSON_AddBoolToObject(*data,"exists",exists)&&cJSON_AddBoolToObject(*data,"official",true)&& + cJSON_AddStringToObject(*data,"signer",acceptance.signer_sha256)&& + cJSON_AddStringToObject(*data,"package_digest",acceptance.package_sha256)&& + cJSON_AddNumberToObject(*data,"expected_revision",exists?old.revision:0)&& + cJSON_AddNumberToObject(*data,"expected_generation",saved_generation)&& + cJSON_AddNumberToObject(*data,"credential_generation",credential)&&cJSON_AddItemToObject(*data,"rule",rule_json); + if(reply_ok)rule_json=NULL; + cJSON_Delete(rule_json); + if(reply_ok&&warnings){reply_ok=cJSON_AddItemToObject(*data,"warnings",warnings);if(reply_ok)warnings=NULL;} + if(!reply_ok){cJSON_Delete(*data);*data=NULL;*reason="no_memory";ret=ESP_ERR_NO_MEM;goto done;} + if(exists){ + /* Old saved identity is distinct from the selected package's digest. + * Never emit duplicate JSON keys: C and browser parsers choose differently. */ + cJSON_AddStringToObject(*data,"current_saved_digest",old.digest); + cJSON_AddStringToObject(*data,"saved_source",old.source==TS_RULE_SET_SD_PACK?"encrypted_pack":old.source==TS_RULE_SET_SD_JSON?"sd_json":"nvs_json"); + cJSON_AddBoolToObject(*data,"saved_readonly",old.readonly); + } + cJSON_AddNumberToObject(*data,"active_revision",active<0?0:s_rule_ctx.rules[active].revision); + cJSON_AddBoolToObject(*data,"runtime_active",active>=0); + if(preview){*reason="ok";goto done;} + bool identical=exists&&old.source==TS_RULE_SET_SD_PACK&&!strcmp(old.digest,acceptance.package_sha256); + if(exists&&!overwrite&&!identical){*reason="overwrite_required";ret=ESP_ERR_INVALID_STATE;goto done;} + if(!identical&&(expected_revision!=(exists?old.revision:0)||expected_generation!=saved_generation)){ + *reason="revision_conflict";ret=ESP_ERR_INVALID_STATE;goto done; + } + if(!ts_cert_material_begin(credential)){*reason="credential_changed";ret=ESP_ERR_INVALID_STATE;goto done;} + ts_rule_commit_t commit; + ret=ts_rule_store_set_commit(&rule,rule.id,identical?old.revision:expected_revision, + identical?saved_generation:expected_generation, + bytes,length,&acceptance,&commit); + ts_cert_material_end(); + *reason=commit.error_code; + if(commit.applied<0){s_rule_ctx.recovery_error=true;cJSON_AddBoolToObject(*data,"result_unknown",true);} + if(ret==ESP_OK){ + cJSON_AddBoolToObject(*data,"imported",true);cJSON_AddBoolToObject(*data,"saved",true); + cJSON_AddBoolToObject(*data,"durable",true);cJSON_AddBoolToObject(*data,"runtime_applied",false); + cJSON_AddBoolToObject(*data,"restart_required",active<0||s_rule_ctx.rules[active].revision!=commit.revision); + cJSON_AddNumberToObject(*data,"saved_revision",commit.revision); + cJSON_AddNumberToObject(*data,"saved_generation",ts_rule_store_set_generation()); + cJSON_AddBoolToObject(*data,"cleanup_pending",!commit.mirror_synced); + } +done: + cJSON_Delete(warnings);ts_rule_dispose(&rule); + xSemaphoreGiveRecursive(s_rule_ctx.transaction);ts_ssh_binding_unlock(); + return ret; +} + +static esp_err_t dependency_change_rule(const ts_auto_rule_t *rule,ts_rule_dependency_t kind, + const char *id,const void *next) { + for(unsigned i=0;iaction_count;++i){ + const ts_auto_action_t *a=&rule->actions[i]; + ts_action_template_t *tpl=NULL; + if(a->template_id[0]){ + if(kind==TS_RULE_DEP_TEMPLATE&&(!id||!strcmp(id,a->template_id))){ + if(!next)return ESP_ERR_INVALID_STATE; + const ts_action_template_t *proposed=next; + if(rule->enabled&&!proposed->enabled)return ESP_ERR_INVALID_STATE; + ts_auto_rule_t check=*rule;check.actions=(ts_auto_action_t *)&proposed->action;check.action_count=1; + const char *reason=NULL; + esp_err_t e=ts_rule_pack_dependencies(&check,&reason,NULL); + if(e!=ESP_OK)return e; + } + tpl=heap_caps_calloc(1,sizeof(*tpl),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!tpl)return ESP_ERR_NO_MEM; + if(ts_action_template_get(a->template_id,tpl)!=ESP_OK){free(tpl);return ESP_ERR_INVALID_STATE;} + a=&tpl->action; + } + if(a->type==TS_AUTO_ACT_SSH_CMD_REF){ + if(kind==TS_RULE_DEP_COMMAND&&(!id||!strcmp(id,a->ssh_ref.cmd_id))){ + if(!next){free(tpl);return ESP_ERR_INVALID_STATE;} + const ts_ssh_command_config_t *cmd=next;ts_action_ssh_host_t host; + if(ts_action_get_ssh_host(cmd->host_id,&host)!=ESP_OK || + !host.host[0]||!host.username[0]||!host.port || + (rule->enabled&&!cmd->enabled)){free(tpl);return ESP_ERR_INVALID_STATE;} + } + if(kind==TS_RULE_DEP_HOST||kind==TS_RULE_DEP_ACTION_HOST){ + ts_ssh_command_config_t *cmd=heap_caps_calloc(1,sizeof(*cmd),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!cmd){free(tpl);return ESP_ERR_NO_MEM;} + esp_err_t got=ts_ssh_commands_config_get(a->ssh_ref.cmd_id,cmd); + bool matches=got==ESP_OK&&(!id||!strcmp(cmd->host_id,id)); + if(got!=ESP_OK){free(cmd);free(tpl);return got;} + if(matches){ + ts_action_ssh_host_t actual;bool internal=false; + got=ts_action_get_ssh_host_ex(cmd->host_id,&actual,&internal); + if(got!=ESP_OK){free(cmd);free(tpl);return got;} + if(kind==TS_RULE_DEP_HOST&&!internal){ + const ts_ssh_host_config_t *proposed=next; + if(!proposed||!proposed->host[0]||!proposed->username[0]||!proposed->port){free(cmd);free(tpl);return ESP_ERR_INVALID_STATE;} + }else if(kind==TS_RULE_DEP_ACTION_HOST){ + const ts_action_ssh_host_t *proposed=next; + ts_ssh_host_config_t fallback; + if((proposed&&(!proposed->host[0]||!proposed->username[0]||!proposed->port)) || + (!proposed&&ts_ssh_hosts_config_get(cmd->host_id,&fallback)!=ESP_OK)){ + free(cmd);free(tpl);return ESP_ERR_INVALID_STATE; + } + } + } + free(cmd); + } + } + free(tpl); + } + return ESP_OK; +} +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next) { + if(!s_rule_ctx.initialized||!s_rule_ctx.loaded)return ESP_OK; + xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); + esp_err_t ret=ESP_OK; + if(s_rule_ctx.recovery_error)ret=ESP_ERR_INVALID_STATE; + for(int i=0;i +#include + +const char *ts_rule_pack_error(ts_config_pack_result_t error) { + switch(error){ + case TS_CONFIG_PACK_ERR_TIME_UNVERIFIED:return "time_unverified"; + case TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED:return "signing_trust_unconfigured"; + case TS_CONFIG_PACK_ERR_CERT_CHAIN:return "signer_untrusted"; + case TS_CONFIG_PACK_ERR_SIGNER_ROLE:return "signer_unauthorized"; + case TS_CONFIG_PACK_ERR_RECIPIENT:return "wrong_device"; + case TS_CONFIG_PACK_ERR_SIGNATURE:return "signature_invalid"; + case TS_CONFIG_PACK_ERR_CREDENTIAL_CHANGED:return "credential_changed"; + case TS_CONFIG_PACK_ERR_NO_MEM:return "no_memory"; + default:return "invalid_pack"; + } +} + +esp_err_t ts_rule_pack_decode(const char *text, size_t length, ts_auto_rule_t *rule) { + cJSON *j = ts_config_pack_parse_json(text, length); + const cJSON *type = cJSON_GetObjectItemCaseSensitive(j, "type"); + esp_err_t ret = cJSON_IsString(type) && !strcmp(type->valuestring, "automation_rule") + ? ts_rule_decode(cJSON_GetObjectItemCaseSensitive(j, "rule"), rule) : ESP_ERR_INVALID_ARG; + cJSON_Delete(j); + return ret; +} +esp_err_t ts_rule_pack_dependencies(const ts_auto_rule_t *rule, const char **reason, cJSON **warnings) { + *reason = "invalid_configuration"; + if (warnings) *warnings = NULL; + bool disabled_warning=false, dynamic_warning=rule->conditions.count!=0; + for (unsigned i = 0; i < rule->action_count; ++i) { + const ts_auto_action_t *a = &rule->actions[i]; + dynamic_warning |= a->condition.has_condition; + ts_action_template_t *tpl = NULL; + if (a->template_id[0]) { + if (!ts_action_templates_ready()) { *reason = "dependency_loading"; return ESP_ERR_INVALID_STATE; } + tpl = heap_caps_calloc(1, sizeof(*tpl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!tpl) { *reason = "no_memory"; return ESP_ERR_NO_MEM; } + esp_err_t got = ts_action_template_get(a->template_id, tpl); + if (got != ESP_OK) { free(tpl); *reason = "dependency_missing"; return got; } + if (rule->enabled && !tpl->enabled) { free(tpl); *reason = "dependency_disabled"; return ESP_ERR_INVALID_STATE; } + disabled_warning |= !tpl->enabled; + a = &tpl->action; + } + if (a->type == TS_AUTO_ACT_SSH_CMD_REF) { + esp_err_t commands=ts_ssh_commands_config_load_state(); + if (commands!=ESP_OK) { + free(tpl); *reason=commands==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; + return ESP_ERR_INVALID_STATE; + } + ts_ssh_command_config_t *cmd = heap_caps_calloc(1, sizeof(*cmd), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + ts_action_ssh_host_t host; + if (!cmd) { free(tpl); *reason = "no_memory"; return ESP_ERR_NO_MEM; } + esp_err_t got = ts_ssh_commands_config_get(a->ssh_ref.cmd_id, cmd); + bool internal=false; + if (got == ESP_OK) got = ts_action_get_ssh_host_ex(cmd->host_id, &host,&internal); + esp_err_t hosts=ts_ssh_hosts_config_load_state(); + if (!internal && hosts!=ESP_OK) { + free(cmd);free(tpl);*reason=hosts==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; + return ESP_ERR_INVALID_STATE; + } + if (got == ESP_OK && (!host.host[0] || !host.username[0] || !host.port)) got=ESP_ERR_INVALID_ARG; + bool disabled = got == ESP_OK && !cmd->enabled; + disabled_warning |= disabled; + free(cmd); free(tpl); + if (got != ESP_OK) { *reason = "dependency_missing"; return got; } + if (rule->enabled && disabled) { *reason = "dependency_disabled"; return ESP_ERR_INVALID_STATE; } + } else free(tpl); + } + /* Dynamic variables are not a static existence proof. Their values remain runtime inputs. */ + if (warnings && (dynamic_warning || disabled_warning)) { + *warnings = cJSON_CreateArray(); + if (!*warnings || (dynamic_warning && !cJSON_AddItemToArray(*warnings,cJSON_CreateString("dynamic_inputs"))) || + (disabled_warning && !cJSON_AddItemToArray(*warnings,cJSON_CreateString("dependency_disabled")))) { + cJSON_Delete(*warnings); *warnings = NULL; *reason = "no_memory"; return ESP_ERR_NO_MEM; + } + } + *reason = "ok"; + return ESP_OK; +} diff --git a/components/ts_automation/src/ts_rule_store_set.c b/components/ts_automation/src/ts_rule_store_set.c new file mode 100644 index 0000000..4bb69bd --- /dev/null +++ b/components/ts_automation/src/ts_rule_store_set.c @@ -0,0 +1,474 @@ +/* Versioned complete sets in the existing rule_stage namespace. Legacy store is + * recovered first; its guard/banks are not modified by this format migration. */ +#include "ts_rule_store_set.h" +#include "ts_rule_pack.h" +#include "ts_crypto.h" +#include "esp_heap_caps.h" +#include "nvs.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef DIR_RULES +#define DIR_RULES "/sdcard/config/rules" +#endif +#ifndef CONFIG_TS_AUTOMATION_MAX_RULES +#define CONFIG_TS_AUTOMATION_MAX_RULES 32 +#endif +#define SET_NAMESPACE "rule_stage" +#define SET_MAGIC 0x52534c33u +#define SET_VERSION 3 +#define OBJECT_DIR DIR_RULES "/.rule_store" +#define RECORD_LIMIT 262144u +typedef struct { + uint32_t magic, version, length, generation, bank, count, aggregate, checksum; +} set_selector_t; +static set_selector_t selected; +static cJSON *saved; +static bool set_loaded, set_blocked, legacy_readonly; +static bool set_sd; +static const char *load_error="config_loading"; +static bool cleanup_objects(void); + +static uint32_t bytes_hash(uint32_t h, const void *p, size_t n) { + const unsigned char *b = p; + while (n--) h = (h ^ *b++) * 16777619u; + return h; +} +static bool digest_text(const char *text, size_t length, char out[65]) { + unsigned char digest[32]; + return ts_crypto_hash(TS_HASH_SHA256, text, length, digest, sizeof digest) == ESP_OK && + ts_crypto_hex_encode(digest, sizeof digest, out, 65) == ESP_OK; +} +static bool selector_valid(const set_selector_t *s) { + return s->magic == SET_MAGIC && s->version == SET_VERSION && s->length == sizeof(*s) && + s->generation && s->bank <= 1 && s->count <= CONFIG_TS_AUTOMATION_MAX_RULES && + s->checksum == bytes_hash(2166136261u, s, offsetof(set_selector_t, checksum)); +} +static esp_err_t read_blob(const char *key, void *out, size_t size) { + nvs_handle_t h; + esp_err_t e = nvs_open(SET_NAMESPACE, NVS_READONLY, &h); + if (e != ESP_OK) return e; + size_t n = size; + e = nvs_get_blob(h, key, out, &n); + nvs_close(h); + return e == ESP_OK && n != size ? ESP_ERR_INVALID_SIZE : e; +} +static esp_err_t write_blob(const char *key, const void *bytes, size_t length) { + nvs_handle_t h; + esp_err_t e = nvs_open(SET_NAMESPACE, NVS_READWRITE, &h); + if (e != ESP_OK) return e; + e = nvs_set_blob(h, key, bytes, length); + if (e == ESP_OK) e = nvs_commit(h); + nvs_close(h); + return e; +} +static char *read_record(unsigned bank, unsigned index) { + char key[16]; snprintf(key, sizeof key, "set_%c%u", bank ? 'b' : 'a', index); + nvs_handle_t h; + if (nvs_open(SET_NAMESPACE, NVS_READONLY, &h) != ESP_OK) return NULL; + size_t n = 0; + char *text = NULL; + if (nvs_get_blob(h, key, NULL, &n) == ESP_OK && n >= 2 && n <= RECORD_LIMIT) + text = heap_caps_malloc(n, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (text && (nvs_get_blob(h, key, text, &n) != ESP_OK || text[n-1] || strlen(text) != n-1)) { + free(text); text = NULL; + } + nvs_close(h); + return text; +} +static const char *string_field(const cJSON *j, const char *key) { + const cJSON *v = cJSON_GetObjectItemCaseSensitive(j, key); + return cJSON_IsString(v) ? v->valuestring : NULL; +} +static bool number_field(const cJSON *j, const char *key, uint32_t *out) { + const cJSON *v = cJSON_GetObjectItemCaseSensitive(j, key); + if (!cJSON_IsNumber(v) || v->valuedouble < 0 || v->valuedouble > UINT32_MAX || + v->valuedouble != (uint32_t)v->valuedouble) return false; + *out = (uint32_t)v->valuedouble; return true; +} +static cJSON *find_record(const cJSON *array, const char *id) { + cJSON *r; + cJSON_ArrayForEach(r, array) { + const char *name = string_field(r, "id"); + if (name && !strcmp(name, id)) return r; + } + return NULL; +} +static bool record_info(const cJSON *r, ts_rule_saved_info_t *info) { + const char *id = string_field(r, "id"), *digest = string_field(r, "digest"); + uint32_t source; + if (!id || !ts_rule_id_valid(id) || !digest || strlen(digest) != 64 || + !number_field(r, "revision", &info->revision) || !info->revision || + !number_field(r, "source", &source) || source > TS_RULE_SET_SD_PACK || + !cJSON_IsBool(cJSON_GetObjectItemCaseSensitive(r, "readonly"))) return false; + strcpy(info->id, id); strcpy(info->digest, digest); + info->source = source; info->generation = selected.generation; + info->readonly = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(r, "readonly")); + const char *name=string_field(r,"name"), *icon=string_field(r,"icon"); + if(!name||strlen(name)>=sizeof(info->name)||!icon||strlen(icon)>=sizeof(info->icon)|| + !number_field(r,"conditions_count",&info->conditions_count)|| + !number_field(r,"actions_count",&info->actions_count))return false; + strcpy(info->name,name);strcpy(info->icon,icon); + info->enabled=cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(r,"enabled")); + return true; +} +static bool acceptance_read(const cJSON *r, ts_config_pack_acceptance_t *a) { + const cJSON *j = cJSON_GetObjectItemCaseSensitive(r, "acceptance"); + const char *package = string_field(j,"package"), *signer = string_field(j,"signer"), + *root = string_field(j,"root"), *recipient = string_field(j,"recipient"); + const cJSON *when = cJSON_GetObjectItemCaseSensitive(j,"accepted_at"); + if (!number_field(j,"policy",&a->policy) || !a->policy || !cJSON_IsNumber(when) || + when->valuedouble <= 0 || when->valuedouble > 9007199254740991.0 || + when->valuedouble != (int64_t)when->valuedouble || !package || !signer || !root || !recipient || + strlen(package)!=64 || strlen(signer)!=64 || strlen(root)!=64 || strlen(recipient)!=64) return false; + a->accepted_at=(int64_t)when->valuedouble; + strcpy(a->package_sha256,package);strcpy(a->signer_sha256,signer); + strcpy(a->root_sha256,root);strcpy(a->recipient,recipient);return true; +} +static cJSON *acceptance_json(const ts_config_pack_acceptance_t *a) { + cJSON *j=cJSON_CreateObject(); + if (!j || !cJSON_AddNumberToObject(j,"policy",a->policy) || + !cJSON_AddNumberToObject(j,"accepted_at",a->accepted_at) || + !cJSON_AddStringToObject(j,"package",a->package_sha256) || + !cJSON_AddStringToObject(j,"signer",a->signer_sha256) || + !cJSON_AddStringToObject(j,"root",a->root_sha256) || + !cJSON_AddStringToObject(j,"recipient",a->recipient)) { cJSON_Delete(j);return NULL; } + return j; +} +static esp_err_t object_read(const cJSON *r, char **text, size_t *length) { + const char *path=string_field(r,"path"), *expected=string_field(r,"digest"); + uint32_t n; + *text=NULL; + if (!path || !expected || !number_field(r,"length",&n) || !n || n>RECORD_LIMIT) + return ESP_ERR_INVALID_ARG; + FILE *f=fopen(path,"rb"); if(!f)return ESP_ERR_NOT_FOUND; + bool ok=fseek(f,0,SEEK_END)==0 && ftell(f)==n && fseek(f,0,SEEK_SET)==0; + char *s=ok?heap_caps_malloc(n+1,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT):NULL; + ok=s && fread(s,1,n,f)==n && !ferror(f); + if(fclose(f))ok=false; + char digest[65]; + if(ok){s[n]=0;ok=digest_text(s,n,digest)&&!strcmp(digest,expected);} + if(!ok){free(s);return ESP_FAIL;} + *text=s;*length=n;return ESP_OK; +} +static esp_err_t decode_record(const cJSON *r, ts_auto_rule_t *rule, ts_rule_saved_info_t *info) { + memset(info,0,sizeof(*info)); + if(!record_info(r,info))return ESP_ERR_INVALID_ARG; + esp_err_t e; + if(info->source==TS_RULE_SET_SD_PACK){ + char *text=NULL;size_t length=0; + e=object_read(r,&text,&length); + if(e!=ESP_OK)return e; + ts_config_pack_acceptance_t a={0};ts_config_pack_t *pack=NULL; + if(!acceptance_read(r,&a)||strcmp(a.package_sha256,info->digest)){free(text);return ESP_ERR_INVALID_ARG;} + ts_config_pack_result_t got=ts_config_pack_load_verified_mem(text,length,&a,&pack,NULL,NULL); + free(text); + if(got!=TS_CONFIG_PACK_OK){load_error=ts_rule_pack_error(got);return ESP_ERR_INVALID_STATE;} + e=ts_rule_pack_decode(pack->content,pack->content_len,rule);ts_config_pack_free(pack); + }else{ + const cJSON *j=cJSON_GetObjectItemCaseSensitive(r,"json"); + char *text=cJSON_PrintUnformatted(j), digest[65]; + bool ok=text&&digest_text(text,strlen(text),digest)&&!strcmp(digest,info->digest); + free(text); + if(!ok)return ESP_FAIL; + if(info->source==TS_RULE_SET_SD_JSON && set_sd && string_field(r,"path")) { + char *bytes=NULL;size_t n=0; + e=object_read(r,&bytes,&n);free(bytes); + if(e!=ESP_OK)return e; + } + e=ts_rule_decode(j,rule); + } + if(e==ESP_OK&&strcmp(rule->id,info->id)){ts_rule_dispose(rule);return ESP_ERR_INVALID_ARG;} + if(e==ESP_OK)rule->revision=info->revision; + return e; +} +void ts_rule_store_set_reset(void){cJSON_Delete(saved);saved=NULL;memset(&selected,0,sizeof selected);set_loaded=set_blocked=legacy_readonly=set_sd=false;load_error="recovery_required";} +const char *ts_rule_store_set_error(void){return load_error;} +bool ts_rule_store_set_available(void){return set_loaded&&!set_blocked&&!legacy_readonly;} +bool ts_rule_store_set_present(void){return set_loaded||set_blocked;} +int ts_rule_store_set_count(void){return cJSON_GetArraySize(saved);} +uint32_t ts_rule_store_set_generation(void){return selected.generation;} +esp_err_t ts_rule_store_set_info(const char *id, ts_rule_saved_info_t *info){ + cJSON *r=find_record(saved,id);return !r?ESP_ERR_NOT_FOUND:record_info(r,info)?ESP_OK:ESP_FAIL; +} +esp_err_t ts_rule_store_set_info_at(int index,ts_rule_saved_info_t *info){ + const cJSON *r=cJSON_GetArrayItem(saved,index); + return !r?ESP_ERR_NOT_FOUND:record_info(r,info)?ESP_OK:ESP_FAIL; +} +esp_err_t ts_rule_store_set_acceptance(const char *bytes,size_t length,ts_config_pack_acceptance_t *out){ + if(!ts_rule_store_set_available())return ESP_ERR_NOT_FOUND; + char digest[65];if(!digest_text(bytes,length,digest))return ESP_FAIL; + const cJSON *r; + cJSON_ArrayForEach(r,saved){ts_rule_saved_info_t info={0}; + if(record_info(r,&info)&&info.source==TS_RULE_SET_SD_PACK&&!strcmp(info.digest,digest)) + return acceptance_read(r,out)?ESP_OK:ESP_FAIL; + } + return ESP_ERR_NOT_FOUND; +} +esp_err_t ts_rule_store_set_get(int index,ts_auto_rule_t *rule,ts_rule_saved_info_t *info){ + const cJSON *r=cJSON_GetArrayItem(saved,index);return r?decode_record(r,rule,info):ESP_ERR_NOT_FOUND; +} +static esp_err_t load_saved_set(bool sd,ts_auto_rule_t *rules,bool *readonly,int capacity,int *count,bool metadata_only){ + ts_rule_store_set_reset();set_sd=sd;*count=0; + esp_err_t e=read_blob("set_select",&selected,sizeof selected); + if(e==ESP_ERR_NVS_NOT_FOUND)return ESP_ERR_NOT_FOUND; + if(e!=ESP_OK||!selector_valid(&selected)){set_blocked=true;return ESP_ERR_INVALID_STATE;} + char key[16];snprintf(key,sizeof key,"set_meta%u",(unsigned)selected.bank); + set_selector_t meta; + if(read_blob(key,&meta,sizeof meta)!=ESP_OK||memcmp(&meta,&selected,sizeof meta)||selected.count>(unsigned)capacity){set_blocked=true;return ESP_FAIL;} + saved=cJSON_CreateArray();if(!saved)return ESP_ERR_NO_MEM; + uint32_t aggregate=2166136261u; + for(unsigned i=0;ipackage_sha256)))&& + cJSON_AddStringToObject(r,"id",rule->id)&&cJSON_AddNumberToObject(r,"revision",rule->revision)&& + cJSON_AddNumberToObject(r,"source",source)&&cJSON_AddBoolToObject(r,"readonly",readonly)&& + cJSON_AddNumberToObject(r,"length",length)&&cJSON_AddStringToObject(r,"digest",digest); + ok=ok&&cJSON_AddStringToObject(r,"name",rule->name)&&cJSON_AddStringToObject(r,"icon",rule->icon)&& + cJSON_AddBoolToObject(r,"enabled",rule->enabled)&& + cJSON_AddNumberToObject(r,"conditions_count",rule->conditions.count)&& + cJSON_AddNumberToObject(r,"actions_count",rule->action_count); + if(ok&&json){ok=cJSON_AddItemToObject(r,"json",json);if(ok)json=NULL;} + if(ok&&source==TS_RULE_SET_SD_PACK){ + cJSON *a=accepted?acceptance_json(accepted):NULL; + ok=path&&a&&cJSON_AddStringToObject(r,"path",path)&&cJSON_AddItemToObject(r,"acceptance",a); + if(!ok)cJSON_Delete(a); + } + free(text);cJSON_Delete(json); + if(!ok){cJSON_Delete(r);return NULL;}return r; +} +esp_err_t ts_rule_store_set_seed(const ts_auto_rule_t *rule,const char *path,bool readonly, + const ts_config_pack_acceptance_t *accepted){ + if(!saved)saved=cJSON_CreateArray(); + if(!saved)return ESP_ERR_NO_MEM; + char *text=NULL;size_t length=0; + if(accepted){FILE *f=fopen(path,"rb");if(!f)return ESP_FAIL; + bool ok=fseek(f,0,SEEK_END)==0;long n=ok?ftell(f):-1;ok=n>0&&n<=RECORD_LIMIT&&fseek(f,0,SEEK_SET)==0; + text=ok?heap_caps_malloc(n+1,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT):NULL; + ok=text&&fread(text,1,n,f)==(size_t)n&&!ferror(f);if(fclose(f))ok=false; + if(!ok){free(text);return ESP_FAIL;}text[n]=0;length=n; + } + cJSON *r=new_record(rule,accepted?TS_RULE_SET_SD_PACK:TS_RULE_SET_SD_JSON,readonly,path,text,length,accepted); + free(text); + if(!r)return ESP_ERR_NO_MEM; + if(find_record(saved,rule->id)||!cJSON_AddItemToArray(saved,r)){cJSON_Delete(r);return ESP_ERR_INVALID_ARG;} + return ESP_OK; +} +esp_err_t ts_rule_store_set_adopt(const ts_auto_rule_t *rules,int count,int source,const bool *readonly){ + if(set_blocked)return ESP_ERR_INVALID_STATE; + if(!saved)saved=cJSON_CreateArray(); + if(!saved)return ESP_ERR_NO_MEM; + if(cJSON_GetArraySize(saved)&&cJSON_GetArraySize(saved)!=count)return ESP_ERR_INVALID_STATE; + if(!cJSON_GetArraySize(saved))for(int i=0;id_name);if(n!=69||strcmp(entry->d_name+64,".pack"))continue; + bool owned=true;for(unsigned i=0;i<64;++i)owned&=(entry->d_name[i]>='0'&&entry->d_name[i]<='9')||(entry->d_name[i]>='a'&&entry->d_name[i]<='f'); + if(!owned)continue; + char path[sizeof(OBJECT_DIR)+72];snprintf(path,sizeof path,OBJECT_DIR "/%.69s",entry->d_name); + if(!object_referenced(saved,path)&&unlink(path)&&errno!=ENOENT)ok=false; + } + closedir(dir);return ok; +} +static esp_err_t prepare_object(const char *bytes,size_t length,char *path,size_t cap){ + char digest[65];if(!digest_text(bytes,length,digest))return ESP_FAIL; + char *parents=strdup(DIR_RULES); + if(!parents)return ESP_ERR_NO_MEM; + for(char *p=parents+1;*p;++p)if(*p=='/'){ + *p=0;bool ok=mkdir(parents,0755)==0||errno==EEXIST;*p='/'; + if(!ok){free(parents);return ESP_FAIL;} + } + free(parents); + if(mkdir(DIR_RULES,0755)&&errno!=EEXIST)return ESP_FAIL; + if(mkdir(OBJECT_DIR,0755)&&errno!=EEXIST)return ESP_FAIL; + if(snprintf(path,cap,OBJECT_DIR "/%s.pack",digest)>=(int)cap)return ESP_ERR_INVALID_SIZE; + struct stat st; + if(stat(path,&st)==0){ + cJSON *r=cJSON_CreateObject();cJSON_AddStringToObject(r,"path",path);cJSON_AddStringToObject(r,"digest",digest);cJSON_AddNumberToObject(r,"length",length); + char *old=NULL;size_t n=0;esp_err_t e=object_read(r,&old,&n);cJSON_Delete(r);free(old); + if(e==ESP_OK)return ESP_OK; + if(object_referenced(saved,path)||unlink(path))return ESP_FAIL; + }else if(errno!=ENOENT)return ESP_FAIL; + FILE *f=fopen(path,"wb");if(!f)return ESP_FAIL; + bool ok=fwrite(bytes,1,length,f)==length&&!ferror(f); + if(fflush(f))ok=false; + if(fsync(fileno(f)))ok=false; + if(fclose(f))ok=false; + if(!ok)return ESP_FAIL; + cJSON *r=cJSON_CreateObject();cJSON_AddStringToObject(r,"path",path);cJSON_AddStringToObject(r,"digest",digest);cJSON_AddNumberToObject(r,"length",length); + char *check=NULL;size_t n=0;esp_err_t e=object_read(r,&check,&n);cJSON_Delete(r);free(check);return e; +} +static esp_err_t prepare_sources(cJSON *next) { + cJSON *r; + cJSON_ArrayForEach(r,next) { + ts_rule_saved_info_t info={0}; + if(!record_info(r,&info))return ESP_ERR_INVALID_ARG; + if(info.source==TS_RULE_SET_SD_JSON) { + char *text=cJSON_PrintUnformatted(cJSON_GetObjectItemCaseSensitive(r,"json")); + if(!text)return ESP_ERR_NO_MEM; + char path[sizeof(OBJECT_DIR)+72]; + esp_err_t e=prepare_object(text,strlen(text),path,sizeof path);free(text); + if(e!=ESP_OK)return e; + cJSON_DeleteItemFromObjectCaseSensitive(r,"path"); + if(!cJSON_AddStringToObject(r,"path",path))return ESP_ERR_NO_MEM; + } else if(info.source==TS_RULE_SET_SD_PACK) { + char *bytes=NULL;size_t n=0; + esp_err_t e=object_read(r,&bytes,&n); + char path[sizeof(OBJECT_DIR)+72]; + if(e==ESP_OK)e=prepare_object(bytes,n,path,sizeof path); + free(bytes); + if(e!=ESP_OK)return e; + cJSON_DeleteItemFromObjectCaseSensitive(r,"path"); + if(!cJSON_AddStringToObject(r,"path",path))return ESP_ERR_NO_MEM; + } + } + return ESP_OK; +} +static esp_err_t commit_set(cJSON *next,uint32_t revision,ts_rule_commit_t *result){ + const cJSON *item; + esp_err_t prepared=prepare_sources(next); + if(prepared!=ESP_OK){cJSON_Delete(next);return prepared;} + set_selector_t target={.magic=SET_MAGIC,.version=SET_VERSION,.length=sizeof target, + .generation=selected.generation+1,.bank=selected.magic?1-selected.bank:0, + .count=cJSON_GetArraySize(next),.aggregate=2166136261u}; + unsigned i=0;esp_err_t e=ESP_OK; + cJSON_ArrayForEach(item,next){ + char *text=cJSON_PrintUnformatted(item);char key[16];snprintf(key,sizeof key,"set_%c%u",target.bank?'b':'a',i++); + if(!text){e=ESP_ERR_NO_MEM;break;} + size_t n=strlen(text)+1; + if(n>RECORD_LIMIT||write_blob(key,text,n)!=ESP_OK){free(text);e=ESP_FAIL;break;} + char *check=read_record(target.bank,i-1);bool ok=check&&!strcmp(check,text); + target.aggregate=bytes_hash(target.aggregate,text,n);free(check);free(text); + if(!ok){e=ESP_FAIL;break;} + } + target.checksum=bytes_hash(2166136261u,&target,offsetof(set_selector_t,checksum)); + char key[16];snprintf(key,sizeof key,"set_meta%u",(unsigned)target.bank); + if(e==ESP_OK)e=write_blob(key,&target,sizeof target); + set_selector_t check={0}; + if(e==ESP_OK&&(read_blob(key,&check,sizeof check)!=ESP_OK||memcmp(&check,&target,sizeof check)))e=ESP_FAIL; + if(e!=ESP_OK){cJSON_Delete(next);return e;} + esp_err_t write=write_blob("set_select",&target,sizeof target); + esp_err_t read=read_blob("set_select",&check,sizeof check); + if(read==ESP_OK&&!memcmp(&check,&target,sizeof check)){ + selected=target;cJSON_Delete(saved);saved=next; + result->applied=result->durable=1;result->revision=revision; + result->mirror_synced=cleanup_objects();result->error_code=result->mirror_synced?"ok":"cleanup_pending"; + return ESP_OK; + } + bool old=(read==ESP_OK&&!memcmp(&check,&selected,sizeof check)) || + (read==ESP_ERR_NVS_NOT_FOUND&&!selected.magic); + cJSON_Delete(next); + if(old)return write==ESP_OK?ESP_FAIL:write; + set_blocked=true;result->applied=result->durable=-1;result->error_code="commit_unknown"; + return ESP_ERR_INVALID_STATE; +} + +esp_err_t ts_rule_store_set_migrate(void){ + if(selected.magic||legacy_readonly)return ESP_OK; + if(!ts_rule_store_set_available())return ESP_ERR_INVALID_STATE; + cJSON *next=cJSON_Duplicate(saved,true);if(!next)return ESP_ERR_NO_MEM; + ts_rule_commit_t result={.error_code="storage_failed"}; + return commit_set(next,0,&result); +} +esp_err_t ts_rule_store_set_commit(const ts_auto_rule_t *candidate,const char *id,uint32_t expected, + uint32_t expected_generation,const char *pack,size_t pack_len,const ts_config_pack_acceptance_t *accepted, + ts_rule_commit_t *result){ + *result=(ts_rule_commit_t){.error_code="storage_failed"}; + if(!ts_rule_store_set_available()){result->error_code=legacy_readonly?"source_read_only":"recovery_required";return ESP_ERR_INVALID_STATE;} + ts_rule_saved_info_t prior={0};esp_err_t found=ts_rule_store_set_info(id,&prior); + if(selected.generation!=expected_generation || (found==ESP_OK?prior.revision:0)!=expected){result->error_code="revision_conflict";return ESP_ERR_INVALID_STATE;} + if(!candidate&&found!=ESP_OK)return ESP_ERR_NOT_FOUND; + if(candidate&&found!=ESP_OK&&ts_rule_store_set_count()>=CONFIG_TS_AUTOMATION_MAX_RULES){result->error_code="capacity";return ESP_ERR_NO_MEM;} + if(selected.generation==UINT32_MAX||prior.revision==UINT32_MAX){result->error_code="revision_exhausted";return ESP_ERR_INVALID_STATE;} + char digest[65]; + if(pack&&(!candidate||!accepted||!digest_text(pack,pack_len,digest)||strcmp(digest,accepted->package_sha256)))return ESP_ERR_INVALID_ARG; + if(pack&&found==ESP_OK&&prior.source==TS_RULE_SET_SD_PACK&&!strcmp(prior.digest,digest)){ + char *verified=NULL;size_t n=0; + esp_err_t e=object_read(find_record(saved,id),&verified,&n);free(verified); + if(e!=ESP_OK){result->error_code="source_unavailable";return e;} + result->applied=result->durable=result->mirror_synced=1;result->revision=prior.revision;result->error_code="no_change";return ESP_OK; + } + if(!pack&&found==ESP_OK&&prior.readonly){result->error_code="source_read_only";return ESP_ERR_NOT_SUPPORTED;} + cJSON *next=cJSON_Duplicate(saved,true);if(!next)return ESP_ERR_NO_MEM; + char path[sizeof(OBJECT_DIR)+72]; + if(pack){esp_err_t e=prepare_object(pack,pack_len,path,sizeof path);if(e!=ESP_OK){cJSON_Delete(next);return e;}} + ts_auto_rule_t local={0};if(candidate){local=*candidate;local.revision=found==ESP_OK?prior.revision+1:1;} + ts_rule_set_source_t source=pack?TS_RULE_SET_SD_PACK: + found==ESP_OK&&prior.source==TS_RULE_SET_SD_JSON?TS_RULE_SET_SD_JSON:TS_RULE_SET_NVS_JSON; + cJSON *record=candidate?new_record(&local,source,pack!=NULL, + pack?path:NULL,pack,pack_len,accepted):NULL; + if(candidate&&!record){cJSON_Delete(next);return ESP_ERR_NO_MEM;} + int index=0;const cJSON *item;cJSON_ArrayForEach(item,next){const char *name=string_field(item,"id");if(name&&!strcmp(name,id))break;++index;} + if(found==ESP_OK){if(record){if(!cJSON_ReplaceItemInArray(next,index,record)){cJSON_Delete(record);cJSON_Delete(next);return ESP_ERR_NO_MEM;}}else cJSON_DeleteItemFromArray(next,index);} + else if(!cJSON_AddItemToArray(next,record)){cJSON_Delete(record);cJSON_Delete(next);return ESP_ERR_NO_MEM;} + return commit_set(next,local.revision,result); +} diff --git a/components/ts_automation/src/ts_rule_store_set.h b/components/ts_automation/src/ts_rule_store_set.h new file mode 100644 index 0000000..2c184e1 --- /dev/null +++ b/components/ts_automation/src/ts_rule_store_set.h @@ -0,0 +1,38 @@ +#pragma once +#include "ts_rule_store.h" +#include "ts_config_pack.h" + +typedef enum { TS_RULE_SET_NVS_JSON, TS_RULE_SET_SD_JSON, TS_RULE_SET_SD_PACK } ts_rule_set_source_t; +typedef struct { + char id[TS_AUTO_NAME_MAX_LEN], digest[65]; + uint32_t revision, generation; + ts_rule_set_source_t source; + bool readonly; + char name[TS_AUTO_LABEL_MAX_LEN], icon[sizeof(((ts_auto_rule_t *)0)->icon)]; + bool enabled; + uint32_t conditions_count, actions_count; +} ts_rule_saved_info_t; + +/* Same engine configuration transaction as legacy store. One selector, complete saved set. + * Legacy recovery/reading finishes before adopt; no write or migration during reads. */ +void ts_rule_store_set_reset(void); +esp_err_t ts_rule_store_set_load(bool sd, ts_auto_rule_t *rules, bool *readonly, int capacity, int *count); +esp_err_t ts_rule_store_set_refresh(bool sd); +const char *ts_rule_store_set_error(void); +esp_err_t ts_rule_store_set_seed(const ts_auto_rule_t *rule, const char *path, bool readonly, + const ts_config_pack_acceptance_t *accepted); +esp_err_t ts_rule_store_set_adopt(const ts_auto_rule_t *rules, int count, int legacy_source, + const bool *readonly); +/* Called only after legacy recovery and complete dependency validation at boot. */ +esp_err_t ts_rule_store_set_migrate(void); +bool ts_rule_store_set_available(void); +bool ts_rule_store_set_present(void); +int ts_rule_store_set_count(void); +uint32_t ts_rule_store_set_generation(void); +esp_err_t ts_rule_store_set_info(const char *id, ts_rule_saved_info_t *info); +esp_err_t ts_rule_store_set_info_at(int index, ts_rule_saved_info_t *info); +esp_err_t ts_rule_store_set_acceptance(const char *bytes,size_t length,ts_config_pack_acceptance_t *out); +esp_err_t ts_rule_store_set_get(int index, ts_auto_rule_t *rule, ts_rule_saved_info_t *info); +esp_err_t ts_rule_store_set_commit(const ts_auto_rule_t *candidate, const char *id, + uint32_t expected_revision, uint32_t expected_generation, const char *pack, size_t pack_len, + const ts_config_pack_acceptance_t *accepted, ts_rule_commit_t *result); diff --git a/components/ts_cert/include/ts_cert.h b/components/ts_cert/include/ts_cert.h index 7d45a26..16fc606 100644 --- a/components/ts_cert/include/ts_cert.h +++ b/components/ts_cert/include/ts_cert.h @@ -142,6 +142,10 @@ typedef struct { char certificate_sha256[65]; } ts_cert_snapshot_t; esp_err_t ts_cert_get_snapshot(bool require_ca, ts_cert_snapshot_t *snapshot); +/* Rule packs have their own accepted-at time policy; this does not relax TLS prerequisites. */ +esp_err_t ts_cert_get_pack_snapshot(ts_cert_snapshot_t *snapshot); +bool ts_cert_material_begin(uint32_t expected_generation); +void ts_cert_material_end(void); void ts_cert_free_snapshot(ts_cert_snapshot_t *snapshot); bool ts_cert_prerequisites(const ts_cert_pki_status_t *status, bool require_ca); const char *ts_cert_validity_to_str(ts_cert_validity_t validity); diff --git a/components/ts_cert/src/ts_cert.c b/components/ts_cert/src/ts_cert.c index 577d723..c5e4a9b 100644 --- a/components/ts_cert/src/ts_cert.c +++ b/components/ts_cert/src/ts_cert.c @@ -1290,3 +1290,37 @@ esp_err_t ts_cert_get_snapshot(bool require_ca, ts_cert_snapshot_t *snapshot) material_unlock(previous, 0); return result; } + +esp_err_t ts_cert_get_pack_snapshot(ts_cert_snapshot_t *snapshot) +{ + if (!snapshot || !s_mutex) return ESP_ERR_INVALID_ARG; + memset(snapshot, 0, sizeof(*snapshot)); + xSemaphoreTake(s_mutex, portMAX_DELAY); + ts_cert_pki_status_t status; + esp_err_t ret = ts_cert_get_status_locked(&status); + if (ret == ESP_OK && (status.storage_error || !status.key_valid || !status.key_matches || + !status.has_certificate || !status.ca_valid)) ret = ESP_ERR_INVALID_STATE; + if (ret == ESP_OK) { + snapshot->key = copy_pem(s_private_key_pem, strlen(s_private_key_pem) + 1); + snapshot->certificate = copy_pem(s_certificate_pem, strlen(s_certificate_pem) + 1); + snapshot->ca = copy_pem(s_ca_chain_pem, strlen(s_ca_chain_pem) + 1); + snapshot->generation = s_generation; + memcpy(snapshot->certificate_sha256, s_fingerprint, sizeof(s_fingerprint)); + if (!snapshot->key || !snapshot->certificate || !snapshot->ca) { + ts_cert_free_snapshot(snapshot); ret = ESP_ERR_NO_MEM; + } + } + xSemaphoreGive(s_mutex); + return ret; +} +/* Short configuration admission lease: acquire after certificate/crypto work. + * Never call TLS/material parsing while holding it. */ +bool ts_cert_material_begin(uint32_t expected_generation) +{ + if (!s_mutex) return false; + xSemaphoreTake(s_mutex, portMAX_DELAY); + if (s_generation == expected_generation) return true; + xSemaphoreGive(s_mutex); + return false; +} +void ts_cert_material_end(void) { xSemaphoreGive(s_mutex); } diff --git a/components/ts_config_pack/CMakeLists.txt b/components/ts_config_pack/CMakeLists.txt index 56d7304..db7c4cf 100644 --- a/components/ts_config_pack/CMakeLists.txt +++ b/components/ts_config_pack/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( SRCS "src/ts_config_pack.c" + "src/ts_config_pack_trust.c" INCLUDE_DIRS "include" REQUIRES @@ -10,4 +11,5 @@ idf_component_register( json PRIV_REQUIRES ts_storage + mbedtls ) diff --git a/components/ts_config_pack/Kconfig b/components/ts_config_pack/Kconfig index 4e9627f..a1133f7 100644 --- a/components/ts_config_pack/Kconfig +++ b/components/ts_config_pack/Kconfig @@ -20,6 +20,15 @@ menu "TianShanOS Config Pack" Verify the signer's certificate chain against the root CA. Disabling this is insecure and should only be done for testing. + config TS_CONFIG_PACK_SIGNING_ROOT_SHA256 + string "Explicit configuration signing root (DER SHA-256, lowercase hex)" + default "" + help + Select one configuration signing root from the device's installed CA + material. TLS trust alone does not authorize configuration signing. + An empty setting disables acceptance of new rule packs. The signer + also needs exact Developer OU and a compatible digital-signature usage. + config TS_CONFIG_PACK_REQUIRE_OFFICIAL_SIG bool "Require official signature for sensitive configs" default n diff --git a/components/ts_config_pack/include/ts_config_pack.h b/components/ts_config_pack/include/ts_config_pack.h index c90615f..e50ef0d 100644 --- a/components/ts_config_pack/include/ts_config_pack.h +++ b/components/ts_config_pack/include/ts_config_pack.h @@ -61,8 +61,24 @@ typedef enum { TS_CONFIG_PACK_ERR_PERMISSION, /**< Permission denied (not a developer device) */ TS_CONFIG_PACK_ERR_INVALID_ARG, /**< Invalid argument */ TS_CONFIG_PACK_ERR_NOT_INIT, /**< System not initialized */ + TS_CONFIG_PACK_ERR_TIME_UNVERIFIED, + TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED, + TS_CONFIG_PACK_ERR_SIGNER_ROLE, + TS_CONFIG_PACK_ERR_CREDENTIAL_CHANGED, } ts_config_pack_result_t; +/** Local acceptance of exact v1 package bytes; never constructed from package metadata. */ +typedef struct { + uint32_t policy; + int64_t accepted_at; + char package_sha256[65], signer_sha256[65], root_sha256[65], recipient[65]; +} ts_config_pack_acceptance_t; + +/** Bounded complete JSON, rejects duplicate keys and decoded NUL characters. */ +cJSON *ts_config_pack_parse_json(const char *text, size_t length); +cJSON *ts_config_pack_parse_import_request(const char *text, size_t length); + + /** * @brief Signature verification result */ @@ -88,6 +104,11 @@ typedef struct { char *target_device; /**< Target device CN */ } ts_config_pack_t; +/** Explicit signing trust, one credential snapshot, optional local acceptance for offline reload. */ +ts_config_pack_result_t ts_config_pack_load_verified_mem( + const char *text, size_t length, const ts_config_pack_acceptance_t *accepted, + ts_config_pack_t **pack, ts_config_pack_acceptance_t *acceptance, uint32_t *credential_generation); + /** * @brief Export options for creating a config pack */ diff --git a/components/ts_config_pack/src/ts_config_pack.c b/components/ts_config_pack/src/ts_config_pack.c index beb0619..10af8da 100644 --- a/components/ts_config_pack/src/ts_config_pack.c +++ b/components/ts_config_pack/src/ts_config_pack.c @@ -24,6 +24,7 @@ #include "ts_core.h" #include "ts_cert.h" #include "ts_crypto.h" +#include "ts_config_pack_trust.h" #include "cJSON.h" #include "esp_log.h" #include @@ -32,6 +33,53 @@ #include #include #include +#include +#include + +#ifndef CONFIG_TS_CONFIG_PACK_MAX_SIZE +#define CONFIG_TS_CONFIG_PACK_MAX_SIZE 65536 +#endif + +static bool unambiguous_json(const cJSON *j, unsigned depth) { + if (depth > 32) return false; + for (const cJSON *a = j->child; a; a = a->next) { + if (cJSON_IsObject(j)) { + for (const cJSON *b = a->next; b; b = b->next) + if (a->string && b->string && !strcmp(a->string, b->string)) return false; + } + if (!unambiguous_json(a, depth + 1)) return false; + } + return true; +} +static cJSON *parse_json_bounded(const char *text, size_t length, size_t limit) { + if (!text || !length || length > limit || memchr(text, 0, length)) + return NULL; + bool string = false; + unsigned depth = 0; + for (size_t i = 0; i < length; ++i) { + unsigned char c = text[i]; + if (string && c == '\\') { + if (i + 5 < length && text[i+1] == 'u' && !memcmp(text+i+2, "0000", 4)) return NULL; + ++i; + } else if (c == '"') string = !string; + else if (!string && (c == '{' || c == '[')) { if (++depth > 32) return NULL; } + else if (!string && (c == '}' || c == ']')) { if (!depth) return NULL; --depth; } + } + const char *end = NULL; + cJSON *j = cJSON_ParseWithLengthOpts(text, length, &end, false); + if (!j) return NULL; + while (end < text + length && isspace((unsigned char)*end)) ++end; + if (end != text + length || !unambiguous_json(j, 0)) { cJSON_Delete(j); return NULL; } + return j; +} +cJSON *ts_config_pack_parse_json(const char *text, size_t length) { + return parse_json_bounded(text,length,CONFIG_TS_CONFIG_PACK_MAX_SIZE); +} +/* Validate the outer transport before cJSON can discard a decoded NUL or + * choose one of duplicate parameters. JSON escaping can expand bytes sixfold. */ +cJSON *ts_config_pack_parse_import_request(const char *text,size_t length) { + return parse_json_bounded(text,length,(size_t)CONFIG_TS_CONFIG_PACK_MAX_SIZE*6+1024); +} static const char *TAG = "ts_config_pack"; @@ -126,6 +174,10 @@ static const char *s_error_messages[] = { [TS_CONFIG_PACK_ERR_PERMISSION] = "Permission denied (not a developer device)", [TS_CONFIG_PACK_ERR_INVALID_ARG]= "Invalid argument", [TS_CONFIG_PACK_ERR_NOT_INIT] = "System not initialized", + [TS_CONFIG_PACK_ERR_TIME_UNVERIFIED] = "Trusted time is not available", + [TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED] = "Configuration signing root is not configured", + [TS_CONFIG_PACK_ERR_SIGNER_ROLE] = "Certificate is not authorized for configuration signing", + [TS_CONFIG_PACK_ERR_CREDENTIAL_CHANGED] = "Credentials changed during validation", }; const char *ts_config_pack_strerror(ts_config_pack_result_t result) @@ -186,7 +238,7 @@ bool ts_config_pack_can_export(void) } /* 检查 OU 字段是否为 "Developer" */ - bool can_export = (strstr(info.subject_ou, "Developer") != NULL); + bool can_export = strcmp(info.subject_ou, "Developer") == 0; if (!can_export) { ESP_LOGD(TAG, "Device OU='%s', export not allowed", info.subject_ou); } @@ -424,20 +476,33 @@ ts_config_pack_result_t ts_config_pack_create( return TS_CONFIG_PACK_ERR_PERMISSION; } + /* PEM parsers require a terminating NUL; callers may supply exact bytes. */ + if (!opts->recipient_cert_pem || !opts->recipient_cert_len || opts->recipient_cert_len > TS_CERT_PEM_MAX_LEN) + return TS_CONFIG_PACK_ERR_INVALID_ARG; + const char *terminator = memchr(opts->recipient_cert_pem, 0, opts->recipient_cert_len); + if (terminator && terminator != opts->recipient_cert_pem + opts->recipient_cert_len - 1) + return TS_CONFIG_PACK_ERR_INVALID_ARG; + size_t recipient_length = opts->recipient_cert_len - (terminator != NULL); + char *recipient_pem = TS_MALLOC_PSRAM(recipient_length + 1); + if (!recipient_pem) return TS_CONFIG_PACK_ERR_NO_MEM; + memcpy(recipient_pem, opts->recipient_cert_pem, recipient_length); + recipient_pem[recipient_length] = 0; /* 解析接收方证书,获取公钥 */ ts_keypair_t recipient_key = NULL; - esp_err_t ret = ts_crypto_keypair_import(opts->recipient_cert_pem, - opts->recipient_cert_len, + esp_err_t ret = ts_crypto_keypair_import(recipient_pem, + recipient_length + 1, &recipient_key); if (ret != ESP_OK) { + free(recipient_pem); ESP_LOGE(TAG, "Failed to parse recipient certificate"); return TS_CONFIG_PACK_ERR_INVALID_ARG; } /* 计算接收方证书指纹 */ char recipient_fingerprint[CERT_FINGERPRINT_LEN + 1]; - ret = compute_cert_fingerprint(opts->recipient_cert_pem, opts->recipient_cert_len, + ret = compute_cert_fingerprint(recipient_pem, recipient_length + 1, recipient_fingerprint, sizeof(recipient_fingerprint)); + free(recipient_pem); if (ret != ESP_OK) { ts_crypto_keypair_free(recipient_key); return TS_CONFIG_PACK_ERR_INVALID_ARG; @@ -466,8 +531,12 @@ ts_config_pack_result_t ts_config_pack_create( /* ECDH 密钥协商 */ uint8_t shared_secret[32]; size_t shared_len = sizeof(shared_secret); - ret = ts_crypto_ecdh_compute_shared(ephemeral_key, opts->recipient_cert_pem, - shared_secret, &shared_len); + uint8_t recipient_pubkey[ECDH_PUBKEY_LEN]; + size_t recipient_pubkey_len=sizeof recipient_pubkey; + ret=ts_crypto_keypair_export_public_raw(recipient_key,recipient_pubkey,&recipient_pubkey_len); + if(ret==ESP_OK&&recipient_pubkey_len==sizeof recipient_pubkey) + ret = ts_crypto_ecdh_compute_shared_raw(ephemeral_key,recipient_pubkey,recipient_pubkey_len,shared_secret,&shared_len); + else ret=ESP_ERR_INVALID_ARG; /* 立即销毁临时私钥 */ ts_crypto_keypair_free(ephemeral_key); @@ -481,8 +550,10 @@ ts_config_pack_result_t ts_config_pack_create( /* 生成随机 salt 和 IV */ uint8_t salt[HKDF_SALT_LEN]; uint8_t iv[AES_IV_LEN]; - ts_crypto_random(salt, sizeof(salt)); - ts_crypto_random(iv, sizeof(iv)); + if(ts_crypto_random(salt,sizeof salt)!=ESP_OK||ts_crypto_random(iv,sizeof iv)!=ESP_OK){ + memset(shared_secret,0,sizeof shared_secret); + return TS_CONFIG_PACK_ERR_DECRYPT; + } /* HKDF 密钥派生 */ uint8_t aes_key[AES_KEY_LEN]; @@ -553,7 +624,10 @@ ts_config_pack_result_t ts_config_pack_create( /* 计算待签名数据的哈希 */ uint8_t data_hash[SHA256_LEN]; - ts_crypto_hash(TS_HASH_SHA256, ciphertext, ciphertext_len, data_hash, sizeof(data_hash)); + if (ts_crypto_hash(TS_HASH_SHA256, ciphertext, ciphertext_len, data_hash, sizeof(data_hash)) != ESP_OK) { + ts_crypto_keypair_free(signer_key);free(ciphertext); + return TS_CONFIG_PACK_ERR_SIGNATURE; + } /* ECDSA 签名 */ uint8_t signature[MAX_SIGNATURE_LEN]; @@ -1442,7 +1516,7 @@ static esp_err_t compute_cert_fingerprint(const char *cert_pem, size_t cert_len, } /* 计算实际内容长度,去除尾部空白和 null */ - size_t actual_len = strlen(cert_pem); + size_t actual_len = strnlen(cert_pem, cert_len); while (actual_len > 0 && (cert_pem[actual_len - 1] == ' ' || cert_pem[actual_len - 1] == '\t' || @@ -1478,14 +1552,14 @@ static ts_config_pack_result_t parse_tscfg_json( char **signature_b64, ts_config_pack_sig_info_t *sig_info) { - *root = cJSON_ParseWithLength(json, len); - if (!*root) { + *root = ts_config_pack_parse_json(json, len); + if (!cJSON_IsObject(*root)) { ESP_LOGE(TAG, "JSON parse error"); return TS_CONFIG_PACK_ERR_PARSE; } /* 验证版本 */ - cJSON *version = cJSON_GetObjectItem(*root, "tscfg_version"); + cJSON *version = cJSON_GetObjectItemCaseSensitive(*root, "tscfg_version"); if (!version || !cJSON_IsString(version)) { ESP_LOGE(TAG, "Missing tscfg_version"); return TS_CONFIG_PACK_ERR_PARSE; @@ -1496,19 +1570,25 @@ static ts_config_pack_result_t parse_tscfg_json( } /* 提取加密参数 */ - cJSON *encryption = cJSON_GetObjectItem(*root, "encryption"); - if (!encryption) { + cJSON *encryption = cJSON_GetObjectItemCaseSensitive(*root, "encryption"); + if (!cJSON_IsObject(encryption)) { ESP_LOGE(TAG, "Missing encryption section"); return TS_CONFIG_PACK_ERR_PARSE; } - cJSON *ephemeral_pub = cJSON_GetObjectItem(encryption, "ephemeral_public_key"); - cJSON *salt = cJSON_GetObjectItem(encryption, "salt"); - cJSON *iv = cJSON_GetObjectItem(encryption, "iv"); - cJSON *tag = cJSON_GetObjectItem(encryption, "tag"); - cJSON *recipient_fp = cJSON_GetObjectItem(encryption, "recipient_cert_fingerprint"); - - if (!ephemeral_pub || !salt || !iv || !tag || !recipient_fp) { + cJSON *ephemeral_pub = cJSON_GetObjectItemCaseSensitive(encryption, "ephemeral_public_key"); + cJSON *salt = cJSON_GetObjectItemCaseSensitive(encryption, "salt"); + cJSON *iv = cJSON_GetObjectItemCaseSensitive(encryption, "iv"); + cJSON *tag = cJSON_GetObjectItemCaseSensitive(encryption, "tag"); + cJSON *recipient_fp = cJSON_GetObjectItemCaseSensitive(encryption, "recipient_cert_fingerprint"); + cJSON *algorithm = cJSON_GetObjectItemCaseSensitive(encryption, "algorithm"); + cJSON *kdf = cJSON_GetObjectItemCaseSensitive(encryption, "kdf"); + + if (!cJSON_IsString(ephemeral_pub) || !cJSON_IsString(salt) || !cJSON_IsString(iv) || + !cJSON_IsString(tag) || !cJSON_IsString(recipient_fp) || + !cJSON_IsString(algorithm) || strcmp(algorithm->valuestring, TS_CONFIG_PACK_ALGORITHM) || + !cJSON_IsString(kdf) || strcmp(kdf->valuestring, TS_CONFIG_PACK_KDF) || + strlen(recipient_fp->valuestring) != CERT_FINGERPRINT_LEN) { ESP_LOGE(TAG, "Missing encryption parameters"); return TS_CONFIG_PACK_ERR_PARSE; } @@ -1517,25 +1597,26 @@ static ts_config_pack_result_t parse_tscfg_json( const char *pub_str = cJSON_GetStringValue(ephemeral_pub); size_t out_len = sizeof(params->ephemeral_pubkey); if (ts_crypto_base64_decode(pub_str, strlen(pub_str), - params->ephemeral_pubkey, &out_len) != ESP_OK) { + params->ephemeral_pubkey, &out_len) != ESP_OK || + out_len != sizeof(params->ephemeral_pubkey) || params->ephemeral_pubkey[0] != 4) { return TS_CONFIG_PACK_ERR_PARSE; } out_len = sizeof(params->salt); if (ts_crypto_base64_decode(cJSON_GetStringValue(salt), strlen(cJSON_GetStringValue(salt)), - params->salt, &out_len) != ESP_OK) { + params->salt, &out_len) != ESP_OK || out_len != sizeof(params->salt)) { return TS_CONFIG_PACK_ERR_PARSE; } out_len = sizeof(params->iv); if (ts_crypto_base64_decode(cJSON_GetStringValue(iv), strlen(cJSON_GetStringValue(iv)), - params->iv, &out_len) != ESP_OK) { + params->iv, &out_len) != ESP_OK || out_len != sizeof(params->iv)) { return TS_CONFIG_PACK_ERR_PARSE; } out_len = sizeof(params->tag); if (ts_crypto_base64_decode(cJSON_GetStringValue(tag), strlen(cJSON_GetStringValue(tag)), - params->tag, &out_len) != ESP_OK) { + params->tag, &out_len) != ESP_OK || out_len != sizeof(params->tag)) { return TS_CONFIG_PACK_ERR_PARSE; } @@ -1543,23 +1624,27 @@ static ts_config_pack_result_t parse_tscfg_json( sizeof(params->recipient_fingerprint) - 1); /* 提取 payload */ - cJSON *payload = cJSON_GetObjectItem(*root, "payload"); - if (!payload || !cJSON_IsString(payload)) { + cJSON *payload = cJSON_GetObjectItemCaseSensitive(*root, "payload"); + if (!cJSON_IsString(payload) || !payload->valuestring[0]) { return TS_CONFIG_PACK_ERR_PARSE; } *payload_b64 = cJSON_GetStringValue(payload); /* 提取签名信息 */ - cJSON *signature_obj = cJSON_GetObjectItem(*root, "signature"); - if (!signature_obj) { + cJSON *signature_obj = cJSON_GetObjectItemCaseSensitive(*root, "signature"); + if (!cJSON_IsObject(signature_obj)) { return TS_CONFIG_PACK_ERR_PARSE; } - cJSON *signer_cert = cJSON_GetObjectItem(signature_obj, "signer_certificate"); - cJSON *sig = cJSON_GetObjectItem(signature_obj, "signature"); - cJSON *is_official = cJSON_GetObjectItem(signature_obj, "is_official"); + cJSON *signer_cert = cJSON_GetObjectItemCaseSensitive(signature_obj, "signer_certificate"); + cJSON *sig = cJSON_GetObjectItemCaseSensitive(signature_obj, "signature"); + cJSON *is_official = cJSON_GetObjectItemCaseSensitive(signature_obj, "is_official"); + const cJSON *sig_algorithm = cJSON_GetObjectItemCaseSensitive(signature_obj, "algorithm"); - if (!signer_cert || !sig) { + if (!cJSON_IsString(signer_cert) || !signer_cert->valuestring[0] || + strlen(signer_cert->valuestring) >= TS_CERT_PEM_MAX_LEN || + !cJSON_IsString(sig) || !sig->valuestring[0] || !cJSON_IsString(sig_algorithm) || + strcmp(sig_algorithm->valuestring, TS_CONFIG_PACK_SIG_ALGORITHM)) { return TS_CONFIG_PACK_ERR_PARSE; } @@ -1569,39 +1654,11 @@ static ts_config_pack_result_t parse_tscfg_json( /* 填充签名信息 */ if (sig_info) { sig_info->valid = false; /* 稍后验证 */ - sig_info->is_official = is_official ? cJSON_IsTrue(is_official) : false; + (void)is_official; + sig_info->is_official = false; /* Unauthenticated metadata is never authorization. */ sig_info->signed_at = 0; /* 默认值 */ - /* 解析 signed_at 时间戳 (ISO 8601 格式: "2026-02-04T02:30:00Z") */ - cJSON *signed_at = cJSON_GetObjectItem(signature_obj, "signed_at"); - if (signed_at && cJSON_IsString(signed_at)) { - const char *time_str = cJSON_GetStringValue(signed_at); - /* 手动解析 ISO 8601 格式 */ - int year, month, day, hour, min, sec; - if (sscanf(time_str, "%d-%d-%dT%d:%d:%dZ", - &year, &month, &day, &hour, &min, &sec) == 6) { - struct tm tm = {0}; - tm.tm_year = year - 1900; - tm.tm_mon = month - 1; - tm.tm_mday = day; - tm.tm_hour = hour; - tm.tm_min = min; - tm.tm_sec = sec; - tm.tm_isdst = 0; - /* 设置环境变量为 UTC 然后调用 mktime */ - char *old_tz = getenv("TZ"); - setenv("TZ", "UTC0", 1); - tzset(); - sig_info->signed_at = mktime(&tm); - if (old_tz) { - setenv("TZ", old_tz, 1); - } else { - unsetenv("TZ"); - } - tzset(); - } - } - + /* Outer signed_at is unauthenticated display metadata; do not change process timezone. */ /* 解析签名者证书获取 CN 和 OU */ ts_cert_info_t cert_info; if (ts_cert_parse_certificate(*signer_cert_pem, strlen(*signer_cert_pem) + 1, @@ -1609,9 +1666,7 @@ static ts_config_pack_result_t parse_tscfg_json( strncpy(sig_info->signer_cn, cert_info.subject_cn, sizeof(sig_info->signer_cn) - 1); strncpy(sig_info->signer_ou, cert_info.subject_ou, sizeof(sig_info->signer_ou) - 1); /* 根据 OU 字段更新 is_official 标记 */ - if (strstr(cert_info.subject_ou, "Developer") != NULL) { - sig_info->is_official = true; - } + /* Only the verified rule-pack path may report trusted Developer status. */ } } @@ -1639,7 +1694,10 @@ static ts_config_pack_result_t verify_signature( /* 计算数据哈希 */ uint8_t hash[SHA256_LEN]; - ts_crypto_hash(TS_HASH_SHA256, data_to_sign, data_len, hash, sizeof(hash)); + if (ts_crypto_hash(TS_HASH_SHA256, data_to_sign, data_len, hash, sizeof(hash)) != ESP_OK) { + ts_crypto_keypair_free(signer_key); + return TS_CONFIG_PACK_ERR_SIGNATURE; + } /* 验证签名 */ ret = ts_crypto_ecdsa_verify(signer_key, hash, sizeof(hash), signature, sig_len); @@ -1668,30 +1726,16 @@ static ts_config_pack_result_t verify_signature( /** * @brief 解密 payload */ -static ts_config_pack_result_t decrypt_payload( +static ts_config_pack_result_t decrypt_payload_key( const ts_config_pack_crypto_params_t *params, + const char *key_pem, const uint8_t *ciphertext, size_t ciphertext_len, char **plaintext, size_t *plaintext_len) { - /* 获取设备私钥 - 使用堆分配避免栈溢出 */ - char *key_pem = TS_MALLOC_PSRAM(TS_CERT_KEY_MAX_LEN); - if (!key_pem) { - return TS_CONFIG_PACK_ERR_NO_MEM; - } - size_t key_len = TS_CERT_KEY_MAX_LEN; - esp_err_t ret = ts_cert_get_private_key(key_pem, &key_len); - if (ret != ESP_OK) { - free(key_pem); - ESP_LOGE(TAG, "Failed to get device private key"); - return TS_CONFIG_PACK_ERR_RECIPIENT; - } - ts_keypair_t device_key = NULL; - ret = ts_crypto_keypair_import(key_pem, key_len, &device_key); - memset(key_pem, 0, TS_CERT_KEY_MAX_LEN); /* 清除私钥 */ - free(key_pem); + esp_err_t ret = ts_crypto_keypair_import(key_pem, strlen(key_pem) + 1, &device_key); if (ret != ESP_OK) { ESP_LOGE(TAG, "Failed to import device key"); @@ -1754,6 +1798,83 @@ static ts_config_pack_result_t decrypt_payload( return TS_CONFIG_PACK_OK; } +static ts_config_pack_result_t decrypt_payload(const ts_config_pack_crypto_params_t *params, + const uint8_t *ciphertext, size_t ciphertext_len, char **plaintext, size_t *plaintext_len) +{ + char *key = TS_MALLOC_PSRAM(TS_CERT_KEY_MAX_LEN); + if (!key) return TS_CONFIG_PACK_ERR_NO_MEM; + size_t len = TS_CERT_KEY_MAX_LEN; + ts_config_pack_result_t ret = TS_CONFIG_PACK_ERR_RECIPIENT; + if (ts_cert_get_private_key(key, &len) == ESP_OK) + ret = decrypt_payload_key(params, key, ciphertext, ciphertext_len, plaintext, plaintext_len); + memset(key, 0, TS_CERT_KEY_MAX_LEN); free(key); + return ret; +} + +ts_config_pack_result_t ts_config_pack_load_verified_mem(const char *text, size_t length, + const ts_config_pack_acceptance_t *accepted, ts_config_pack_t **pack, + ts_config_pack_acceptance_t *acceptance, uint32_t *credential_generation) +{ + if (!pack) return TS_CONFIG_PACK_ERR_INVALID_ARG; + *pack = NULL; + cJSON *root = NULL; + ts_config_pack_crypto_params_t params = {0}; + char *payload = NULL, *signer = NULL, *signature_text = NULL; + ts_config_pack_sig_info_t info = {0}; + ts_cert_snapshot_t snapshot = {0}; + unsigned char *ciphertext = NULL; + char *plaintext = NULL; + ts_config_pack_acceptance_t local = {0}; + ts_config_pack_result_t ret = parse_tscfg_json(text, length, &root, ¶ms, + &payload, &signer, &signature_text, &info); + if (ret != TS_CONFIG_PACK_OK) goto done; + esp_err_t snapshot_result = ts_cert_get_pack_snapshot(&snapshot); + if (snapshot_result != ESP_OK) { + ret = snapshot_result == ESP_ERR_NO_MEM ? TS_CONFIG_PACK_ERR_NO_MEM : TS_CONFIG_PACK_ERR_CERT_CHAIN; goto done; + } + char recipient[65], hash[65]; + unsigned char digest[32]; + if (compute_cert_fingerprint(snapshot.certificate, strlen(snapshot.certificate) + 1, + recipient, sizeof recipient) != ESP_OK || + ts_crypto_hash(TS_HASH_SHA256, text, length, digest, sizeof digest) != ESP_OK || + ts_crypto_hex_encode(digest, sizeof digest, hash, sizeof hash) != ESP_OK) { + ret = TS_CONFIG_PACK_ERR_INVALID_ARG; goto done; + } + if (strcmp(recipient, params.recipient_fingerprint)) { ret = TS_CONFIG_PACK_ERR_RECIPIENT; goto done; } + ret = ts_config_pack_signer_trust(signer, &snapshot, hash, recipient, accepted, &local); + if (ret != TS_CONFIG_PACK_OK) goto done; + size_t cipher_len = strlen(payload) * 3 / 4 + 4; + ciphertext = TS_MALLOC_PSRAM(cipher_len); + if (!ciphertext) { ret = TS_CONFIG_PACK_ERR_NO_MEM; goto done; } + unsigned char signature[MAX_SIGNATURE_LEN]; size_t sig_len = sizeof signature; + if (ts_crypto_base64_decode(payload, strlen(payload), ciphertext, &cipher_len) != ESP_OK || !cipher_len || + ts_crypto_base64_decode(signature_text, strlen(signature_text), signature, &sig_len) != ESP_OK || !sig_len) { + ret = TS_CONFIG_PACK_ERR_PARSE; goto done; + } + ret = verify_signature(signer, ciphertext, cipher_len, signature, sig_len); + if (ret != TS_CONFIG_PACK_OK) goto done; + size_t plain_len = 0; + ret = decrypt_payload_key(¶ms, snapshot.key, ciphertext, cipher_len, &plaintext, &plain_len); + if (ret != TS_CONFIG_PACK_OK) goto done; + cJSON *content = ts_config_pack_parse_json(plaintext, plain_len); + if (!content) { ret = TS_CONFIG_PACK_ERR_PARSE; goto done; } + cJSON_Delete(content); + ts_cert_pki_status_t status = {0}; + if (ts_cert_get_status(&status) != ESP_OK || status.generation != snapshot.generation) { + ret = TS_CONFIG_PACK_ERR_CREDENTIAL_CHANGED; goto done; + } + ts_config_pack_t *loaded = TS_CALLOC_PSRAM(1, sizeof(*loaded)); + if (!loaded) { ret = TS_CONFIG_PACK_ERR_NO_MEM; goto done; } + loaded->content = plaintext; plaintext = NULL; loaded->content_len = plain_len; + info.valid = info.is_official = true; loaded->sig_info = info; + if (acceptance) *acceptance = local; + if (credential_generation) *credential_generation = snapshot.generation; + *pack = loaded; +done: + cJSON_Delete(root); free(ciphertext); free(plaintext); ts_cert_free_snapshot(&snapshot); + return ret; +} + // ============================================================================ // 通用配置加载辅助函数 (Generic Config Loading Helpers) // ============================================================================ diff --git a/components/ts_config_pack/src/ts_config_pack_trust.c b/components/ts_config_pack/src/ts_config_pack_trust.c new file mode 100644 index 0000000..e2138f3 --- /dev/null +++ b/components/ts_config_pack/src/ts_config_pack_trust.c @@ -0,0 +1,108 @@ +#include "ts_config_pack_trust.h" +#include "ts_crypto.h" +#include "ts_core.h" +#include "mbedtls/x509_crt.h" +#include "mbedtls/oid.h" +#include +#include + +#ifndef CONFIG_TS_CONFIG_PACK_SIGNING_ROOT_SHA256 +#define CONFIG_TS_CONFIG_PACK_SIGNING_ROOT_SHA256 "" +#endif +#define ACCEPT_POLICY 1 + +static bool cert_hash(const mbedtls_x509_crt *crt, char out[65]) { + unsigned char digest[32]; + return ts_crypto_hash(TS_HASH_SHA256, crt->raw.p, crt->raw.len, digest, sizeof digest) == ESP_OK && + ts_crypto_hex_encode(digest, sizeof digest, out, 65) == ESP_OK; +} +static bool developer(const mbedtls_x509_crt *crt) { + unsigned count = 0; + for (const mbedtls_x509_name *n = &crt->subject; n; n = n->next) { + if (MBEDTLS_OID_CMP(MBEDTLS_OID_AT_ORG_UNIT, &n->oid) == 0) { + if (n->val.len != 9 || memcmp(n->val.p, "Developer", 9)) return false; + ++count; + } + } + return count == 1; +} +/* A local accepted record permits natural expiry/offline reboot, not a new trust root + * or different package. All non-time certificate validation failures remain fatal. */ +static int accepted_time(void *context, mbedtls_x509_crt *crt, int depth, uint32_t *flags) { + (void)crt; (void)depth; + if (*(bool *)context) + *flags &= ~(MBEDTLS_X509_BADCERT_EXPIRED | MBEDTLS_X509_BADCERT_FUTURE); + return 0; +} +ts_config_pack_result_t ts_config_pack_signer_trust(const char *signer, + const ts_cert_snapshot_t *snapshot, const char *package_hash, const char *recipient, + const ts_config_pack_acceptance_t *accepted, ts_config_pack_acceptance_t *out) { + if (strlen(CONFIG_TS_CONFIG_PACK_SIGNING_ROOT_SHA256) != 64) + return TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED; + struct { mbedtls_x509_crt leaf, ca, root; } *certs = TS_CALLOC_PSRAM(1, sizeof(*certs)); + if (!certs) return TS_CONFIG_PACK_ERR_NO_MEM; + mbedtls_x509_crt_init(&certs->leaf); mbedtls_x509_crt_init(&certs->ca); + mbedtls_x509_crt_init(&certs->root); + ts_config_pack_result_t result = TS_CONFIG_PACK_ERR_CERT_CHAIN; + if (!snapshot->ca || mbedtls_x509_crt_parse(&certs->leaf, (const unsigned char *)signer, + strlen(signer) + 1) != 0 || + mbedtls_x509_crt_parse(&certs->ca, (const unsigned char *)snapshot->ca, + strlen(snapshot->ca) + 1) != 0) goto done; + char signer_hash[65], root_hash[65]; + if (!cert_hash(&certs->leaf, signer_hash)) goto done; + bool found = false; + for (mbedtls_x509_crt *c = &certs->ca; c; c = c->next) { + char hash[65]; + if (!cert_hash(c, hash)) goto done; + if (!strcmp(hash, CONFIG_TS_CONFIG_PACK_SIGNING_ROOT_SHA256)) { + if (!c->MBEDTLS_PRIVATE(ca_istrue) || mbedtls_x509_crt_parse_der(&certs->root, c->raw.p, c->raw.len)) goto done; + strcpy(root_hash, hash); found = true; + } + } + if (!found) goto done; + /* Installed intermediates may complete the chain, but only the explicitly + * selected certificate is passed as a trust anchor. */ + for (mbedtls_x509_crt *c = &certs->ca; c; c = c->next) { + char hash[65]; + if (!cert_hash(c, hash)) goto done; + if (strcmp(hash, root_hash) && mbedtls_x509_crt_parse_der(&certs->leaf, c->raw.p, c->raw.len)) goto done; + } + bool reload = accepted && accepted->policy == ACCEPT_POLICY && accepted->accepted_at > 0 && + !strcmp(accepted->package_sha256, package_hash) && !strcmp(accepted->recipient, recipient) && + !strcmp(accepted->signer_sha256, signer_hash) && !strcmp(accepted->root_sha256, root_hash); + if (accepted && !reload) goto done; + ts_cert_pki_status_t status = {0}; + if (!reload && (ts_cert_get_status(&status) != ESP_OK || !status.time_ready)) { + result = TS_CONFIG_PACK_ERR_TIME_UNVERIFIED; goto done; + } + uint32_t flags = 0; + if (mbedtls_x509_crt_verify(&certs->leaf, &certs->root, NULL, NULL, &flags, + accepted_time, &reload) != 0 || flags) goto done; + result = TS_CONFIG_PACK_ERR_SIGNER_ROLE; + if(!mbedtls_pk_can_do(&certs->leaf.pk,MBEDTLS_PK_ECDSA) || + mbedtls_pk_ec(certs->leaf.pk)->MBEDTLS_PRIVATE(grp).id!=MBEDTLS_ECP_DP_SECP256R1)goto done; + if (!developer(&certs->leaf) || + mbedtls_x509_crt_check_key_usage(&certs->leaf, MBEDTLS_X509_KU_DIGITAL_SIGNATURE)) goto done; + /* No invented mandatory EKU. If a signer carries EKU restrictions, it must + * permit code signing or any usage; a TLS-only certificate is not a signing credential. */ + if (certs->leaf.MBEDTLS_PRIVATE(ext_types) & MBEDTLS_X509_EXT_EXTENDED_KEY_USAGE) { + bool allowed = false; + for (const mbedtls_x509_sequence *s = &certs->leaf.ext_key_usage; s; s = s->next) + allowed |= (s->buf.len == MBEDTLS_OID_SIZE(MBEDTLS_OID_CODE_SIGNING) && + !memcmp(s->buf.p, MBEDTLS_OID_CODE_SIGNING, s->buf.len)) || + (s->buf.len == MBEDTLS_OID_SIZE(MBEDTLS_OID_ANY_EXTENDED_KEY_USAGE) && + !memcmp(s->buf.p, MBEDTLS_OID_ANY_EXTENDED_KEY_USAGE, s->buf.len)); + if (!allowed) goto done; + } + if (out) { + memset(out, 0, sizeof(*out)); out->policy = ACCEPT_POLICY; + out->accepted_at = reload ? accepted->accepted_at : (int64_t)time(NULL); + strcpy(out->package_sha256, package_hash); strcpy(out->recipient, recipient); + strcpy(out->signer_sha256, signer_hash); strcpy(out->root_sha256, root_hash); + } + result = TS_CONFIG_PACK_OK; +done: + mbedtls_x509_crt_free(&certs->leaf); mbedtls_x509_crt_free(&certs->ca); + mbedtls_x509_crt_free(&certs->root); free(certs); + return result; +} diff --git a/components/ts_config_pack/src/ts_config_pack_trust.h b/components/ts_config_pack/src/ts_config_pack_trust.h new file mode 100644 index 0000000..f167985 --- /dev/null +++ b/components/ts_config_pack/src/ts_config_pack_trust.h @@ -0,0 +1,6 @@ +#pragma once +#include "ts_config_pack.h" +#include "ts_cert.h" +ts_config_pack_result_t ts_config_pack_signer_trust(const char *signer, + const ts_cert_snapshot_t *snapshot, const char *package_hash, const char *recipient, + const ts_config_pack_acceptance_t *accepted, ts_config_pack_acceptance_t *out); diff --git a/components/ts_security/include/ts_ssh_commands_config.h b/components/ts_security/include/ts_ssh_commands_config.h index b121d4f..9bab583 100644 --- a/components/ts_security/include/ts_ssh_commands_config.h +++ b/components/ts_security/include/ts_ssh_commands_config.h @@ -103,6 +103,7 @@ void ts_ssh_commands_config_deinit(void); * @brief 检查模块是否已初始化 */ bool ts_ssh_commands_config_is_initialized(void); +esp_err_t ts_ssh_commands_config_load_state(void); /*===========================================================================*/ /* CRUD Operations */ diff --git a/components/ts_security/include/ts_ssh_hosts_config.h b/components/ts_security/include/ts_ssh_hosts_config.h index fd54fe9..6078af8 100644 --- a/components/ts_security/include/ts_ssh_hosts_config.h +++ b/components/ts_security/include/ts_ssh_hosts_config.h @@ -87,6 +87,7 @@ void ts_ssh_hosts_config_deinit(void); * @brief 检查模块是否已初始化 */ bool ts_ssh_hosts_config_is_initialized(void); +esp_err_t ts_ssh_hosts_config_load_state(void); /*===========================================================================*/ /* CRUD Operations */ diff --git a/components/ts_security/src/ts_crypto.c b/components/ts_security/src/ts_crypto.c index 92767cf..ecdfeaf 100644 --- a/components/ts_security/src/ts_crypto.c +++ b/components/ts_security/src/ts_crypto.c @@ -34,20 +34,16 @@ esp_err_t ts_crypto_hash(ts_hash_algo_t algo, const void *data, size_t len, switch (algo) { case TS_HASH_SHA256: if (hash_len < 32) return ESP_ERR_INVALID_SIZE; - mbedtls_sha256(data, len, hash, 0); - break; + return mbedtls_sha256(data, len, hash, 0) == 0 ? ESP_OK : ESP_FAIL; case TS_HASH_SHA384: if (hash_len < 48) return ESP_ERR_INVALID_SIZE; - mbedtls_sha512(data, len, hash, 1); - break; + return mbedtls_sha512(data, len, hash, 1) == 0 ? ESP_OK : ESP_FAIL; case TS_HASH_SHA512: if (hash_len < 64) return ESP_ERR_INVALID_SIZE; - mbedtls_sha512(data, len, hash, 0); - break; + return mbedtls_sha512(data, len, hash, 0) == 0 ? ESP_OK : ESP_FAIL; default: return ESP_ERR_NOT_SUPPORTED; } - return ESP_OK; } esp_err_t ts_crypto_hmac(ts_hash_algo_t algo, const void *key, size_t key_len, diff --git a/components/ts_security/src/ts_ssh_commands_config.c b/components/ts_security/src/ts_ssh_commands_config.c index 60f530c..66b8d34 100644 --- a/components/ts_security/src/ts_ssh_commands_config.c +++ b/components/ts_security/src/ts_ssh_commands_config.c @@ -1,4 +1,6 @@ #include "ts_ssh_service.h" +#include "ts_rule_engine.h" +#include /** * @file ts_ssh_commands_config.c * @brief SSH Command Configuration Storage Implementation @@ -221,24 +223,15 @@ static bool s_pending_export = false; /** 正在从 SD 卡加载中(禁止触发同步) */ static bool s_loading_from_sdcard = false; static TaskHandle_t initial_loader; +static atomic_int initial_load_result = ESP_ERR_INVALID_STATE; /** * @brief 延迟加载/导出任务 - 在独立任务中处理 SD 卡操作(避免 main 任务栈溢出) * * 配置加载优先级:SD 卡 > NVS > 硬编码默认值 */ -static void deferred_export_task(void *arg) -{ - (void)arg; - initial_loader = xTaskGetCurrentTaskHandle(); - vTaskDelay(pdMS_TO_TICKS(2000)); /* 等待系统稳定、SD 卡挂载完成 */ - - if (!s_state.initialized) { - initial_loader = NULL; - vTaskDelete(NULL); - return; - } - +static esp_err_t commands_load_once(void) { + esp_err_t outcome=ESP_OK; size_t nvs_count = ts_ssh_commands_config_count(); /* @@ -272,12 +265,11 @@ static void deferred_export_task(void *arg) esp_err_t cleared = ts_ssh_commands_config_clear(); if (cleared != ESP_OK) { s_loading_from_sdcard = false; - initial_loader = NULL; - vTaskDelete(NULL); - return; + return cleared; } esp_err_t import_ret = ts_ssh_commands_config_import_from_sdcard(false); + outcome=import_ret; size_t count = ts_ssh_commands_config_count(); if (import_ret == ESP_OK) { @@ -295,6 +287,26 @@ static void deferred_export_task(void *arg) } } + return outcome; +} +static void deferred_export_task(void *arg) +{ + (void)arg; + initial_loader = xTaskGetCurrentTaskHandle(); + vTaskDelay(pdMS_TO_TICKS(2000)); /* 等待系统稳定、SD 卡挂载完成 */ + + if (!s_state.initialized) { + initial_loader = NULL; + vTaskDelete(NULL); + return; + } + + while(s_state.initialized) { + esp_err_t result=commands_load_once(); + atomic_store(&initial_load_result,result); + if(result==ESP_OK)break; + vTaskDelay(pdMS_TO_TICKS(1000)); + } s_pending_export = false; initial_loader = NULL; vTaskDelete(NULL); @@ -321,6 +333,7 @@ esp_err_t ts_ssh_commands_config_init(void) return ret; } + atomic_store(&initial_load_result,ESP_ERR_INVALID_STATE); s_state.initialized = true; /* @@ -337,7 +350,11 @@ esp_err_t ts_ssh_commands_config_init(void) /* 创建延迟加载任务(在独立任务中处理 SD 卡操作,避免 main 任务栈溢出) * 必须使用 DRAM 栈,因为内部会调用 ts_ssh_commands_config_count() 访问 NVS */ s_pending_export = true; /* 标记需要处理 SD 卡 */ - xTaskCreate(deferred_export_task, "ssh_cmd_load", 8192, NULL, 2, NULL); + if(xTaskCreate(deferred_export_task, "ssh_cmd_load", 8192, NULL, 2, NULL)!=pdPASS){ + atomic_store(&initial_load_result,ESP_ERR_NO_MEM); + s_pending_export=false; + return ESP_ERR_NO_MEM; + } ESP_LOGI(TAG, "SSH commands config initialized (SD card loading deferred)"); @@ -1443,7 +1460,8 @@ esp_err_t ts_ssh_commands_config_add(const ts_ssh_command_config_t *cfg, char *o old->nohup != cfg->nohup || old->service_mode != cfg->service_mode); bool busy = (changed || got == ESP_ERR_NOT_FOUND) && ts_ssh_service_command_protected(cfg->id); free(old); - esp_err_t ret = busy ? ESP_ERR_INVALID_STATE : command_add_impl(cfg, out, size); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_COMMAND,cfg->id,cfg); + if(ret==ESP_OK) ret = busy ? ESP_ERR_INVALID_STATE : command_add_impl(cfg, out, size); ts_ssh_binding_unlock(); return ret; } @@ -1452,8 +1470,9 @@ esp_err_t ts_ssh_commands_config_remove(const char *id) { return ESP_ERR_INVALID_ARG; ts_ssh_binding_lock(); uint32_t credential = 0; - esp_err_t ret = ts_ssh_service_command_protected(id) ? ESP_ERR_INVALID_STATE - : ts_ssh_service_delete_begin(id, &credential); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_COMMAND,id,NULL); + if(ret==ESP_OK) ret = ts_ssh_service_command_protected(id) ? ESP_ERR_INVALID_STATE + : ts_ssh_service_delete_begin(id, &credential); if (ret == ESP_OK) { ret = command_remove_impl(id); ts_ssh_service_delete_finish(id, credential, ret == ESP_OK); @@ -1463,6 +1482,9 @@ esp_err_t ts_ssh_commands_config_remove(const char *id) { } esp_err_t ts_ssh_commands_config_clear(void) { ts_ssh_binding_lock(); + if (ts_rule_dependency_change(TS_RULE_DEP_COMMAND,NULL,NULL) != ESP_OK) { + ts_ssh_binding_unlock(); return ESP_ERR_INVALID_STATE; + } esp_err_t ret = (xTaskGetCurrentTaskHandle() == initial_loader ? ts_ssh_service_any_in_use() : ts_ssh_service_host_protected(NULL)) @@ -1472,3 +1494,7 @@ esp_err_t ts_ssh_commands_config_clear(void) { ts_ssh_binding_unlock(); return ret; } + +esp_err_t ts_ssh_commands_config_load_state(void) { + return s_state.initialized?atomic_load(&initial_load_result):ESP_ERR_INVALID_STATE; +} diff --git a/components/ts_security/src/ts_ssh_hosts_config.c b/components/ts_security/src/ts_ssh_hosts_config.c index 26a223f..30dff9c 100644 --- a/components/ts_security/src/ts_ssh_hosts_config.c +++ b/components/ts_security/src/ts_ssh_hosts_config.c @@ -1,4 +1,6 @@ #include "ts_ssh_service.h" +#include "ts_rule_engine.h" +#include /** * @file ts_ssh_hosts_config.c * @brief SSH Host Configuration Storage Implementation @@ -87,24 +89,15 @@ static esp_err_t host_add_guarded(const ts_ssh_host_config_t *cfg, bool sync_sdc static bool s_hosts_pending_export = false; static TaskHandle_t initial_loader; +static atomic_int initial_load_result = ESP_ERR_INVALID_STATE; /** * @brief 延迟加载/导出任务 - 在独立任务中处理 SD 卡操作(避免 main 任务栈溢出) * * 配置加载优先级:SD 卡 > NVS > 硬编码默认值 */ -static void hosts_deferred_export_task(void *arg) -{ - (void)arg; - initial_loader = xTaskGetCurrentTaskHandle(); - vTaskDelay(pdMS_TO_TICKS(2500)); /* 等待系统稳定,错开 commands 加载 */ - - if (!s_state.initialized) { - initial_loader = NULL; - vTaskDelete(NULL); - return; - } - +static esp_err_t hosts_load_once(void) { + esp_err_t outcome=ESP_OK; size_t nvs_count = ts_ssh_hosts_config_count(); /* @@ -135,6 +128,7 @@ static void hosts_deferred_export_task(void *arg) if (sdcard_has_config) { ESP_LOGI(TAG, "SD card has config, merging into NVS..."); esp_err_t import_ret = ts_ssh_hosts_config_import_from_sdcard(true); + outcome=import_ret; size_t count = ts_ssh_hosts_config_count(); if (import_ret == ESP_OK) { @@ -152,6 +146,26 @@ static void hosts_deferred_export_task(void *arg) } } + return outcome; +} +static void hosts_deferred_export_task(void *arg) +{ + (void)arg; + initial_loader = xTaskGetCurrentTaskHandle(); + vTaskDelay(pdMS_TO_TICKS(2500)); /* 等待系统稳定,错开 commands 加载 */ + + if (!s_state.initialized) { + initial_loader = NULL; + vTaskDelete(NULL); + return; + } + + while(s_state.initialized) { + esp_err_t result=hosts_load_once(); + atomic_store(&initial_load_result,result); + if(result==ESP_OK)break; + vTaskDelay(pdMS_TO_TICKS(1000)); + } s_hosts_pending_export = false; initial_loader = NULL; vTaskDelete(NULL); @@ -185,6 +199,7 @@ esp_err_t ts_ssh_hosts_config_init(void) return ret; } + atomic_store(&initial_load_result,ESP_ERR_INVALID_STATE); s_state.initialized = true; /* @@ -200,7 +215,11 @@ esp_err_t ts_ssh_hosts_config_init(void) /* 创建延迟加载任务 * 必须使用 DRAM 栈,因为内部会访问 NVS */ s_hosts_pending_export = true; - xTaskCreate(hosts_deferred_export_task, "ssh_host_load", 8192, NULL, 2, NULL); + if(xTaskCreate(hosts_deferred_export_task, "ssh_host_load", 8192, NULL, 2, NULL)!=pdPASS){ + atomic_store(&initial_load_result,ESP_ERR_NO_MEM); + s_hosts_pending_export=false; + return ESP_ERR_NO_MEM; + } ESP_LOGI(TAG, "SSH hosts config initialized (SD card loading deferred)"); return ESP_OK; @@ -1129,7 +1148,8 @@ static esp_err_t host_add_guarded(const ts_ssh_host_config_t *cfg, bool sync_sdc bool protected = got == ESP_ERR_NOT_FOUND ? ts_ssh_service_host_runtime_protected(cfg->id) : changed && ts_ssh_service_host_protected(cfg->id); - esp_err_t ret = protected ? ESP_ERR_INVALID_STATE : host_add_impl(cfg, sync_sdcard); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_HOST,cfg->id,cfg); + if(ret==ESP_OK) ret = protected ? ESP_ERR_INVALID_STATE : host_add_impl(cfg, sync_sdcard); ts_ssh_binding_unlock(); return ret; } @@ -1140,13 +1160,16 @@ esp_err_t ts_ssh_hosts_config_remove(const char *id) { if (!id) return ESP_ERR_INVALID_ARG; ts_ssh_binding_lock(); - esp_err_t ret = - ts_ssh_service_host_protected(id) ? ESP_ERR_INVALID_STATE : host_remove_impl(id); + esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_HOST,id,NULL); + if(ret==ESP_OK) ret = ts_ssh_service_host_protected(id) ? ESP_ERR_INVALID_STATE : host_remove_impl(id); ts_ssh_binding_unlock(); return ret; } esp_err_t ts_ssh_hosts_config_clear(void) { ts_ssh_binding_lock(); + if (ts_rule_dependency_change(TS_RULE_DEP_HOST,NULL,NULL) != ESP_OK) { + ts_ssh_binding_unlock(); return ESP_ERR_INVALID_STATE; + } esp_err_t ret = (xTaskGetCurrentTaskHandle() == initial_loader ? ts_ssh_service_any_in_use() : ts_ssh_service_host_protected(NULL)) @@ -1155,3 +1178,7 @@ esp_err_t ts_ssh_hosts_config_clear(void) { ts_ssh_binding_unlock(); return ret; } + +esp_err_t ts_ssh_hosts_config_load_state(void) { + return s_state.initialized?atomic_load(&initial_load_result):ESP_ERR_INVALID_STATE; +} diff --git a/components/ts_webui/CMakeLists.txt b/components/ts_webui/CMakeLists.txt index 0fd6e09..71cbbc8 100644 --- a/components/ts_webui/CMakeLists.txt +++ b/components/ts_webui/CMakeLists.txt @@ -19,6 +19,7 @@ idf_component_register( esp_http_server PRIV_REQUIRES json + ts_config_pack ) # =========================================================================== diff --git a/components/ts_webui/src/ts_webui_api.c b/components/ts_webui/src/ts_webui_api.c index 2c7441a..eb5afc6 100644 --- a/components/ts_webui/src/ts_webui_api.c +++ b/components/ts_webui/src/ts_webui_api.c @@ -190,6 +190,9 @@ static esp_err_t api_handler(ts_http_request_t *req, void *user_data) // } bool key_generation = strcmp(api_name, "key.generate") == 0; + bool rule_import = strcmp(api_name,"automation.rules.import")==0; + if(rule_import&&(!is_binary_upload_complete(req)||!req->body||httpd_req_get_url_query_len(req->req)>0)) + return ts_http_send_error(req,400,"invalid_pack"); int64_t key_started = key_generation ? esp_timer_get_time() : 0; char key_request_id[32] = "legacy"; if (key_generation) TS_LOGI(TAG, "key.generate received"); @@ -267,7 +270,11 @@ static esp_err_t api_handler(ts_http_request_t *req, void *user_data) // Add body data if present (POST/PUT requests - overrides query params) if (req->body && req->body_len > 0) { - cJSON *body = cJSON_Parse(req->body); + cJSON *body = rule_import ? ts_config_pack_parse_import_request(req->body,req->body_len) : cJSON_Parse(req->body); + if(rule_import&&!cJSON_IsObject(body)){ + cJSON_Delete(body);cJSON_Delete(request); + return ts_http_send_error(req,400,"invalid_pack"); + } if (body) { cJSON *item; cJSON_ArrayForEach(item, body) { diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 7914498..c7884ea 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -5,7 +5,7 @@ function runtimeText(key) { } function runtimeSaveError(result) { const code = result.data?.error_code || result.rawMessage || result.error || result.message; - const known = ['revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config']; + const known = ['restart_pending', 'revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config']; return known.includes(code) ? runtimeText(code) : (typeof code === 'string' ? code : runtimeText('saveFailed')); } async function ruleWriteWithRevision(method, id) { @@ -3831,6 +3831,13 @@ async function refreshQuickActions() { if (!pageCurrent()) return; if (result.code === 0 && result.data && result.data.rules) { + if (result.data.loaded === false || result.data.recovery_required === true) { + const reason = result.data.load_error || 'recovery_required'; + const key = 'rulePack.errors.' + reason; + const message = t(key) === key ? t('rulePack.errors.recovery_required') : t(key); + container.innerHTML = `

${escapeHtml(message)}

`; + return; + } // 过滤出启用且标记为可手动触发的规则 if (!result.data.loaded || result.data.recovery_required) { container.textContent = runtimeText(result.data.recovery_required ? 'recovery_required' : 'configLoading'); return; @@ -15246,7 +15253,8 @@ function formatUptimeSec(seconds) { async function automationControl(action) { try { const result = await api.call(`automation.${action}`); - showToast(typeof t === 'function' ? t('toast.actionResult', { action, msg: result.message || 'OK' }) : `${action}: ${result.message || 'OK'}`, result.code === 0 ? 'success' : 'error'); + const message = (result.rawMessage || result.error || result.message) === 'restart_pending' ? runtimeText('restart_pending') : result.message || 'OK'; + showToast(typeof t === 'function' ? t('toast.actionResult', { action, msg: message }) : `${action}: ${message}`, result.code === 0 ? 'success' : 'error'); if (result.code === 0) { await refreshAutomationStatus(); } @@ -15280,15 +15288,18 @@ async function refreshRules() { ${rules.map(r => { const label = r.enabled ? t('common.disabled') : t('common.enabled'); + const locked = r.restart_required || r.readonly; + const active = r.runtime_active !== false; + const handler = (name, ...args) => escapeHtml(name + '(' + args.map(x => JSON.stringify(x)).join(',') + ')'); return `
-
${r.id}
-
${r.name || r.id}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
-
+
${escapeHtml(r.id)}
+
${escapeHtml(r.name || r.id)}${r.restart_required ? ` ${t('rulePack.pending')}` : ''}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
+
${r.conditions_count || 0}
${r.actions_count || 0}
${r.trigger_count || 0}
-
${icoBtn('ri-play-line', t('automation.manualTrigger'), `triggerRule('${r.id}')`)}${icoBtn('ri-edit-line', t('common.edit'), `editRule('${r.id}')`)}${icoBtn('ri-download-line', t('securityPage.exportConfigPack'), `showExportRuleModal('${r.id}')`)}${icoBtn('ri-delete-bin-line', t('common.delete'), `deleteRule('${r.id}')`, 'dg')}
+
${icoBtn('ri-play-line', t(r.restart_required ? 'rulePack.runCurrent' : 'automation.manualTrigger'), handler('triggerRule', r.id), '', !active)}${icoBtn('ri-edit-line', t('common.edit'), handler('editRule', r.id), '', locked)}${icoBtn('ri-download-line', t('rulePack.exportCurrent'), handler('showExportRuleModal', r.id), '', !active)}${icoBtn('ri-delete-bin-line', t('common.delete'), handler('deleteRule', r.id), 'dg', locked)}
`; }).join('')} `; @@ -16870,7 +16881,7 @@ async function deleteAction(id) { async function toggleSource(id, enable) { try { const action = enable ? 'automation.sources.enable' : 'automation.sources.disable'; - const result = await ruleWriteWithRevision(action, id); + const result = await api.call(action, { id }); showToast(typeof t === 'function' ? t('toast.sourceToggled', { id, state: enable ? t('status.enabled') : t('status.disabled') }) + ': ' + (result.message || 'OK') : `数据源 ${id} ${enable ? '启用' : '禁用'}: ${result.message || 'OK'}`, result.code === 0 ? 'success' : 'error'); if (result.code === 0) { await refreshSources(); @@ -18982,6 +18993,7 @@ async function doExportRule(ruleId) { * 显示导入规则配置模态框 */ function showImportRuleModal() { + ++rulePackPreviewAttempt; rulePackPreview = null; let modal = document.getElementById('import-rule-modal'); if (!modal) { modal = document.createElement('div'); @@ -18999,103 +19011,114 @@ function showImportRuleModal() { } function hideImportRuleModal() { + ++rulePackPreviewAttempt; rulePackPreview = null; const modal = document.getElementById('import-rule-modal'); if (modal) modal.classList.add('hidden'); window._importRuleTscfg = null; } +let rulePackPreviewAttempt = 0; +let rulePackPreview = null; +function rulePackFailure(result) { + const token = result?.data?.error_code || result?.rawMessage || result?.error || result?.message; + const key = 'rulePack.errors.' + token; + const text = t(key); + return text !== key ? text : t('rulePack.errors.invalid_pack'); +} async function previewRuleImport() { - const fileInput = document.getElementById('import-rule-file'); - const resultBox = document.getElementById('import-rule-result'); - const step2 = document.getElementById('import-rule-step2'); - const previewDiv = document.getElementById('import-rule-preview'); - const importBtn = document.getElementById('import-rule-btn'); - const statusEl = document.getElementById('import-rule-file-status'); - - if (!fileInput.files || !fileInput.files[0]) { - if (statusEl) statusEl.textContent = typeof t === 'function' ? t('common.noFileSelected') : '未选择任何文件'; - return; - } - - const file = fileInput.files[0]; - if (statusEl) statusEl.textContent = file.name; - - resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = (typeof t === 'function' ? t('ssh.verifyingPack') : '正在验证配置包...'); - importBtn.disabled = true; - previewDiv.innerHTML = importPlaceholder('rule'); - + const attempt = ++rulePackPreviewAttempt; + rulePackPreview = null; + window._importRuleTscfg = null; + const input = document.getElementById('import-rule-file'); + const box = document.getElementById('import-rule-result'); + const button = document.getElementById('import-rule-btn'); + const preview = document.getElementById('import-rule-preview'); + button.disabled = true; + if (!input.files?.[0]) return; + const file = input.files[0]; + document.getElementById('import-rule-file-status').textContent = file.name; + box.className = 'result-box'; box.textContent = t('ssh.verifyingPack'); + preview.innerHTML = importPlaceholder('rule'); try { - const content = await file.text(); - window._importRuleTscfg = content; - window._importRuleFilename = file.name; - - const result = await api.call('automation.rules.import', { - tscfg: content, - filename: file.name, - preview: true - }); - - if (result.code === 0 && result.data?.valid) { - const data = result.data; - renderImportPreview('rule', data, t('automation.packTypeRule')); - resultBox.className = 'result-box success'; - resultBox.textContent = typeof t === 'function' ? t('ssh.signatureVerified') : '签名验证通过'; - importBtn.disabled = false; - } else { - resultBox.className = 'result-box error'; - resultBox.textContent = (result.message || (typeof t === 'function' ? t('ssh.cannotVerifyPack') : '无法验证配置包')); - } - } catch (e) { - resultBox.className = 'result-box error'; - resultBox.textContent = e.message; + const bytes = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(await file.arrayBuffer()); + if (attempt !== rulePackPreviewAttempt) return; + const result = await api.call('automation.rules.import', {tscfg: bytes, filename: file.name, preview: true}); + if (attempt !== rulePackPreviewAttempt) return; + const data = result.data; + if (result.code !== 0 || data?.valid !== true || data?.trusted !== true || data?.target_matches !== true || + !data.rule || data.rule.id !== data.id || !Array.isArray(data.rule.actions) || !data.rule.actions.length || + !Number.isInteger(data.expected_revision) || !Number.isInteger(data.expected_generation) || + !Number.isInteger(data.credential_generation) || !/^[0-9a-f]{64}$/.test(data.package_digest || '')) { + box.className = 'result-box error'; box.textContent = rulePackFailure(result); return; + } + rulePackPreview = data; + window._importRuleTscfg = bytes; window._importRuleFilename = file.name; + renderImportPreview('rule', data, t('automation.packTypeRule')); + const conditions = Array.isArray(data.rule.conditions) ? data.rule.conditions.length : data.rule.conditions?.items?.length || 0; + preview.insertAdjacentHTML('afterbegin', row(t('common.name'), escapeHtml(data.rule.name || data.id))); + preview.insertAdjacentHTML('beforeend', row(t('rulePack.summaryLabel'), escapeHtml(t('rulePack.summary', {conditions, actions: data.rule.actions?.length || 0})))); + preview.insertAdjacentHTML('beforeend', `
${escapeHtml(t('rulePack.content'))}
${escapeHtml(JSON.stringify(data.rule, null, 2))}
`); + if (data.exists) preview.insertAdjacentHTML('beforeend', row(t('rulePack.overwriteLabel'), escapeHtml(t('rulePack.overwriteImpact', {revision: data.expected_revision})))); + for (const warning of data.warnings || []) { + const key = warning === 'dynamic_inputs' ? 'rulePack.dynamicInputs' + : warning === 'dependency_disabled' ? 'rulePack.disabledDependency' : null; + if (key) preview.insertAdjacentHTML('beforeend', row(t('securityPage.noteLabel'), escapeHtml(t(key)))); + } + box.className = 'result-box success'; box.textContent = t('rulePack.verified'); + button.disabled = false; + } catch (error) { + if (attempt !== rulePackPreviewAttempt) return; + box.className = 'result-box error'; box.textContent = t('rulePack.verifyFailed'); } } - async function confirmRuleImport() { - const overwrite = document.getElementById('import-rule-overwrite').checked; - const resultBox = document.getElementById('import-rule-result'); - const importBtn = document.getElementById('import-rule-btn'); - - if (!window._importRuleTscfg) { - showToast((typeof t === 'function' ? t('toast.selectFileFirst') : '请先选择文件'), 'error'); - return; - } - - resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = typeof t === 'function' ? t('ssh.savingConfig') : '正在保存配置...'; - importBtn.disabled = true; - + const attempt = rulePackPreviewAttempt; + const preview = rulePackPreview; + const box = document.getElementById('import-rule-result'); + const button = document.getElementById('import-rule-btn'); + if (!preview || !window._importRuleTscfg) { box.textContent = t('rulePack.errors.preview_required'); return; } + const bytes = window._importRuleTscfg; + button.disabled = true; + box.className = 'result-box'; box.textContent = t('ssh.savingConfig'); + let uncertain = false; try { - const params = { - tscfg: window._importRuleTscfg, - filename: window._importRuleFilename, - overwrite: overwrite - }; - - const result = await api.call('automation.rules.import', params); - - if (result.code === 0) { - const data = result.data; - if (data?.exists && !data?.imported) { - resultBox.className = 'result-box warning'; - resultBox.textContent = typeof t === 'function' ? t('securityPage.configExistsCheckOverwrite', { id: data.id }) : `配置 ${data.id} 已存在,请勾选「覆盖」选项`; - importBtn.disabled = false; - } else { - resultBox.className = 'result-box success'; - resultBox.innerHTML = `${typeof t === 'function' ? t('securityPage.savedConfig') : 'Saved config'}: ${escapeHtml(data?.id)}
${typeof t === 'function' ? t('securityPage.restartToApply') : 'Restart to apply'}`; - showToast(typeof t === 'function' ? t('toast.configImported') : '已导入配置,重启后生效', 'success'); - setTimeout(() => hideImportRuleModal(), 2000); - } - } else { - resultBox.className = 'result-box error'; - resultBox.textContent = (result.message || (typeof t === 'function' ? t('toast.importFailed') : '导入失败')); - importBtn.disabled = false; + const result = await api.call('automation.rules.import', { + tscfg: bytes, filename: window._importRuleFilename, + overwrite: document.getElementById('import-rule-overwrite').checked, + expected_revision: preview.expected_revision, expected_generation: preview.expected_generation, + credential_generation: preview.credential_generation, package_digest: preview.package_digest + }); + if (attempt !== rulePackPreviewAttempt) return; + const data = result.data; + if (result.code === 0 && data?.saved === true && data?.durable === true && data?.runtime_applied === false) { + box.className = data.cleanup_pending ? 'result-box warning' : 'result-box success'; + box.textContent = t(data.restart_required ? 'rulePack.saved' : 'rulePack.alreadyActive') + (data.cleanup_pending ? ' ' + t('rulePack.cleanupPending') : ''); + rulePackPreview = null; + await refreshRules(); + return; } - } catch (e) { - resultBox.className = 'result-box error'; - resultBox.textContent = e.message; - importBtn.disabled = false; + uncertain = result.code === 0 || data?.result_unknown === true || ['commit_unknown', 'recovery_required'].includes(result.rawMessage || result.error || result.message); + if (!uncertain) { + box.className = 'result-box error'; box.textContent = rulePackFailure(result); + if ((result.rawMessage || result.error || result.message) === 'overwrite_required') button.disabled = false; + else rulePackPreview = null; + return; + } + } catch (error) { uncertain = true; } + if (attempt !== rulePackPreviewAttempt) return; + if (uncertain) { + // Observe once; never replay an uncertain write. + box.className = 'result-box warning'; box.textContent = t('rulePack.unknown'); + rulePackPreview = null; + try { + const state = await api.call('automation.rules.list'); + if (attempt !== rulePackPreviewAttempt) return; + const saved = state.code === 0 && state.data?.loaded !== false && state.data?.recovery_required !== true && state.data?.rules?.find(r => r.id === preview.id && r.package_digest === preview.package_digest); + if (saved && Number.isInteger(saved.saved_revision) && saved.saved_generation >= preview.expected_generation) { + box.className = 'result-box success'; box.textContent = t(saved.restart_required ? 'rulePack.saved' : 'rulePack.alreadyActive'); + await refreshRules(); + } + } catch (error) { /* Keep the explicit unknown result. */ } } } diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index f8e20e8..fae56e6 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -2,6 +2,52 @@ * TianShanOS WebUI - English Language Pack */ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { + rulePack: { + content: "View rule content", + overwriteLabel: "Overwrite impact", + overwriteImpact: "Replaces saved revision {revision}; the running version continues until restart.", + "disabledDependency": "This rule and some dependencies are disabled. Check dependencies before enabling it.", + "pending": "Saved; restart required", + "runCurrent": "Run the current, older version", + "exportCurrent": "Export the current running version", + "verified": "Signing trust, target device and rule content verified.", + "saved": "Saved; takes effect after restart. The running version has not changed.", + "alreadyActive": "This configuration is already saved and active.", + "unknown": "Save outcome is unconfirmed. Read the rule list before importing again.", + "cleanupPending": "Old storage objects still need cleanup. Keep the SD card installed and check after restart.", + "dynamicInputs": "Condition variables can be created at runtime; missing samples do not prove missing configuration.", + "summaryLabel": "Rule summary", + "summary": "{conditions} conditions, {actions} actions", + "verifyFailed": "Verification did not complete. Check the connection and select the file again.", + "errors": { + "config_loading": "Rule configuration is still loading. Check again shortly.", + "invalid_pack": "The package format or content is invalid. Export it again.", + "invalid_rule_pack": "This is not a valid rule configuration package.", + "wrong_device": "This package targets another device. Export it using the target device certificate.", + "signature_invalid": "Signature verification failed. Obtain the original package again.", + "time_unverified": "Device time is unverified. Complete time synchronization first.", + "signing_trust_unconfigured": "A configuration signing root has not been selected. Ask the administrator to complete deployment configuration.", + "signer_untrusted": "The signer is not trusted by the device configuration signing policy.", + "signer_unauthorized": "The signer certificate is not authorized to sign configurations.", + "credential_changed": "Credentials or the selected file changed after preview. Verify again.", + "dependency_loading": "Dependency configuration is still loading. Verify again shortly.", + "dependency_unavailable": "Dependency configuration failed to load. Check and recover it first.", + "dependency_missing": "Required configuration is missing. Install the referenced template, command or host first.", + "dependency_disabled": "This enabled rule depends on disabled configuration. Check its dependencies.", + "preview_required": "Select the file again and complete preview first.", + "overwrite_required": "Configuration with this ID exists. Review it and select overwrite.", + "revision_conflict": "Saved configuration changed after preview. Verify again.", + "restart_pending": "This rule has a saved version awaiting restart. Ordinary edits, toggles and deletion are unavailable.", + "recovery_required": "Saved configuration requires recovery. Import is unavailable.", + "source_unavailable": "The required SD card is unavailable. Check its mount state.", + "source_read_only": "The current configuration source is read-only. Restore its writable source before importing.", + "service_busy": "A bound service is active or unconfirmed. Verify and stop it first.", + "capacity": "The saved rule limit has been reached, including pending rules.", + "no_memory": "The device has insufficient memory. Retry shortly.", + "storage_failed": "Configuration could not be saved reliably. Check storage and verify again.", + "commit_unknown": "Save outcome is unconfirmed. Read state before importing again." + } +}, conditionEditor: { variableRequired: "Select a condition variable.", variableInvalid: "The condition variable is invalid or exceeds 63 UTF-8 bytes.", @@ -235,6 +281,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, runtimeRepair: { + restart_pending: "This rule has a saved version awaiting restart. Ordinary edits, toggles and deletion are unavailable.", configLoading: 'Configuration is loading. Refresh shortly.', confirmFingerprint: 'Verify this server fingerprint through a trusted channel. Trust this exact fingerprint and connect?', saved: 'Saved', diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 6663157..c7c9adb 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -2,6 +2,52 @@ * TianShanOS WebUI - 简体中文语言包 */ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { + rulePack: { + content: "查看规则内容", + overwriteLabel: "覆盖影响", + overwriteImpact: "替换已保存的第 {revision} 版;重启前仍运行当前版本。", + "disabledDependency": "此规则已禁用,部分依赖也已禁用;请在启用前检查依赖状态。", + "pending": "已保存,待重启", + "runCurrent": "执行正在使用的旧版本", + "exportCurrent": "导出正在使用的版本", + "verified": "来源、目标设备和规则内容已验证。", + "saved": "已保存,重启后生效;当前运行版本未改变。", + "alreadyActive": "此配置已保存,当前运行版本已是这一版。", + "unknown": "保存结果尚未确认,请读取规则列表核对,不要重复导入。", + "cleanupPending": "旧存储对象尚待清理,请保留 SD 卡并在重启后核对。", + "dynamicInputs": "条件变量可能由运行时产生;没有采样值不代表配置缺失。", + "summaryLabel": "规则摘要", + "summary": "{conditions} 项条件,{actions} 项动作", + "verifyFailed": "未能完成验证,请检查连接后重新选择文件。", + "errors": { + "config_loading": "规则配置仍在加载,请稍后刷新。", + "invalid_pack": "配置包格式或内容无效,请重新导出。", + "invalid_rule_pack": "此文件不是有效的规则配置包。", + "wrong_device": "此包属于另一台设备,请用目标设备证书重新导出。", + "signature_invalid": "签名验证失败,请重新取得原始配置包。", + "time_unverified": "设备时间尚未确认,请先完成时间同步。", + "signing_trust_unconfigured": "设备尚未指定配置签发信任根,请由管理员完成部署配置。", + "signer_untrusted": "签名者不受设备配置签发策略信任。", + "signer_unauthorized": "签名者证书没有配置签发权限。", + "credential_changed": "凭证或所选文件在预览后发生变化,请重新验证。", + "dependency_loading": "依赖配置仍在加载,请稍后重新验证。", + "dependency_unavailable": "依赖配置加载失败,请先检查并恢复相关配置。", + "dependency_missing": "规则引用的必要配置不存在,请先补齐动作模板、指令或主机。", + "dependency_disabled": "启用的规则依赖已禁用的配置,请先检查依赖状态。", + "preview_required": "请先重新选择文件并完成预览。", + "overwrite_required": "已有同 ID 配置,请核对内容后勾选覆盖。", + "revision_conflict": "已保存的配置在预览后发生变化,请重新验证。", + "restart_pending": "此规则已有待重启版本,普通编辑、启停和删除暂不可用。", + "recovery_required": "已保存配置需要恢复核对,当前不能导入。", + "source_unavailable": "配置所需的 SD 卡不可用,请检查挂载状态。", + "source_read_only": "当前配置来源为只读,请恢复可写来源后再导入。", + "service_busy": "规则绑定的服务仍在运行或状态未确认,请先核验并停止服务。", + "capacity": "已保存规则数量已达上限,包括待重启规则。", + "no_memory": "设备内存不足,请稍后再试。", + "storage_failed": "配置未能可靠保存,请检查存储后重新验证。", + "commit_unknown": "保存结果尚未确认,请读取状态核对,不要重复导入。" + } +}, conditionEditor: { variableRequired: "请选择条件变量。", variableInvalid: "条件变量名称无效或过长(最多 63 个 UTF-8 字节)。", @@ -235,6 +281,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, runtimeRepair: { + restart_pending: "此规则已有待重启版本,普通编辑、启停和删除暂不可用。", configLoading: '配置正在加载,请稍后刷新。', confirmFingerprint: '请通过可信渠道核对以下服务器指纹。确认信任此指纹并继续连接?', saved: '已保存', diff --git a/tests/certificate/test_material.c b/tests/certificate/test_material.c index 8483f21..bf3cc0a 100644 --- a/tests/certificate/test_material.c +++ b/tests/certificate/test_material.c @@ -111,10 +111,17 @@ int main(int argc,char **argv) { clock_now=from;assert(status().cert_info.is_valid);clock_now=to;assert(status().cert_info.is_valid); clock_now=to+1;assert(status().cert_info.validity==TS_CERT_VALIDITY_EXPIRED&&status().cert_info.days_until_expiry==-1); clock_now=0;assert(status().status==TS_CERT_STATUS_TIME_UNVERIFIED&&!status().cert_info.is_valid); + ts_cert_snapshot_t offline; + assert(ts_cert_get_snapshot(true,&offline)!=ESP_OK); + assert(ts_cert_get_pack_snapshot(&offline)==ESP_OK&&offline.generation==status().generation); + ts_cert_free_snapshot(&offline); + fail_alloc=true;assert(ts_cert_get_pack_snapshot(&offline)==ESP_ERR_NO_MEM&&!offline.key&&!offline.certificate&&!offline.ca);fail_alloc=false; clock_now=-1;assert(!status().time_ready);clock_now=1790121600; install(future);assert(status().status==TS_CERT_STATUS_NOT_YET_VALID);install(expired);assert(status().status==TS_CERT_STATUS_EXPIRED);install(a); drop_event=true;install(b);assert(status().generation>generation);drop_event=false; - ts_cert_snapshot_t snap;assert(ts_cert_get_snapshot(true,&snap)==0);install(a);assert(!strcmp(snap.certificate,b));assert(snap.generation!=status().generation);ts_cert_free_snapshot(&snap); + ts_cert_snapshot_t snap;assert(ts_cert_get_snapshot(true,&snap)==0);uint32_t old_generation=snap.generation;install(a);assert(!strcmp(snap.certificate,b));assert(snap.generation!=status().generation);ts_cert_free_snapshot(&snap); + assert(!ts_cert_material_begin(old_generation)); + assert(ts_cert_material_begin(status().generation));ts_cert_material_end(); pthread_t t1,t2;pthread_create(&t1,NULL,writer,NULL);pthread_create(&t2,NULL,reader,NULL);pthread_join(t1,NULL);pthread_join(t2,NULL); assert(ts_cert_get_snapshot(true,&snap)==0);generation=status().generation; assert(ts_cert_generate_keypair()==0);assert(status().generation!=generation&&!status().has_certificate);assert(snap.certificate!=NULL);ts_cert_free_snapshot(&snap); diff --git a/tests/prompts/rule-pack.browser.test.cjs b/tests/prompts/rule-pack.browser.test.cjs new file mode 100644 index 0000000..f84be2e --- /dev/null +++ b/tests/prompts/rule-pack.browser.test.cjs @@ -0,0 +1,174 @@ +// Real page and file picker, local simulated device. Cryptography/storage are verified separately by production C tests. +const {test,before,after}=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'),path=require('node:path'),http=require('node:http'); +const {chromium}=require('playwright'); +const root=path.resolve(__dirname,'../../components/ts_webui/web'); +let browser; +before(async()=>{browser=await chromium.launch({channel:'chrome',headless:true});}); +after(async()=>{await browser?.close();}); +const hash='a'.repeat(64); +const id='night\'"<&>'; +const preview={valid:true,trusted:true,target_matches:true,type:'automation_rule',id,name:'Night test', + signer:'Test Developer',official:true,exists:false,expected_revision:0,expected_generation:7,credential_generation:4, + package_digest:hash,rule:{id,name:'Night test',conditions:[{variable:'future.sample'}],actions:[{type:'log',message:'Test'}]}, + warnings:['dynamic_inputs']}; +async function pageFor(language,mode,run){ + const calls=[];let saved=false,writes=0,sourceEnabled=false; + const server=http.createServer((req,res)=>{ + const url=new URL(req.url,'http://local'); + if(url.pathname.startsWith('/api/v1/')){ + let raw='';req.on('data',part=>raw+=part); + req.on('end',()=>{ + const endpoint=url.pathname.slice(8); + const params=req.method==='GET'?Object.fromEntries(url.searchParams):(raw?JSON.parse(raw):{}); + calls.push({endpoint,params}); + let reply={code:0,data:{}}; + if(endpoint==='automation/sources/list')reply={code:0,data:{sources:[{id:'source-only',label:'Source',enabled:sourceEnabled,type:'ssh'}]}}; + if(endpoint==='automation/sources/enable'||endpoint==='automation/sources/disable'){ + assert.deepEqual(params,{id:'source-only'});sourceEnabled=endpoint.endsWith('/enable'); + } + if(endpoint==='automation/rules/get')reply={code:1,message:'not_found'}; + if(endpoint==='automation/rules/import'){ + if(params.preview){ + const data={...preview,exists:mode==='old',expected_revision:mode==='old'?4:0}; + if(params.tscfg==='untrusted')data.trusted=false; + if(params.tscfg==='slow'||params.tscfg==='fast')data.rule={...preview.rule,name:params.tscfg}; + reply={code:0,data}; + if(params.tscfg==='slow'){ + setTimeout(()=>{res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify(reply));},150);return; + } + } + else{ + ++writes; + assert.equal(params.expected_revision,mode==='old'?4:0); + assert.equal(params.expected_generation,7); + assert.equal(params.credential_generation,4); + assert.equal(params.package_digest,hash); + if(mode==='conflict')reply={code:1,message:'revision_conflict'}; + else{ + saved=true; + if(mode==='lost'||mode==='unknown'){ + res.writeHead(200,{'Content-Type':'application/json'});res.flushHeaders(); + res.write('{"code":0,');setImmediate(()=>res.destroy());return; + } + reply={code:0,data:{id,saved:true,durable:true,runtime_applied:false,restart_required:true,saved_revision:1,saved_generation:8}}; + } + } + } + if(endpoint==='automation/rules/list')reply={code:0,data:{loaded:true,recovery_required:mode==='unknown',rules:saved?[{id,name:'Night test',enabled:false, + readonly:true,restart_required:true,runtime_active:mode==='old',saved_revision:mode==='old'?5:1,active_revision:mode==='old'?4:0,saved_generation:8, + package_digest:hash,conditions_count:1,actions_count:1}]:[]}}; + res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify(reply)); + });return; + } + const file=path.resolve(root,'.'+(url.pathname==='/'?'/index.html':url.pathname)); + if(!file.startsWith(root+'/')){res.writeHead(403).end();return;} + fs.readFile(file,(error,data)=>{if(error){res.writeHead(404).end();return;} + res.setHeader('Content-Type',({'.js':'application/javascript','.css':'text/css','.html':'text/html','.svg':'image/svg+xml','.woff2':'font/woff2'})[path.extname(file)]||'application/octet-stream'); + res.end(data); + }); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const context=await browser.newContext({locale:language}); + await context.addInitScript(language=>{ + localStorage.setItem('ts_language',language); + class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}} + window.WebSocket=Socket; + },language); + const page=await context.newPage(),errors=[]; + page.on('pageerror',e=>errors.push(e.message)); + try{ + await page.goto('http://127.0.0.1:'+server.address().port); + await page.waitForFunction(()=>i18n.isReady()); + await page.evaluate(()=>{closeLoginModal();document.body.insertAdjacentHTML('beforeend','
');showImportRuleModal();}); + await page.locator('#import-rule-file').setInputFiles({name:'renamed-package.tscfg',mimeType:'application/json',buffer:Buffer.from('local page fixture')}); + await page.waitForFunction(()=>!document.getElementById('import-rule-btn').disabled); + assert.equal(await page.locator('#import-rule-preview b').count(),0,'the signed name must remain text'); + assert.match(await page.locator('#import-rule-preview').innerText(),/Night test<\/b>/); + await run({page,calls,writes:()=>writes,saved:()=>saved}); + assert.deepEqual(errors,[]); + }finally{await context.close();await new Promise(resolve=>server.close(resolve));} +} +for(const language of ['zh-CN','en-US']){ + test(language+': preview -> one save -> pending row and escaped ID',async()=>{ + await pageFor(language,'ok',async({page,calls,writes,saved})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.saved')); + await page.waitForFunction(()=>document.querySelector('#rules-list .tr:not(.th)')); + assert(saved());assert.equal(writes(),1); + const buttons=page.locator('#rules-list .tr:not(.th) button'); + for(let i=0;ic.endpoint==='automation/rules/list')); + }); + }); + test(language+': a lost save reply is observed without repeating the write',async()=>{ + await pageFor(language,'lost',async({page,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.saved')); + assert.equal(writes(),1); + }); + }); + test(language+': saved generation conflict requires a fresh preview',async()=>{ + await pageFor(language,'conflict',async({page,writes,saved})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.errors.revision_conflict')); + assert.equal(writes(),1);assert.equal(saved(),false); + assert(await page.locator('#import-rule-btn').isDisabled()); + }); + }); + test(language+': pending replacement retains only current trigger and export controls',async()=>{ + await pageFor(language,'old',async({page,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.querySelector('#rules-list .tr:not(.th)')); + const buttons=page.locator('#rules-list .tr:not(.th) button'); + for(const [index,disabled] of [[0,true],[1,false],[2,true],[3,false],[4,true]])assert.equal(await buttons.nth(index).isDisabled(),disabled); + assert.match(await buttons.nth(1).getAttribute('title'),language==='zh-CN'?/当前|旧/:/older/); + assert.equal(await buttons.nth(3).getAttribute('title'),await page.evaluate(()=>t('rulePack.exportCurrent'))); + assert.equal(writes(),1); + }); + }); + test(language+': recovery failure cannot confirm an uncertain save',async()=>{ + await pageFor(language,'unknown',async({page,calls,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.unknown')); + await page.waitForTimeout(100); + assert(calls.some(c=>c.endpoint==='automation/rules/list'));assert.equal(writes(),1); + assert.equal(await page.locator('#import-rule-result').innerText(),await page.evaluate(()=>t('rulePack.unknown'))); + assert(await page.locator('#import-rule-btn').isDisabled()); + }); + }); + test(language+': out-of-order previews cannot replace the newest selected bytes',async()=>{ + await pageFor(language,'ok',async({page,calls,writes})=>{ + await page.locator('#import-rule-file').setInputFiles({name:'slow.tscfg',buffer:Buffer.from('slow')}); + await page.waitForTimeout(20); + await page.locator('#import-rule-file').setInputFiles({name:'fast.tscfg',buffer:Buffer.from('fast')}); + await page.waitForFunction(()=>!document.getElementById('import-rule-btn').disabled); + await page.waitForTimeout(200); + assert.equal(await page.evaluate(()=>rulePackPreview.rule.name),'fast'); + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.saved')); + assert.equal(calls.find(c=>c.endpoint==='automation/rules/import'&&!c.params.preview).params.tscfg,'fast');assert.equal(writes(),1); + }); + }); + test(language+': an untrusted preview cannot enable import',async()=>{ + await pageFor(language,'ok',async({page,writes})=>{ + await page.locator('#import-rule-file').setInputFiles({name:'untrusted.tscfg',buffer:Buffer.from('untrusted')}); + await page.waitForFunction(()=>document.getElementById('import-rule-result').classList.contains('error')); + assert(await page.locator('#import-rule-btn').isDisabled());assert.equal(writes(),0); + }); + }); + test(language+': datasource switch works without a same-name rule',async()=>{ + await pageFor(language,'ok',async({page,calls})=>{ + await page.evaluate(async()=>{hideImportRuleModal();document.body.insertAdjacentHTML('beforeend','
');await refreshSources();}); + const button=page.locator('#sources-list button[role="switch"]'); + await button.click();await page.waitForFunction(()=>document.querySelector('#sources-list button[role="switch"]').getAttribute('aria-checked')==='true'); + await button.click();await page.waitForFunction(()=>document.querySelector('#sources-list button[role="switch"]').getAttribute('aria-checked')==='false'); + assert.equal(calls.filter(c=>c.endpoint==='automation/sources/enable').length,1); + assert.equal(calls.filter(c=>c.endpoint==='automation/sources/disable').length,1); + assert.equal(calls.filter(c=>c.endpoint==='automation/rules/get').length,0); + }); + }); +} diff --git a/tests/runtime/extract_engine.py b/tests/runtime/extract_engine.py index 5cf8426..861f003 100644 --- a/tests/runtime/extract_engine.py +++ b/tests/runtime/extract_engine.py @@ -2,8 +2,22 @@ import re s=Path('components/ts_automation/src/ts_rule_engine.c').read_text() a=s.index('typedef struct {\n ts_auto_rule_t *rules;');b=s.index('static void payload_free',a) -parts=[s[a:b]] -for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: +header=Path('components/ts_automation/src/ts_rule_store_set.h').read_text().replace('#pragma once','') +header=header.replace('#include "ts_rule_store.h"', '#include "'+str(Path('components/ts_automation/src/ts_rule_store.h').resolve())+'"') +header=header.replace('#include "ts_config_pack.h"', '#include "'+str(Path('components/ts_config_pack/include/ts_config_pack.h').resolve())+'"') +# These legacy engine tests deliberately have no saved-set instance. Full set/pack integration is tested separately. +stubs = r""" +#ifndef TS_RULE_SET_INTEGRATION_TEST +bool ts_rule_store_set_available(void){return false;} +bool ts_rule_store_set_present(void){return false;} +int ts_rule_store_set_count(void){return 0;} +uint32_t ts_rule_store_set_generation(void){return 0;} +esp_err_t ts_rule_store_set_info(const char *id,ts_rule_saved_info_t *out){return ESP_ERR_NOT_FOUND;} +esp_err_t ts_rule_store_set_commit(const ts_auto_rule_t*r,const char*id,uint32_t v,uint32_t g,const char*p,size_t n,const ts_config_pack_acceptance_t*a,ts_rule_commit_t*out){assert(!"unexpected saved-set commit in legacy engine test");return ESP_FAIL;} +#endif +""" +parts=[header,stubs,s[a:b]] +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','pending_id','rule_commit_impl','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name end=s.index('\n}',m.start())+2;parts.append(s[m.start():end]) Path('/tmp/tianshan-runtime-tests/engine.inc').write_text('\n'.join(parts)) diff --git a/tests/runtime/rule_pack/stubs/esp_err.h b/tests/runtime/rule_pack/stubs/esp_err.h new file mode 100644 index 0000000..f732eef --- /dev/null +++ b/tests/runtime/rule_pack/stubs/esp_err.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include +#include +typedef int esp_err_t; +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_NO_MEM 0x101 +#define ESP_ERR_INVALID_ARG 0x102 +#define ESP_ERR_INVALID_STATE 0x103 +#define ESP_ERR_INVALID_SIZE 0x104 +#define ESP_ERR_NOT_FOUND 0x105 +#define ESP_ERR_TIMEOUT 0x106 +#define ESP_ERR_NOT_SUPPORTED 0x107 +#define ESP_ERR_NVS_NOT_FOUND 0x1102 diff --git a/tests/runtime/rule_pack/stubs/esp_heap_caps.h b/tests/runtime/rule_pack/stubs/esp_heap_caps.h new file mode 100644 index 0000000..ab2dc38 --- /dev/null +++ b/tests/runtime/rule_pack/stubs/esp_heap_caps.h @@ -0,0 +1,6 @@ +#pragma once +#include +#define MALLOC_CAP_SPIRAM 1 +#define MALLOC_CAP_8BIT 2 +#define heap_caps_malloc(n,c) malloc(n) +#define heap_caps_calloc(n,s,c) calloc(n,s) diff --git a/tests/runtime/rule_pack/stubs/esp_log.h b/tests/runtime/rule_pack/stubs/esp_log.h new file mode 100644 index 0000000..d56bc66 --- /dev/null +++ b/tests/runtime/rule_pack/stubs/esp_log.h @@ -0,0 +1,5 @@ +#pragma once +#define ESP_LOGI(...) ((void)0) +#define ESP_LOGW(...) ((void)0) +#define ESP_LOGD(...) ((void)0) +#define ESP_LOGE(...) ((void)0) diff --git a/tests/runtime/rule_pack/stubs/nvs.h b/tests/runtime/rule_pack/stubs/nvs.h new file mode 100644 index 0000000..276729c --- /dev/null +++ b/tests/runtime/rule_pack/stubs/nvs.h @@ -0,0 +1,10 @@ +#pragma once +#include "esp_err.h" +typedef int nvs_handle_t; +#define NVS_READONLY 0 +#define NVS_READWRITE 1 +esp_err_t nvs_open(const char *,int,nvs_handle_t *); +void nvs_close(nvs_handle_t); +esp_err_t nvs_get_blob(nvs_handle_t,const char *,void *,size_t *); +esp_err_t nvs_set_blob(nvs_handle_t,const char *,const void *,size_t); +esp_err_t nvs_commit(nvs_handle_t); diff --git a/tests/runtime/rule_pack/stubs/ts_action_manager.h b/tests/runtime/rule_pack/stubs/ts_action_manager.h new file mode 100644 index 0000000..bbe2f38 --- /dev/null +++ b/tests/runtime/rule_pack/stubs/ts_action_manager.h @@ -0,0 +1,14 @@ +#pragma once +#include "ts_automation_types.h" +typedef struct { + char id[TS_AUTO_NAME_MAX_LEN], name[TS_AUTO_LABEL_MAX_LEN], description[64]; + ts_auto_action_t action; bool enabled,async; + int64_t created_at,last_used_at; uint32_t use_count; +} ts_action_template_t; +esp_err_t ts_action_template_get(const char *, ts_action_template_t *); +bool ts_action_templates_ready(void); +typedef struct { + char id[TS_AUTO_NAME_MAX_LEN],host[64];uint16_t port;char username[32],password[64],key_path[96];bool use_key_auth; +} ts_action_ssh_host_t; +esp_err_t ts_action_get_ssh_host(const char *,ts_action_ssh_host_t *); +esp_err_t ts_action_get_ssh_host_ex(const char *,ts_action_ssh_host_t *,bool *); diff --git a/tests/runtime/rule_pack/stubs/ts_core.h b/tests/runtime/rule_pack/stubs/ts_core.h new file mode 100644 index 0000000..fa22baf --- /dev/null +++ b/tests/runtime/rule_pack/stubs/ts_core.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include +#include +#define TS_MALLOC_PSRAM malloc +#define TS_CALLOC_PSRAM calloc +#define TS_STRDUP_PSRAM strdup +#define MALLOC_CAP_SPIRAM 1 +#define MALLOC_CAP_8BIT 2 +#define heap_caps_malloc(n,c) malloc(n) +#define heap_caps_calloc(n,s,c) calloc(n,s) diff --git a/tests/runtime/rule_pack/test_crypto.c b/tests/runtime/rule_pack/test_crypto.c new file mode 100644 index 0000000..5e367a1 --- /dev/null +++ b/tests/runtime/rule_pack/test_crypto.c @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include "ts_config_pack.h" +#include "ts_crypto.h" +#include "ts_cert.h" +#include "mbedtls/x509_crt.h" +#include "mbedtls/oid.h" +#include +static time_t fixture_time; +time_t time(time_t *out){time_t now=fixture_time?fixture_time:TEST_TIME;if(out)*out=now;return now;} +static char *device_key, *device_cert, *ca; +static uint32_t generation=1; +static bool trusted_time=true; +static char *read_file(const char *dir,const char *name) { + char path[512];snprintf(path,sizeof path,"%s/%s",dir,name); + FILE *f=fopen(path,"rb");assert(f);fseek(f,0,SEEK_END);long n=ftell(f);rewind(f); + char *s=malloc(n+1);assert(fread(s,1,n,f)==(size_t)n);s[n]=0;fclose(f);return s; +} +static esp_err_t copy(const char *s,char *out,size_t *n) { + size_t len=strlen(s)+1;if(*nnext){ + char *out=NULL;size_t cap=0; + if(!MBEDTLS_OID_CMP(MBEDTLS_OID_AT_ORG_UNIT,&p->oid)){out=info->subject_ou;cap=sizeof info->subject_ou;} + if(!MBEDTLS_OID_CMP(MBEDTLS_OID_AT_CN,&p->oid)){out=info->subject_cn;cap=sizeof info->subject_cn;} + if(out){size_t len=p->val.lenval.len:cap-1;memcpy(out,p->val.p,len);out[len]=0;} + } + mbedtls_x509_crt_free(&c);return ESP_OK; +} +esp_err_t ts_cert_get_info(ts_cert_info_t *i){return ts_cert_parse_certificate(device_cert,strlen(device_cert)+1,i);} +esp_err_t ts_cert_get_status(ts_cert_pki_status_t *s){memset(s,0,sizeof *s);s->time_ready=trusted_time;s->generation=generation;return ESP_OK;} +esp_err_t ts_cert_get_pack_snapshot(ts_cert_snapshot_t *s){ + memset(s,0,sizeof *s);s->key=strdup(device_key);s->certificate=strdup(device_cert);s->ca=strdup(ca);s->generation=generation;return ESP_OK; +} +void ts_cert_free_snapshot(ts_cert_snapshot_t *s){if(s->key)memset(s->key,0,strlen(s->key));free(s->key);free(s->certificate);free(s->ca);memset(s,0,sizeof *s);} +int main(int argc,char **argv) { + assert(argc==2);const char *dir=argv[1]; + device_key=read_file(dir,"developer.key");device_cert=read_file(dir,"developer.pem");ca=read_file(dir,"root.pem"); + assert(ts_config_pack_init()==ESP_OK); + char *recipient=read_file(dir,"recipient.pem"); + const char *content="{\"type\":\"automation_rule\",\"rule\":{\"id\":\"night\",\"name\":\"Night\",\"enabled\":true,\"actions\":[{\"type\":\"log\",\"message\":\"test\"}]}}"; + ts_config_pack_export_opts_t opts={.recipient_cert_pem=recipient,.recipient_cert_len=strlen(recipient)}; +#ifndef PACK_BASELINE + char *exact=malloc(opts.recipient_cert_len);memcpy(exact,recipient,opts.recipient_cert_len);opts.recipient_cert_pem=exact; +#endif + char *bytes=NULL;size_t length=0; + ts_config_pack_result_t made=ts_config_pack_create("ignored-name",content,strlen(content),&opts,&bytes,&length); +#ifndef PACK_BASELINE + free(exact); +#endif +#ifdef PACK_BASELINE + printf("baseline explicit recipient PEM length: create=%d (expected OK)\n",made); + fflush(stdout); + free(bytes);free(recipient);free(device_key);free(device_cert);free(ca); + assert(made==TS_CONFIG_PACK_OK); +#elif defined(PACK_UNCONFIGURED) + assert(made==TS_CONFIG_PACK_OK); + free(device_key);free(device_cert);device_key=read_file(dir,"recipient.key");device_cert=strdup(recipient); + ts_config_pack_t *pack=NULL; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED&&!pack); + free(bytes);free(recipient);free(device_key);free(device_cert);free(ca); + puts("PASS unconfigured signing root refuses a cryptographically valid package"); +#else + assert(made==TS_CONFIG_PACK_OK&&bytes&&length==strlen(bytes)); + ts_config_pack_export_opts_t self_opts={.recipient_cert_pem=device_cert,.recipient_cert_len=strlen(device_cert)+1}; + char *self=NULL;size_t self_len=0;ts_config_pack_t *self_pack=NULL; + assert(ts_config_pack_create("self",content,strlen(content),&self_opts,&self,&self_len)==TS_CONFIG_PACK_OK); + assert(ts_config_pack_load_verified_mem(self,self_len,NULL,&self_pack,NULL,NULL)==TS_CONFIG_PACK_OK&&!strcmp(self_pack->content,content)); + ts_config_pack_free(self_pack);free(self); + free(device_key);free(device_cert);device_key=read_file(dir,"recipient.key");device_cert=strdup(recipient); + ts_config_pack_t *pack=NULL;ts_config_pack_acceptance_t accepted={0};uint32_t gen=0; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,&accepted,&gen)==TS_CONFIG_PACK_OK); + assert(gen==generation&&!strcmp(pack->content,content));ts_config_pack_free(pack); + /* Expiry is checked when first accepting; accepted exact bytes may reload. */ + fixture_time=TEST_TIME+4000LL*86400; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_CERT_CHAIN); + assert(ts_config_pack_load_verified_mem(bytes,length,&accepted,&pack,NULL,NULL)==TS_CONFIG_PACK_OK);ts_config_pack_free(pack); + fixture_time=0; + ts_config_pack_acceptance_t changed=accepted;changed.policy++; + assert(ts_config_pack_load_verified_mem(bytes,length,&changed,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_CERT_CHAIN); + changed=accepted;changed.root_sha256[0]^=1; + assert(ts_config_pack_load_verified_mem(bytes,length,&changed,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_CERT_CHAIN); + trusted_time=false; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_TIME_UNVERIFIED); + assert(ts_config_pack_load_verified_mem(bytes,length,&accepted,&pack,NULL,NULL)==TS_CONFIG_PACK_OK);ts_config_pack_free(pack); + trusted_time=true; + char *save=device_cert;device_cert=read_file(dir,"other.pem"); + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_RECIPIENT); + free(device_cert);device_cert=save; + const char *unauthorized[]={"not-developer.pem","tls-developer.pem","no-sign.pem","p384.pem","duplicate-role.pem"}; + for(unsigned i=0;ivaluestring;tag[0]=tag[0]=='A'?'B':'A';bad=cJSON_PrintUnformatted(j); + assert(ts_config_pack_load_verified_mem(bad,strlen(bad),NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_DECRYPT); + free(bad);cJSON_Delete(j); + j=cJSON_Parse(bytes);enc=cJSON_GetObjectItem(j,"encryption"); + cJSON_ReplaceItemInObject(enc,"iv",cJSON_CreateString("AA=="));bad=cJSON_PrintUnformatted(j); + assert(ts_config_pack_load_verified_mem(bad,strlen(bad),NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_PARSE); + free(bad);cJSON_Delete(j); + assert(!ts_config_pack_parse_json("{\"x\":1,\"x\":2}",13)); + assert(!ts_config_pack_parse_json("{} {}",5)); + assert(!ts_config_pack_parse_json("{\"x\":\"\\u0000\"}",14)); + assert(!ts_config_pack_parse_import_request("{\"tscfg\":\"ok\\u0000tail\"}",24)); + assert(!ts_config_pack_parse_import_request("{\"preview\":true,\"preview\":false}",32)); + cJSON *empty=ts_config_pack_parse_json("{}",2);assert(empty);cJSON_Delete(empty); + j=cJSON_Parse(bytes);char *cipher=cJSON_GetObjectItem(j,"payload")->valuestring; + cipher[0]=cipher[0]=='A'?'B':'A';bad=cJSON_PrintUnformatted(j); + assert(ts_config_pack_load_verified_mem(bad,strlen(bad),NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_SIGNATURE); + free(bad);cJSON_Delete(j); + free(bytes);free(recipient);free(device_key);free(device_cert);free(ca); + puts("PASS real production create/trust/signature/ECDH/HKDF/GCM, cross-device PEM, offline accepted reload, malformed inputs"); +#endif + return 0; +} diff --git a/tests/runtime/rule_pack/test_engine_pack.c b/tests/runtime/rule_pack/test_engine_pack.c new file mode 100644 index 0000000..0d5c155 --- /dev/null +++ b/tests/runtime/rule_pack/test_engine_pack.c @@ -0,0 +1,137 @@ +/* Production engine functions + real codec, crypto and complete-set storage. + * Only hardware/RTOS, credentials and NVS are fixture adapters. */ +#define RULE_ENGINE_INTEGRATION +#include "test_store_set.c" +#include +#include "ts_rule_engine.h" +#include "ts_rule_pack.h" +#define pdTRUE 1 +typedef struct {int code;char *message;cJSON *data;} ts_api_result_t; +#define TS_API_OK 0 +#define TS_API_ERR_INTERNAL 500 +static void ts_api_result_error(ts_api_result_t *r,int code,const char *why){r->code=code;r->message=strdup(why);} +typedef pthread_mutex_t *SemaphoreHandle_t; +#define portMAX_DELAY 0xffffffff +static int xSemaphoreTake(SemaphoreHandle_t m,unsigned timeout){return !pthread_mutex_lock(m);} +static int xSemaphoreGive(SemaphoreHandle_t m){return !pthread_mutex_unlock(m);} +#define xSemaphoreTakeRecursive xSemaphoreTake +#define xSemaphoreGiveRecursive xSemaphoreGive +static pthread_mutex_t binding,lock,transaction; +static bool sd_mounted=true; +void ts_ssh_binding_lock(void){assert(!pthread_mutex_lock(&binding));} +void ts_ssh_binding_unlock(void){assert(!pthread_mutex_unlock(&binding));} +bool ts_storage_sd_mounted(void){return sd_mounted;} +bool ts_ssh_service_rule_protected(const char *id){return false;} +bool ts_ssh_service_command_protected(const char *id){return false;} +bool ts_ssh_service_any_in_use(void){return false;} +bool ts_cert_material_begin(uint32_t wanted){return wanted==generation;} +void ts_cert_material_end(void){} +esp_err_t ts_rule_store_commit(const ts_auto_rule_t *r,int n,const ts_auto_rule_t *c,const char *id,int source,ts_rule_commit_t *out){assert(!"legacy writer must not run");return ESP_FAIL;} +esp_err_t ts_rule_store_recover(bool sd,bool *stage,bool *required){assert(!"selected-set load must not fall back to legacy");return ESP_FAIL;} +esp_err_t ts_rule_store_load_bank(ts_auto_rule_t *r,int cap,int *count){assert(0);return ESP_FAIL;} +static esp_err_t load_legacy_file(const char *p,ts_auto_rule_t *r,int *n){assert(0);return ESP_FAIL;} +static esp_err_t load_legacy_nvs(ts_auto_rule_t *r,int *n){assert(0);return ESP_FAIL;} +static esp_err_t load_directory(ts_auto_rule_t *r,int *n,bool *ro){assert(0);return ESP_FAIL;} +#define RULES_SDCARD_DIR DIR_RULES +static int find_rule_index(const char *id); +#include "pack_engine.inc" +static void boot(void){ + for(int i=0;ivalueint,cJSON_GetObjectItem(pre,"expected_generation")->valueint, + cJSON_GetObjectItem(pre,"credential_generation")->valueint,cJSON_GetObjectItem(pre,"package_digest")->valuestring,data,reason); +} +int main(int argc,char **argv){ + assert(argc==2);const char *dir=argv[1];pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE); + pthread_mutex_init(&binding,&attr);pthread_mutex_init(&transaction,&attr);pthread_mutex_init(&lock,NULL);pthread_mutexattr_destroy(&attr); + ca=read_file(dir,"root.pem");device_key=read_file(dir,"recipient.key");device_cert=read_file(dir,"recipient.pem");assert(ts_config_pack_init()==ESP_OK); + reset_disk();ts_auto_rule_t a=ordinary("a","Old A"),b=ordinary("b","B");ts_rule_commit_t stored; + assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); + assert(ts_rule_store_set_commit(&a,"a",0,0,NULL,0,NULL,&stored)==ESP_OK); + assert(ts_rule_store_set_commit(&b,"b",0,1,NULL,0,NULL,&stored)==ESP_OK);boot(); + char *raw=make_pack(dir,"a","Imported A",true);cJSON *pre=preview(raw),*data=NULL;const char *reason; + templates_ready=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_loading"));cJSON_Delete(data);data=NULL;templates_ready=true; + template_enabled=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_disabled"));cJSON_Delete(data);data=NULL;template_enabled=true; + ts_auto_rule_t held;assert(ts_rule_acquire("a",&held)==ESP_OK); + fail_selector_read=true; + assert(save(raw,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"commit_unknown")); + assert(s_rule_ctx.recovery_error&&!strcmp(s_rule_ctx.rules[0].name,"Old A")); + cJSON_Delete(data);data=NULL;fail_selector_read=false; + assert(ts_rule_refresh_saved()==ESP_OK&&!s_rule_ctx.recovery_error&&ts_rule_restart_pending()); + ts_api_result_t view={0};assert(api_automation_rules_list(NULL,&view)==ESP_OK&&view.code==TS_API_OK); + cJSON *rows=cJSON_GetObjectItem(view.data,"rules");assert(cJSON_GetArraySize(rows)==2); + cJSON *first=cJSON_GetArrayItem(rows,0);assert(cJSON_GetObjectItem(first,"revision")->valueint==1&&cJSON_GetObjectItem(first,"saved_revision")->valueint==2&&cJSON_IsTrue(cJSON_GetObjectItem(first,"restart_required"))); + assert(!strcmp(cJSON_GetObjectItem(first,"package_digest")->valuestring,cJSON_GetObjectItem(pre,"package_digest")->valuestring)); + cJSON_Delete(view.data);free(view.message); + assert(save(raw,pre,&reason,&data)==ESP_OK);assert(cJSON_IsFalse(cJSON_GetObjectItem(data,"runtime_applied")));cJSON_Delete(data);data=NULL; + assert(!strcmp(s_rule_ctx.rules[0].name,"Old A")&&s_rule_ctx.rules[0].revision==1&&!strcmp(held.name,"Old A")); + assert(ts_rule_restart_pending());ts_rule_commit_result_t result; + assert(ts_rule_commit(&a,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rules_load()!=ESP_OK&&ts_rules_load_from_file("anything")!=ESP_OK&&ts_rule_engine_deinit()!=ESP_OK); + ts_rule_release(&held);strcpy(b.name,"Edited B");assert(ts_rule_commit(&b,"b",1,&result)==ESP_OK&&s_rule_ctx.rules[1].revision==2); + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",NULL)!=ESP_OK); + ts_action_template_t tpl={.enabled=false,.action={.type=TS_AUTO_ACT_LOG}};assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&tpl)!=ESP_OK);tpl.enabled=true;assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&tpl)==ESP_OK); + trusted_time=false;assert(save(raw,pre,&reason,&data)==ESP_OK&&!strcmp(reason,"no_change"));cJSON_Delete(data);data=NULL;trusted_time=true;cJSON_Delete(pre); + command_exists=host_exists=true; + char *newraw=make_pack(dir,"new","New",2);pre=preview(newraw); + ++generation;assert(save(newraw,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"credential_changed"));cJSON_Delete(data);data=NULL;--generation; + strcpy(b.name,"B changed after preview");assert(ts_rule_commit(&b,"b",2,&result)==ESP_OK); + assert(save(newraw,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"revision_conflict"));cJSON_Delete(data);data=NULL;cJSON_Delete(pre);pre=preview(newraw); + assert(save(newraw,pre,&reason,&data)==ESP_OK);cJSON_Delete(data);data=NULL;cJSON_Delete(pre); + assert(s_rule_ctx.count==2&&ts_rule_store_set_count()==3); + assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",NULL)!=ESP_OK); + ts_ssh_command_config_t cmd;assert(ts_ssh_commands_config_get("command",&cmd)==ESP_OK);cmd.enabled=false; + assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",&cmd)!=ESP_OK);cmd.enabled=true; + strcpy(cmd.host_id,"missing");assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",&cmd)!=ESP_OK);strcpy(cmd.host_id,"host"); + assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",&cmd)==ESP_OK); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)!=ESP_OK); + internal_host=true;assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)==ESP_OK);internal_host=false; + /* Dependency preflight follows template priority and explicit readiness. */ + ts_auto_action_t ref={.type=TS_AUTO_ACT_SSH_CMD_REF};strcpy(ref.ssh_ref.cmd_id,"command"); + ts_auto_rule_t check=ordinary("check","Check");check.actions=&ref;cJSON *warnings=NULL; + command_state=ESP_ERR_INVALID_STATE;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_loading")); + command_state=ESP_FAIL;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_unavailable"));command_state=ESP_OK; + host_state=ESP_ERR_INVALID_STATE;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_loading")); + internal_host=true;assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK);internal_host=false;host_state=ESP_OK; + command_enabled=false;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_disabled")); + check.enabled=false;assert(ts_rule_pack_dependencies(&check,&reason,&warnings)==ESP_OK&&cJSON_GetArraySize(warnings)==1);cJSON_Delete(warnings);command_enabled=true; + check.enabled=true;strcpy(ref.template_id,"template");command_exists=false;assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK);command_exists=true; + cJSON *pending=ts_rule_pending_list();assert(cJSON_GetArraySize(pending)==1&&!strcmp(cJSON_GetObjectItem(cJSON_GetArrayItem(pending,0),"id")->valuestring,"new"));cJSON_Delete(pending); + ts_auto_rule_t c=ordinary("c","C");assert(ts_rule_commit(&c,"c",0,&result)==ESP_OK); + char *aba=make_pack(dir,"c","ABA C",0);pre=preview(aba); + assert(ts_rule_commit(NULL,"c",1,&result)==ESP_OK&&ts_rule_commit(&c,"c",0,&result)==ESP_OK); + assert(save(aba,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"revision_conflict"));cJSON_Delete(data);data=NULL;cJSON_Delete(pre);free(aba); + ts_auto_rule_t d=ordinary("d","D");assert(ts_rule_commit(&d,"d",0,&result)!=ESP_OK&&!strcmp(result.error_code,"capacity")); + trusted_time=false;boot();assert(!ts_rule_restart_pending()&&s_rule_ctx.count==4&&!strcmp(s_rule_ctx.rules[0].name,"Imported A")); + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",NULL)!=ESP_OK); + sd_mounted=false;assert(ts_rules_load()!=ESP_OK&&!s_rule_ctx.loaded&&s_rule_ctx.recovery_error);sd_mounted=true;assert(ts_rules_load()==ESP_OK&&s_rule_ctx.count==4); + assert(ts_rule_engine_deinit()==ESP_OK);ts_rule_store_set_reset();reset_disk(); + free(raw);free(newraw);free(device_key);free(device_cert);free(ca); + pthread_mutex_destroy(&binding);pthread_mutex_destroy(&transaction);pthread_mutex_destroy(&lock); + puts("PASS production import + engine + crypto + store: saved/active isolation, held old lease, pending edit/reload/deinit guards, unrelated merge, CAS/credential conflicts, offline idempotence/boot, dependency union, capacity and missing SD recovery"); +} diff --git a/tests/runtime/rule_pack/test_store_set.c b/tests/runtime/rule_pack/test_store_set.c new file mode 100644 index 0000000..9fe65e6 --- /dev/null +++ b/tests/runtime/rule_pack/test_store_set.c @@ -0,0 +1,242 @@ +#define main crypto_checks +#include "test_crypto.c" +#undef main +#include +#include +#include +#include +#include +#include "ts_rule_codec.h" +#include "ts_action_manager.h" +#include "ts_ssh_commands_config.h" +#include "ts_ssh_hosts_config.h" +#include "nvs.h" +static bool templates_ready=true, template_exists=true, template_enabled=true; +static bool command_exists,command_enabled=true,host_exists,internal_host; +static esp_err_t command_state=ESP_OK,host_state=ESP_OK; +static ts_auto_action_t template_action={.type=TS_AUTO_ACT_LOG}; +esp_err_t ts_action_template_get(const char *id,ts_action_template_t *t){ + if(!template_exists||strcmp(id,"template"))return ESP_ERR_NOT_FOUND; + memset(t,0,sizeof *t);strcpy(t->id,id);t->enabled=template_enabled;t->action=template_action;return ESP_OK; +} +bool ts_action_templates_ready(void){return templates_ready;} +bool ts_ssh_commands_config_is_initialized(void){return true;} +bool ts_ssh_hosts_config_is_initialized(void){return true;} +esp_err_t ts_ssh_commands_config_load_state(void){return command_state;} +esp_err_t ts_ssh_hosts_config_load_state(void){return host_state;} +esp_err_t ts_ssh_commands_config_get(const char *id,ts_ssh_command_config_t *c){if(!command_exists||strcmp(id,"command"))return ESP_ERR_NOT_FOUND;memset(c,0,sizeof *c);strcpy(c->id,id);strcpy(c->host_id,"host");c->enabled=command_enabled;return ESP_OK;} +esp_err_t ts_ssh_hosts_config_get(const char *id,ts_ssh_host_config_t *h){if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;h->enabled=false;return ESP_OK;} +esp_err_t ts_action_get_ssh_host_ex(const char *id,ts_action_ssh_host_t *h,bool *internal){*internal=internal_host;if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;return ESP_OK;} +esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *h){bool internal;return ts_action_get_ssh_host_ex(id,h,&internal);} +typedef struct {char key[24];void *data;size_t size;} value_t; +static value_t values[160]; +static unsigned operation, fault, mode; +static bool fail_selector_read; +static jmp_buf crash; +static bool before(void){ + ++operation; + if(operation!=fault)return false; + if(mode==3)longjmp(crash,1); + return mode==1; +} +static bool after(void){ + if(operation!=fault)return false; + if(mode==4)longjmp(crash,1); + return mode==2; +} +esp_err_t nvs_open(const char *ns,int access,nvs_handle_t *h){*h=1;return before()||after()?ESP_FAIL:ESP_OK;} +void nvs_close(nvs_handle_t h){} +esp_err_t nvs_commit(nvs_handle_t h){return before()||after()?ESP_FAIL:ESP_OK;} +static value_t *value(const char *key,bool add){ + for(unsigned i=0;i<160;++i)if(!strcmp(values[i].key,key))return &values[i]; + if(add)for(unsigned i=0;i<160;++i)if(!values[i].key[0]){strcpy(values[i].key,key);return &values[i];} + return NULL; +} +esp_err_t nvs_get_blob(nvs_handle_t h,const char *key,void *out,size_t *n){ + if(fail_selector_read&&!strcmp(key,"set_select"))return ESP_FAIL; + if(before())return ESP_FAIL;value_t *v=value(key,false);if(!v)return ESP_ERR_NVS_NOT_FOUND; + if(out){if(*nsize)return ESP_ERR_INVALID_SIZE;memcpy(out,v->data,v->size);}*n=v->size; + return after()?ESP_FAIL:ESP_OK; +} +esp_err_t nvs_set_blob(nvs_handle_t h,const char *key,const void *bytes,size_t n){ + if(before())return ESP_FAIL;value_t *v=value(key,true);if(!v)return ESP_ERR_NO_MEM; + free(v->data);v->data=malloc(n);assert(v->data);memcpy(v->data,bytes,n);v->size=n; + return after()?ESP_FAIL:ESP_OK; +} +static size_t write_fault(const void *p,size_t s,size_t n,FILE *f){ + if(before())return 0; + if(operation==fault&&mode==5){size_t part=n/2;fwrite(p,s,part,f);return part;} + size_t result=fwrite(p,s,n,f);return after()?0:result; +} +static FILE *open_fault(const char *path,const char *access){ + if(before())return NULL;FILE *f=fopen(path,access);if(after()){if(f)fclose(f);return NULL;}return f; +} +static size_t read_fault(void *p,size_t s,size_t n,FILE *f){ + if(before())return 0;if(operation==fault&&mode==5)return fread(p,s,n/2,f); + size_t r=fread(p,s,n,f);return after()?0:r; +} +static int seek_fault(FILE *f,long offset,int origin){if(before())return -1;int r=fseek(f,offset,origin);return after()?-1:r;} +static long tell_fault(FILE *f){if(before())return -1;long r=ftell(f);return after()?-1:r;} +static int flush_fault(FILE *f){if(before())return -1;int r=fflush(f);return after()?-1:r;} +static int sync_fault(int fd){if(before())return -1;int r=fsync(fd);return after()?-1:r;} +static int close_fault(FILE *f){bool fail=before();int r=fclose(f);return fail||after()?-1:r;} +static int unlink_fault(const char *p){if(before()){errno=EIO;return -1;}int r=unlink(p);if(after()){errno=EIO;return -1;}return r;} +#define fwrite write_fault +#define fopen open_fault +#define fread read_fault +#define fseek seek_fault +#define ftell tell_fault +#define fflush flush_fault +#define fsync sync_fault +#define fclose close_fault +#define unlink unlink_fault +#define DIR_RULES "/tmp/tianshan-rule-pack-tests/rules" +#include "../../../components/ts_automation/src/ts_rule_store_set.c" +#undef fwrite +#undef fopen +#undef fread +#undef fseek +#undef ftell +#undef fflush +#undef fsync +#undef fclose +#undef unlink +static void reset_disk(void){ + fail_selector_read=false; + fault=mode=operation=0;ts_rule_store_set_reset(); + for(unsigned i=0;i<160;++i){free(values[i].data);memset(&values[i],0,sizeof values[i]);} + DIR *d=opendir(OBJECT_DIR);struct dirent *e; + if(d){while((e=readdir(d)))if(e->d_name[0]!='.'){char p[512];snprintf(p,sizeof p,OBJECT_DIR "/%s",e->d_name);assert(!unlink(p));}closedir(d);} +} +static ts_auto_rule_t ordinary(const char *id,const char *name){ + static ts_auto_action_t action={.type=TS_AUTO_ACT_LOG}; + ts_auto_rule_t r={.actions=&action,.action_count=1,.enabled=true,.revision=1}; + strcpy(r.id,id);strcpy(r.name,name);return r; +} +static void boot_expect(bool has_pack){ + fault=mode=operation=0;ts_rule_store_set_reset(); + ts_auto_rule_t rules[4]={0};bool ro[4]={0};int count=0; + assert(ts_rule_store_set_load(true,rules,ro,4,&count)==ESP_OK); + assert(count==(has_pack?3:2)); + bool a=false,b=false,c=false; + for(int i=0;icontent,decoded->content_len,&actual)==ESP_OK); + ts_config_pack_free(decoded); + /* Legacy seeding re-reads the original file after verification. A changed + * file must not receive an acceptance record for different bytes. */ + char *legacy_changed=malloc(length+2);memcpy(legacy_changed,raw,length);legacy_changed[length]=' ';legacy_changed[length+1]=0; + assert(!new_record(&actual,TS_RULE_SET_SD_PACK,true,"legacy.tscfg",legacy_changed,length+1,&accepted)); + free(legacy_changed); + ts_auto_rule_t initial[]={ordinary("a","A"),ordinary("b","B")};bool readonly[]={false,false}; + unsigned cases=0; + for(unsigned m=1;m<=5;++m)for(unsigned step=1;step<160;++step){ + reset_disk();assert(ts_rule_store_set_adopt(initial,2,TS_RULE_SOURCE_NVS,readonly)==ESP_OK); + ts_rule_commit_t result;ts_auto_rule_t a=initial[0]; + /* Establish complete old set before injecting the import. */ + assert(ts_rule_store_set_commit(&a,"a",1,0,NULL,0,NULL,&result)==ESP_OK); + initial[0].revision=2; + /* Use a fresh A/B at revision 1 for assertions; establish via seed without mutation increment. */ + reset_disk();initial[0].revision=1; + assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); + assert(ts_rule_store_set_commit(&initial[0],"a",0,0,NULL,0,NULL,&result)==ESP_OK); + assert(ts_rule_store_set_commit(&initial[1],"b",0,1,NULL,0,NULL,&result)==ESP_OK); + operation=0;fault=step;mode=m; + esp_err_t ret=ESP_FAIL; + if(!setjmp(crash))ret=ts_rule_store_set_commit(&actual,actual.id,0,2,raw,length,&accepted,&result); + fault=mode=0; + value_t *selector=value("set_select",false);assert(selector&&selector->size==sizeof(set_selector_t)); + set_selector_t durable;memcpy(&durable,selector->data,sizeof durable); + assert(selector_valid(&durable));bool committed=durable.generation==3;assert(committed||durable.generation==2); + if(m!=3&&m!=4&&ret==ESP_OK)assert(committed&&result.durable==1); + trusted_time=false;boot_expect(committed);boot_expect(committed);trusted_time=true; + ++cases; + } + reset_disk(); + assert(ts_rule_store_set_adopt(initial,2,TS_RULE_SOURCE_NVS,readonly)==ESP_OK); + ts_rule_commit_t result;assert(ts_rule_store_set_commit(&actual,actual.id,0,0,raw,length,&accepted,&result)==ESP_OK); + assert(ts_rule_store_set_count()==3); + uint32_t gen=ts_rule_store_set_generation(); + assert(ts_rule_store_set_commit(&actual,actual.id,1,gen,raw,length,&accepted,&result)==ESP_OK); + assert(ts_rule_store_set_generation()==gen&&!strcmp(result.error_code,"no_change")); + ts_auto_rule_t edited=ordinary("a","Changed A"); + assert(ts_rule_store_set_commit(&edited,"a",1,gen,NULL,0,NULL,&result)==ESP_OK); + ts_auto_rule_t restored[4]={0};bool ro[4];int n; + ts_rule_store_set_reset();assert(ts_rule_store_set_load(true,restored,ro,4,&n)==ESP_OK&&n==3); + for(int i=0;i real pack -> complete-set store -> offline boot: %u save, %u migration and %u recovery fault cases; full sources/revisions/readonly, idempotence, unrelated edit, SD absence and same-generation JSON mirror\n",cases,migrations,recovery_cases); +} +#endif diff --git a/tests/runtime/rule_pack_contract.txt b/tests/runtime/rule_pack_contract.txt new file mode 100644 index 0000000..77df358 --- /dev/null +++ b/tests/runtime/rule_pack_contract.txt @@ -0,0 +1,42 @@ +Implementation base: main at bdc0354b; independent branch fix/rule-pack-import-standalone. +Claude prompt fixes remain separate; only his one-line datasource toggle routing fix is included with independent click tests. +The existing dirty source-switch test and main's untracked guides remain in their original worktrees. + +Save-only contract: imported packs alter the durable set; the active set and leased payloads remain unchanged until boot. +Authority: one versioned selector chooses one bounded complete bank. Each record has a local revision, source, +readonly reason, exact bytes hash/length, managed object name, and (for packs) a local acceptance record. +Existing ordinary records stay ordinary. Imported pack plaintext is never added to a new NVS mirror. +Legacy transactions are recovered before migration. Legacy authority is retained until the new selector is confirmed. +Boot migration commits the fully validated legacy set without incrementing individual revisions. Legacy encrypted +packages receive a durable acceptance record and immutable byte copy before being published, so a later offline +boot does not lose the acceptance evidence. Custom read-only single-file sources retain their original policy. +Ordinary SD JSON records retain their same-generation inline NVS mirror and SD source on edits; new COW object +paths are internal. Legacy visible filenames remain untouched and are no longer authoritative after selection. + +Commit states: +1. Prepare immutable managed objects outside the legacy scanner, sync/close and verify exact bytes. +2. Write and verify every inactive-bank record and its aggregate metadata. +3. Write the sole selector, commit, and read it back. A confirmed new selector commits the complete new set. +4. Confirmed old selector means not committed; unreadable/ambiguous selector means commit_unknown and closes writes. +5. Post-commit object cleanup cannot roll back authority; cleanup is bounded and repeatable. +Boot chooses precisely the selected set. No fallback to older banks, directory shadows or decrypted pack mirrors. +Healthy storage after an injected failure must recover the selected old/new set; permanent loss reports failure. + +Trust: explicitly configured signing-root digest must match a root in the installed CA material; exact Developer OU +and applicable certificate usage must authorize signing. New imports require trusted time. Accepted package bytes, +root/signer/device identity and policy are stored with the record so unchanged packages can reload offline after expiry. +The v1 recipient fingerprint remains the trimmed-PEM hash. Root/recipient/policy change invalidates acceptance. +An unset signing-root configuration is a deployment prerequisite, never a reason to bypass verification. +Deployment facts not established here: the real device's installed signing root, Developer certificate usage, +trusted clock and available NVS/SD space. No CA, credential, device setting or firmware was changed by testing. +There is no promised downgrade after committing v3 sets: older releases do not understand set_select and may scan +stale legacy files/NVS. Retain this firmware or a future v3-aware build; explicit backup/restore is needed before +using an older release. Historical plaintext banks are preserved, not securely erased. + +Fault matrix: fail-before/effect-then-error/crash/short-write/sync-close failure at each persistent operation; FAT +rename refuses occupied targets; bank preparation, selector uncertainty and cleanup/recovery interrupted repeatedly. +Assertions compare the full set, source, revision and readonly state, reset all production memory between boots, +and never exempt healthy-storage failures because recover returned an error. +Run: IDF_PATH=/path/to/esp-idf bash tests/runtime/run_rule_pack.sh (also included by run_all.sh). +The fixture builds the SDK's native Mbed TLS and generates disposable OpenSSL PKI; no installed device credential +is read. Browser tests: node --test tests/prompts/rule-pack.browser.test.cjs with the existing Playwright dependency. diff --git a/tests/runtime/run_all.sh b/tests/runtime/run_all.sh index cba25f4..e67d273 100755 --- a/tests/runtime/run_all.sh +++ b/tests/runtime/run_all.sh @@ -5,6 +5,7 @@ export DEVELOPER_DIR="${DEVELOPER_DIR:-/Library/Developer/CommandLineTools}" for suite in log codec store engine ssh service_watch; do bash "tests/runtime/run_${suite}.sh" done +bash tests/runtime/run_rule_pack.sh python3 tests/runtime/test_completion.py python3 tests/runtime/test_action_submission.py python3 tests/runtime/test_service_control.py diff --git a/tests/runtime/run_rule_pack.sh b/tests/runtime/run_rule_pack.sh new file mode 100755 index 0000000..bd2e222 --- /dev/null +++ b/tests/runtime/run_rule_pack.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Library/Developer/CommandLineTools}" +# Sequential: these production tests intentionally share one disposable NVS/SD fixture. +python3 tests/runtime/test_rule_pack_crypto.py +python3 tests/runtime/test_rule_pack_crypto.py --unconfigured +python3 tests/runtime/test_rule_pack_crypto.py --store +python3 tests/runtime/test_rule_pack_crypto.py --engine diff --git a/tests/runtime/test_action_pack.py b/tests/runtime/test_action_pack.py index de59b67..722c6ca 100644 --- a/tests/runtime/test_action_pack.py +++ b/tests/runtime/test_action_pack.py @@ -9,7 +9,12 @@ def extract(path,name): source=Path(path).read_text();m=re.search(r'^(?:static )?(?:\w+[ \t]+)+[ \t*]*'+name+r'\([^;]+?\)\s*\{',source,re.M);assert m,name return source[m.start():source.index('\n}',m.start())+2]+'\n' +code=code.replace('int template_count;', 'int template_count; bool templates_ready;') code+=r''' +#include "ts_rule_engine.h" +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t k,const char *id,const void *next){return ESP_OK;} +void ts_ssh_binding_lock(void){} +void ts_ssh_binding_unlock(void){} #include #define TS_CONFIG_PACK_OK 0 typedef int ts_config_pack_result_t; @@ -38,6 +43,7 @@ def extract(path,name): esp_err_t nvs_get_u8(nvs_handle_t h,const char *k,uint8_t *count){++fallbacks;*count=0;return ESP_ERR_NOT_FOUND;} esp_err_t nvs_get_str(nvs_handle_t h,const char *k,char *s,size_t *n){++fallbacks;return ESP_ERR_NOT_FOUND;} ''' +code+=extract('components/ts_automation/src/ts_action_manager.c','templates_load_impl') code+=extract('components/ts_automation/src/ts_action_manager.c','ts_action_templates_load') code+=r''' static void put(const char *p,const char *s){FILE*f=fopen(p,"wb");assert(f);assert(fwrite(s,1,strlen(s),f)==strlen(s));fclose(f);} diff --git a/tests/runtime/test_action_submission.py b/tests/runtime/test_action_submission.py index 212a50d..ebc18b1 100644 --- a/tests/runtime/test_action_submission.py +++ b/tests/runtime/test_action_submission.py @@ -39,7 +39,7 @@ def typedef(name): typedef(name) for name in ['action_manager_ctx_t', 'action_binding_t', 'action_completion_t'])) definitions = [function(manager, name) for name in [ 'completion_release', 'ts_action_snapshot_retain', 'ts_action_snapshot_release', - 'ts_action_get_ssh_host', 'snapshot_command', 'ts_action_snapshot', 'action_admit', + 'ts_action_get_ssh_host_ex', 'ts_action_get_ssh_host', 'snapshot_command', 'ts_action_snapshot', 'action_admit', 'action_finished', 'direct_service_finished', 'entry_finished', 'ts_action_manager_accepting', 'ts_action_manager_quiesce', 'ts_action_manager_resume', 'prepare_action_entry', 'action_manager_execute', 'ts_action_manager_execute', diff --git a/tests/runtime/test_configuration_protocol.py b/tests/runtime/test_configuration_protocol.py index a190fba..ee97f7f 100644 --- a/tests/runtime/test_configuration_protocol.py +++ b/tests/runtime/test_configuration_protocol.py @@ -15,6 +15,8 @@ def extract(path,name): s=s.replace('out->port=22;return ESP_OK;}', 'out->port=22;if(interleave)host_interleave();return ESP_OK;}') s=s.replace('(*out)->config=*cfg;++ssh_live;', '(*out)->config=*cfg;strcpy(connected_host,cfg->host);++ssh_live;') s+=''' +#include "ts_rule_engine.h" +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next){return ESP_OK;} static esp_err_t host_add_impl(const ts_ssh_host_config_t *cfg,bool sync_sdcard) {strcpy(configured_host,cfg->host);return ESP_OK;} static esp_err_t command_add_impl(const ts_ssh_command_config_t *cfg,char*out,size_t len) {command=*cfg;return ESP_OK;} static int storage_fail; diff --git a/tests/runtime/test_keystore.py b/tests/runtime/test_keystore.py index 8562560..8f90dd4 100644 --- a/tests/runtime/test_keystore.py +++ b/tests/runtime/test_keystore.py @@ -10,6 +10,9 @@ web_source=(root/'components/ts_webui/src/ts_webui_api.c').read_text() a=web_source.index('static esp_err_t send_key_generate_result(');web_handler=web_source[a:web_source.index('\nstatic esp_err_t',a+1)] a=web_source.index('static esp_err_t api_handler(');web_handler+=web_source[a:web_source.index('\nstatic esp_err_t login_handler',a)] +a=web_source.index('static bool is_binary_upload_complete(');body_complete=web_source[a:web_source.index('\n}',a)+2] +pack_source=(root/'components/ts_config_pack/src/ts_config_pack.c').read_text() +a=pack_source.index('static bool unambiguous_json(');strict_json=pack_source[a:pack_source.index('\nstatic const char *TAG',a)] source=re.sub(r'^#include .*$', '',source,flags=re.M) platform=(root/'tests/certificate/stubs/platform.h').read_text() platform=platform[:platform.index('typedef void *ts_keypair_t;')]+'\n#endif\n' @@ -21,17 +24,21 @@ #include "ts_crypto.h" #include "ts_api.h" #include +#include +#define CONFIG_TS_CONFIG_PACK_MAX_SIZE 65536 #define TS_LOGI(...) ((void)0) #define TS_LOGE(...) ((void)0) #define TS_LOGD(...) ((void)0) #define API_PREFIX "/api/v1" #define TS_API_MALLOC malloc -typedef struct {const char *uri;const char *body;size_t body_len;int method;void *req;} ts_http_request_t; -static size_t httpd_req_get_url_query_len(void *p){return 0;} +typedef struct {size_t content_len,query_len;} test_http_req_t; +typedef struct {const char *uri;const char *body;size_t body_len;int method;test_http_req_t *req;} ts_http_request_t; +static size_t httpd_req_get_url_query_len(void *p){return p?((test_http_req_t *)p)->query_len:0;} static esp_err_t httpd_req_get_url_query_str(void *p,char *b,size_t n){return ESP_FAIL;} esp_err_t ts_http_send_json(ts_http_request_t *,int,const char *); esp_err_t ts_http_send_error(ts_http_request_t *,int,const char *); static int64_t esp_timer_get_time(void){return 0;} +static unsigned rule_dispatch_calls; #include "cJSON.h" static size_t allocation_size_fail; static void *key_alloc(size_t n){return n==allocation_size_fail?NULL:malloc(n);} @@ -92,6 +99,7 @@ esp_err_t ts_crypto_keypair_export_openssh(ts_keypair_t p,char *b,size_t *n,const char *c){if(public_error)return public_error;strcpy(b,"SYNTHETIC");*n=10;return ESP_OK;} esp_err_t ts_api_call(const char *name,const cJSON *p,ts_api_result_t *r){ if(!strcmp(name,"key.generate"))return api_key_generate(p,r); + if(!strcmp(name,"automation.rules.import")){++rule_dispatch_calls;r->code=0;return ESP_OK;} r->code=TS_API_ERR_INTERNAL;r->message=strdup("fixture");r->data=cJSON_CreateObject();cJSON_AddBoolToObject(r->data,"should_omit",true);return ESP_FAIL; } void ts_api_result_error(ts_api_result_t *r,ts_api_result_code_t c,const char *s){r->code=c;r->message=strdup(s);} @@ -179,13 +187,20 @@ req.uri="/api/v1/other/endpoint";assert(api_handler(&req,NULL)==ESP_OK);response=cJSON_Parse(sent_json);assert(!cJSON_GetObjectItem(response,"data"));cJSON_Delete(response); req.uri="/api/v1/key/generate";cJSON_Hooks fail_request={always_fail,free};cJSON_InitHooks(&fail_request);assert(api_handler(&req,NULL)==ESP_OK);cJSON_InitHooks(NULL);assert(send_status==500&&find("new_priv")); r=(ts_api_result_t){.code=0,.data=cJSON_CreateObject()};cJSON_AddBoolToObject(r.data,"generated",true);cJSON_Hooks hooks={always_fail,free};cJSON_InitHooks(&hooks);send_key_generate_result(NULL,&r,"test",0);cJSON_InitHooks(NULL);assert(send_status==500&&find("new_priv")); + const char *invalid[]={"{\"tscfg\":\"{}\\u0000tail\"}","{\"preview\":true,\"preview\":false}","{} {}","[]"}; + test_http_req_t native={0};req=(ts_http_request_t){.uri="/api/v1/automation/rules/import",.req=&native}; + for(unsigned i=0;i\n#include \n#include \n' + entry + '\n' + state code += r''' +#include "ts_rule_engine.h" +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next){return ESP_OK;} #define NVS_KEY_PREFIX "h_" static char sd_dir[256]; #undef TS_SSH_HOSTS_SDCARD_DIR @@ -40,6 +42,7 @@ def extract(name): static int import_result; static ts_ssh_host_config_t concurrent_host; static TaskHandle_t initial_loader; +static atomic_int initial_load_result=ESP_ERR_INVALID_STATE; static bool s_hosts_pending_export; static int slot(const char *key) { int n=-1; assert(sscanf(key,"h_%d",&n)==1 && n>=0 && n Date: Sat, 10 Oct 2026 09:44:28 +0800 Subject: [PATCH 16/29] =?UTF-8?q?test:=20=E5=A3=B0=E6=98=8E=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=BC=95=E7=94=A8=E4=B8=BB=E6=9C=BA=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=89=80=E9=9C=80=E7=9A=84=20POSIX=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/repair/delete-protection/RESULT.md | 11 +++++++++++ .../delete-protection/evidence/ci-posix-local.log | 2 ++ .../delete-protection/evidence/source-hashes.json | 2 +- tests/runtime/test_delete_reference.py | 3 ++- 4 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 docs/repair/delete-protection/evidence/ci-posix-local.log diff --git a/docs/repair/delete-protection/RESULT.md b/docs/repair/delete-protection/RESULT.md index 2763114..d80d405 100644 --- a/docs/repair/delete-protection/RESULT.md +++ b/docs/repair/delete-protection/RESULT.md @@ -102,3 +102,14 @@ [source-hashes.json](evidence/source-hashes.json)现对应本轮源码;[source-hashes-before-round3.json](evidence/source-hashes-before-round3.json)保留上一轮源码记录,旧工件哈希不变。复审可用[round3.patch](evidence/round3.patch)查看相对上一轮未提交实现的精确增量,而不只与 main 比较。补丁采用无上下文格式,避免空白上下文行被 Git 当作尾随空格;需要应用时使用 git apply --unidiff-zero。 方案无实质偏差,没有需要用户另作决定的事项。固件、通用HTTP出口、锁顺序、committing及api.js/CSS等受保护文件与本轮开始时逐字相同;main源码未改。未提交、推送、开PR、合并或刷机,未修改其他分支/工作目录。 + + +## PR CI 补记:Linux 严格 C11 的 POSIX 接口声明 + +PR #47 的运行 [37992607181](https://github.com/RMinte-AI/TianshanOS/actions/runs/37992607181) 中,WebUI 与固件构建通过,Runtime and fan regression tests 在新增主机测试的编译阶段失败,Release 跳过。 + +失败原因:`test_delete_reference.py` 使用 `-std=c11`,但没有声明所需 POSIX 接口;Ubuntu/glibc 隐藏了 PTHREAD_MUTEX_RECURSIVE 以及 strdup、nanosleep 的声明。macOS 的头文件行为不同,先前本地通过未证明 Linux 兼容。 + +修复仅在该主机测试编译参数中加入 `-D_POSIX_C_SOURCE=200809L`,覆盖它实际使用的 POSIX 接口,不修改固件、测试断言、响应夹具或发布工作流。 + +本地执行 `IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf python3 tests/runtime/test_delete_reference.py` 通过,原生产删除/提交场景和8份实际HTTP字节均通过;[ci-posix-local.log](evidence/ci-posix-local.log)。这里只证明本地主机回归,Linux结果以修复后的远端CI为准。当前源码哈希已同步;round3.patch仍是第三轮修订当时的历史增量,不包含本次CI修复。 diff --git a/docs/repair/delete-protection/evidence/ci-posix-local.log b/docs/repair/delete-protection/evidence/ci-posix-local.log new file mode 100644 index 0000000..c41ad75 --- /dev/null +++ b/docs/repair/delete-protection/evidence/ci-posix-local.log @@ -0,0 +1,2 @@ +PASS production delete + rule commit + HTTP: H01-H14, leases, no side effects, historical multiplicity, committing cleared, 47/63-byte fields +PASS exact HTTP byte fixtures: 8 diff --git a/docs/repair/delete-protection/evidence/source-hashes.json b/docs/repair/delete-protection/evidence/source-hashes.json index 1a96431..b3e8511 100644 --- a/docs/repair/delete-protection/evidence/source-hashes.json +++ b/docs/repair/delete-protection/evidence/source-hashes.json @@ -27,7 +27,7 @@ "tests/runtime/extract_engine.py": "f7917c42055ae1301792c127f3eafe775cff03f457564e474110b2bf5e4e7daf", "tests/runtime/run_all.sh": "a227fb5b280e120ec795dd851dffe0b5725b97c9724ef48fc67f4cd65feebb90", "tests/runtime/test_delete_api.py": "e13035af2b482b2dbd27529d340bdf759ba575f8b2cd96ad9d523474a7e5b7f4", - "tests/runtime/test_delete_reference.py": "73ff90a36df5e17dcfff25944390e8d9421583f38cf600f107c5661cff8ea1d4", + "tests/runtime/test_delete_reference.py": "7aad0c8571393bd1b5e4709cc1da15a626612cc4a3f29caddfed7794a2ea6ad5", "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c", "tests/prompts/static.test.cjs": "be2ea30d0e40ee116a19283add4973b7e4685faf65062f2ad903c6d37563ea94" } diff --git a/tests/runtime/test_delete_reference.py b/tests/runtime/test_delete_reference.py index c575130..1c4c591 100644 --- a/tests/runtime/test_delete_reference.py +++ b/tests/runtime/test_delete_reference.py @@ -144,7 +144,8 @@ def extract(file,name): d=Path(tmp);f=d/'test.c';f.write_text(code);wire=d/'wire';wire.mkdir() env={**os.environ,'DEVELOPER_DIR':os.environ.get('DEVELOPER_DIR','/Library/Developer/CommandLineTools')} includes=['tests/runtime/stubs','tests/certificate/stubs','components/ts_automation/include','components/ts_security/include','components/ts_api/include',str(idf/'components/json/cJSON')] - subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations',*[f'-I{x}' for x in includes],str(f),'components/ts_automation/src/ts_rule_codec.c','components/ts_automation/src/ts_action_filter.c',str(idf/'components/json/cJSON/cJSON.c'),'-lpthread','-lm','-o',str(d/'test')],check=True,env=env) + # The harness uses POSIX mutexes, strdup and nanosleep under strict C11 on Linux. + subprocess.run(['cc','-std=c11','-D_POSIX_C_SOURCE=200809L','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations',*[f'-I{x}' for x in includes],str(f),'components/ts_automation/src/ts_rule_codec.c','components/ts_automation/src/ts_action_filter.c',str(idf/'components/json/cJSON/cJSON.c'),'-lpthread','-lm','-o',str(d/'test')],check=True,env=env) subprocess.run([str(d/'test'),str(wire)],check=True,env=env) fixtures=Path('tests/fixtures/delete-reference') if os.environ.get('UPDATE_FIXTURES')=='1':fixtures.mkdir(parents=True,exist_ok=True) From 0e5be43b45a24fa7a915e4f77a55c8d9945873e3 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 09:58:31 +0800 Subject: [PATCH 17/29] =?UTF-8?q?feat(firmware):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8C=85=E6=A0=A1=E9=AA=8C=E3=80=81=E5=BA=94=E7=94=A8=E3=80=81?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=A4=B1=E8=B4=A5=E6=97=B6=E5=A6=82=E5=AE=9E?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=85=B7=E4=BD=93=E5=8E=9F=E5=9B=A0=E4=BB=A3?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- components/ts_api/src/ts_api_config_pack.c | 10 ++++++---- components/ts_webui/src/ts_webui_api.c | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/components/ts_api/src/ts_api_config_pack.c b/components/ts_api/src/ts_api_config_pack.c index 7705899..acc3cbe 100644 --- a/components/ts_api/src/ts_api_config_pack.c +++ b/components/ts_api/src/ts_api_config_pack.c @@ -11,6 +11,7 @@ #include "ts_api.h" #include "ts_config_pack.h" +#include "ts_api_pack.h" #include "ts_cert.h" #include "ts_log.h" #include "ts_storage.h" @@ -175,6 +176,7 @@ static esp_err_t api_config_pack_verify(const cJSON *params, ts_api_result_t *re cJSON_AddBoolToObject(data, "valid", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(data, "result_code", pack_result); cJSON_AddStringToObject(data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(data, "reason", ts_config_pack_reason(pack_result)); if (pack_result == TS_CONFIG_PACK_OK || sig_info.signer_cn[0] != '\0') { cJSON *sig = cJSON_CreateObject(); @@ -232,6 +234,7 @@ static esp_err_t api_config_pack_import(const cJSON *params, ts_api_result_t *re cJSON_AddBoolToObject(data, "valid", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(data, "result_code", pack_result); cJSON_AddStringToObject(data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(data, "reason", ts_config_pack_reason(pack_result)); cJSON_AddStringToObject(data, "path", path->valuestring); if (pack_result == TS_CONFIG_PACK_OK) { @@ -297,6 +300,7 @@ static esp_err_t api_config_pack_apply(const cJSON *params, ts_api_result_t *res cJSON_AddBoolToObject(data, "success", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(data, "result_code", pack_result); cJSON_AddStringToObject(data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(data, "reason", ts_config_pack_reason(pack_result)); cJSON_AddStringToObject(data, "path", path->valuestring); if (pack_result == TS_CONFIG_PACK_OK && applied_modules) { @@ -398,8 +402,7 @@ static esp_err_t api_config_pack_export(const cJSON *params, ts_api_result_t *re free(json_content); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, - ts_config_pack_strerror(pack_result)); + pack_failure(result, pack_result); return ESP_FAIL; } @@ -576,8 +579,7 @@ static esp_err_t api_config_pack_content(const cJSON *params, ts_api_result_t *r ); if (pack_result != TS_CONFIG_PACK_OK) { - ts_api_result_error(result, TS_API_ERR_INTERNAL, - ts_config_pack_strerror(pack_result)); + pack_failure(result, pack_result); return ESP_FAIL; } diff --git a/components/ts_webui/src/ts_webui_api.c b/components/ts_webui/src/ts_webui_api.c index c4b8c32..c51d316 100644 --- a/components/ts_webui/src/ts_webui_api.c +++ b/components/ts_webui/src/ts_webui_api.c @@ -632,6 +632,7 @@ static esp_err_t file_upload_handler(ts_http_request_t *req, void *user_data) cJSON_AddBoolToObject(validation, "valid", pack_result == TS_CONFIG_PACK_OK); cJSON_AddNumberToObject(validation, "result_code", pack_result); cJSON_AddStringToObject(validation, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(validation, "reason", ts_config_pack_reason(pack_result)); if (pack_result == TS_CONFIG_PACK_OK || sig_info.signer_cn[0] != '\0') { cJSON *sig = cJSON_CreateObject(); @@ -652,6 +653,7 @@ static esp_err_t file_upload_handler(ts_http_request_t *req, void *user_data) pack_result == TS_CONFIG_PACK_OK ? "success" : "error"); cJSON_AddNumberToObject(ws_data, "result_code", pack_result); cJSON_AddStringToObject(ws_data, "result_message", ts_config_pack_strerror(pack_result)); + cJSON_AddStringToObject(ws_data, "reason", ts_config_pack_reason(pack_result)); if (pack_result == TS_CONFIG_PACK_OK || sig_info.signer_cn[0] != '\0') { cJSON *ws_sig = cJSON_CreateObject(); From e59544356087041bd4a3a05da26e9ed3d273fa47 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 09:58:31 +0800 Subject: [PATCH 18/29] =?UTF-8?q?feat(webui):=20=E5=85=A8=E7=AB=99?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA=E8=BF=81?= =?UTF-8?q?=E5=88=B0=E7=BB=9F=E4=B8=80=E7=9B=AE=E5=BD=95=EF=BC=88=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E6=94=B9=E5=AF=86=E3=80=81=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E4=B8=8E=E9=87=8D=E5=90=AF=E3=80=81OTA=E3=80=81=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E3=80=81=E7=BD=91=E7=BB=9C=E3=80=81=E9=A3=8E=E6=89=87?= =?UTF-8?q?=E3=80=81LED=E3=80=81=E8=AF=81=E4=B9=A6=E4=B8=8E=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E3=80=81=E9=85=8D=E7=BD=AE=E5=8C=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- components/ts_webui/web/index.html | 10 +- components/ts_webui/web/js/api.js | 33 +- components/ts_webui/web/js/app.js | 478 ++++++++++------------- components/ts_webui/web/js/lang/en-US.js | 86 +++- components/ts_webui/web/js/lang/zh-CN.js | 86 +++- docs/repair/failure-messages/README.md | 19 +- tests/prompts/failure-catalog.test.cjs | 4 +- tests/prompts/flows.browser.test.cjs | 34 +- 8 files changed, 465 insertions(+), 285 deletions(-) diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index 078b65f..7b6d7de 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -7,8 +7,8 @@ - - + + @@ -88,7 +88,7 @@ - + - + diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index 9e08669..d2360d1 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -86,12 +86,16 @@ function requireApiSuccess(result, operation = '') { } return result; } +// A problem the page found by itself (nothing was sent, or an answer cannot be used): carries a catalogue reason for describeFailure. +function pageFailure(token, params = {}) { + return Object.assign(new Error(token), {name: 'PageFailure', failureToken: token, failureParams: params}); +} // --------------------------------------------------------------------------------------------------------------------- // User-facing failure text: ": . " // op key under failure.ops, e.g. 'rule.enable' (.failed, or .unknown when the outcome is not confirmed) // params values for the {name} / {host} ... placeholders of the operation and reason texts -// result API envelope {code, error | rawMessage, data}; error an ApiOperationError; token a reason the UI detected itself +// result API envelope {code, error | rawMessage, data}; error an ApiOperationError; token a reason the UI detected itself (an error made by pageFailure(token, params) carries one) // write true for an operation that changes something: an exception without an answer is then "unconfirmed" // The device reports the reason as a stable token in `error` (e.g. 'execution_busy'); the texts live in the language packs // (failure.reasons..{why,hint}). A device text the catalogue does not know never reaches the user: it falls back to an @@ -99,8 +103,25 @@ function requireApiSuccess(result, operation = '') { // --------------------------------------------------------------------------------------------------------------------- const FAILURE_LEGACY_TEXT = { // English texts the device sends without a reason token 'Memory allocation failed': 'no_memory', 'Rule not found': 'rule_not_found', 'Request timeout': 'timeout', 'Failed to fetch': 'network_lost', - 'SD card not mounted': 'sd_unavailable' + 'SD card not mounted': 'sd_unavailable', + 'Invalid username or password': 'credentials_invalid', 'Invalid credentials': 'credentials_invalid', 'Account locked due to too many failed attempts': 'account_locked', + 'Password must be 4-64 characters': 'password_length', 'Old password is incorrect': 'old_password_wrong', 'Root permission required': 'root_required', + 'Missing token': 'auth_required', 'Invalid or expired token': 'auth_required', 'Session expired': 'auth_required', + 'Cannot delete root directory': 'root_directory_protected', 'File not found': 'file_not_found', 'Source file not found': 'file_not_found', 'Directory not found': 'directory_not_found', + 'SD card already mounted': 'sd_already_mounted', 'Failed to mount SD card': 'sd_mount_failed', 'Failed to unmount SD card': 'sd_unmount_failed', + 'Failed to create directory': 'storage_failed', 'Failed to rename': 'storage_failed', 'Failed to delete': 'storage_failed', + 'USB MUX not configured': 'usb_mux_unconfigured', 'WiFi STA not connected': 'wifi_sta_not_connected', + 'Too many curve points': 'fan_curve_too_many', 'Invalid duty limits (0-100, min <= max)': 'fan_limits_invalid', + 'Too many variables (max 8)': 'temp_too_many_variables', 'Each variable needs a name': 'temp_name_missing', 'At least one variable must have a positive weight': 'temp_weight_required', + 'No private key exists. Generate key pair first.': 'cert_no_keypair', 'Key pair already exists. Use force=true to overwrite.': 'cert_keypair_exists', 'No certificate installed': 'cert_not_installed', + 'CSR fields must be strings': 'csr_invalid', 'Export requires Developer device (OU=Developer)': 'pack_not_allowed', 'Key not found': 'key_not_found', + 'Device not found': 'led_device_not_found', 'Effect not found': 'led_effect_not_found', 'Filter not found': 'led_filter_not_found', 'Font not found': 'led_font_not_found', + 'Image only supported on matrix': 'led_matrix_only', 'QR code only supported on matrix': 'led_matrix_only', 'Text only supported on matrix': 'led_matrix_only', + 'Text too long for QR code': 'qr_text_too_long', 'Failed to load image': 'led_image_unreadable', 'Failed to load background image': 'led_image_unreadable', + '无可用回滚分区': 'ota_no_rollback', '缺少 url 参数且未设置 OTA 服务器': 'ota_server_not_set', 'URL 格式无效': 'url_invalid', 'URL 过长': 'url_invalid', '文件不存在': 'file_not_found', '内存不足': 'no_memory', 'WWW OTA 已在运行': 'busy' }; +const FAILURE_LEGACY_PREFIX = [['AP config requires', 'ap_mode_required'], ['AP stations requires', 'ap_mode_required'], ['WiFi scan requires', 'sta_mode_required'], ['CSR device ID must fit', 'csr_invalid']]; +const legacyToken = raw => FAILURE_LEGACY_TEXT[raw] || (FAILURE_LEGACY_PREFIX.find(([prefix]) => raw.startsWith(prefix)) || [])[1]; const FAILURE_CODE_TOKEN = {1: 'invalid_request', 2: 'not_found', 3: 'no_permission', 4: 'busy', 5: 'timeout', 6: 'no_memory', 7: 'no_detail', 8: 'not_supported', 9: 'hardware_failed', 10: 'target_unreachable', 11: 'auth_required'}; const FAILURE_CODE_NAME = {INVALID_ARG: 1, NOT_FOUND: 2, NO_PERMISSION: 3, BUSY: 4, TIMEOUT: 5, NO_MEM: 6, INTERNAL: 7, @@ -110,7 +131,7 @@ function describeFailure({op, phase = 'failed', params = {}, result = null, erro const ready = typeof i18n !== 'undefined' && typeof t === 'function'; // numbers the device sends in `data` (e.g. an HTTP status) can fill the placeholders of the reason's text; the caller's params win const sent = Object.fromEntries(Object.entries(result?.data && typeof result.data === 'object' ? result.data : {}).filter(([, value]) => typeof value === 'number')); - params = {...sent, ...params}; + params = {...error?.failureParams, ...sent, ...params}; const has = (key, values = params) => ready && i18n.complete(key, values); const anything = new Proxy({}, {get: () => 'x'}); const exists = key => ready && i18n.complete(key, anything); // the text exists, whatever its placeholders need @@ -123,7 +144,7 @@ function describeFailure({op, phase = 'failed', params = {}, result = null, erro part(`failure.reasons.${tok}.why`) && part(`failure.reasons.${tok}.hint`); // Every failed request is an ApiOperationError; any other exception comes from the page's own code. const pageError = !!error && error.name !== 'ApiOperationError'; - let tok = token; + let tok = token || error?.failureToken || null; // (a request the page sends as GET can still change something: the caller says so with `write`) // write: true = changes something (unconfirmed when no answer came); false = a read or a test even if it was sent as POST; unset = by the request method const writeMayHaveHappened = write === true || (write === undefined && error?.uncertain); @@ -132,7 +153,7 @@ function describeFailure({op, phase = 'failed', params = {}, result = null, erro if (!tok && write && error && !(typeof error.code === 'number' && error.code !== 0)) tok = 'result_unknown'; if (!tok && pageError) tok = 'page_error'; if (!tok && known(raw)) tok = raw; - if (!tok && known(FAILURE_LEGACY_TEXT[raw])) tok = FAILURE_LEGACY_TEXT[raw]; + if (!tok && known(legacyToken(raw))) tok = legacyToken(raw); if (!tok) tok = FAILURE_CODE_TOKEN[numeric] || 'no_detail'; if (!known(tok)) tok = 'no_detail'; // e.g. a reason text whose placeholders the caller could not fill const unconfirmed = FAILURE_UNCONFIRMED.has(tok); @@ -152,7 +173,7 @@ function describeFailure({op, phase = 'failed', params = {}, result = null, erro const detail = device || (typeof result?.data?.detail === 'string' ? result.data.detail : ''); const stage = typeof result?.data?.stage === 'string' ? result.data.stage : ''; const api = endpoint || error?.operation || ''; - const page = pageError && typeof error.message === 'string' ? error.message : ''; + const page = pageError && !error.failureToken && typeof error.message === 'string' ? error.message : ''; // Details are for whoever has to look into it: a reason the page worked out by itself (nothing was sent) has none. const lines = !ready || !(api || detail || stage || page) ? [] : [ line('code', tok), line('api', api), line('device', detail), line('stage', stage), line('page', page), diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 9413411..cc4c4ed 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -298,17 +298,17 @@ document.getElementById('login-form')?.addEventListener('submit', async (e) => { } else { // 显示错误信息 if (errorEl) { - errorEl.textContent = result.message || t('login.loginFailed'); + errorEl.textContent = failureText({ op: 'account.login', result, endpoint: 'auth.login', write: false }); errorEl.classList.remove('hidden'); } - showToast(result.message || t('login.loginFailed'), 'error'); + reportFailure({ op: 'account.login', result, endpoint: 'auth.login', write: false }); } } catch (error) { if (errorEl) { - errorEl.textContent = error.message || t('login.networkError'); + errorEl.textContent = failureText({ op: 'account.login', error, endpoint: 'auth.login', write: false }); errorEl.classList.remove('hidden'); } - showToast(t('login.loginFailed') + ': ' + error.message, 'error'); + reportFailure({ op: 'account.login', error, endpoint: 'auth.login', write: false }); } finally { submitBtn.disabled = false; submitBtn.textContent = t('login.loginButton'); @@ -373,11 +373,11 @@ async function submitPasswordChange() { closePasswordChangeModal(); showToast(t('login.passwordChanged'), 'success'); } else { - errorEl.textContent = result.message || t('toast.saveFailed'); + errorEl.textContent = failureText({ op: 'account.changepassword', result, endpoint: 'auth.change_password', write: true }); errorEl.classList.remove('hidden'); } } catch (error) { - errorEl.textContent = error.message || t('login.networkError'); + errorEl.textContent = failureText({ op: 'account.changepassword', error, endpoint: 'auth.change_password', write: true }); errorEl.classList.remove('hidden'); } } @@ -1037,12 +1037,12 @@ async function toggleProtection() { } else { // 恢复原状态 updateProtectionUI(currentRunning); - showToast(t('system.switchFailed') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.switchfailed', result, write: true }); } } catch (e) { // 恢复原状态 updateProtectionUI(currentRunning); - showToast(t('system.switchFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.switchfailed', error: e, write: true }); } finally { if (icon) icon.style.pointerEvents = 'auto'; } @@ -1069,10 +1069,10 @@ async function toggleUsbMux() { updateUsbMuxButton(); showToast(t('system.usbSwitchedTo', { name: displayName }), 'success'); } else { - showToast(t('system.switchFailed') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.switchfailed', result, write: true }); } } catch (e) { - showToast(t('system.switchFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.switchfailed', error: e, write: true }); } } @@ -1117,10 +1117,10 @@ async function toggleAgxPower() { updateAgxPowerButton(); showToast(toastSuccess, 'success'); } else { - showToast(t('system.agxPowerFail') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.agxpowerfail', result, write: true }); } } catch (e) { - showToast(t('system.agxPowerFail') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.agxpowerfail', error: e, write: true }); } } @@ -1150,10 +1150,10 @@ async function toggleLpmuPower() { // 启动状态检测(传入之前的状态) startLpmuStatePolling(wasOnline); } else { - showToast(t('system.lpmuTriggerFail') + ': ' + (result.message || t('common.unknown')), 'error'); + reportFailure({ op: 'system.lpmutriggerfail', result, write: true }); } } catch (e) { - showToast(t('system.lpmuTriggerFail') + ': ' + e.message, 'error'); + reportFailure({ op: 'system.lpmutriggerfail', error: e, write: true }); } } @@ -1598,11 +1598,11 @@ async function applyTestTemp() { // 刷新风扇状态 await refreshFans(); } else { - showToast((typeof t === 'function' ? t('fan.setFailed', { msg: result.message }) : `设置失败: ${result.message}`), 'error'); + reportFailure({ op: 'fan.setfailed', result, write: true }); } } catch (e) { console.error('设置测试温度失败:', e); - showToast((typeof t === 'function' ? t('fan.setFailed', { msg: e.message }) : `设置失败: ${e.message}`), 'error'); + reportFailure({ op: 'fan.setfailed', error: e, write: true }); } } @@ -1620,11 +1620,11 @@ async function clearTestTemp() { // 刷新风扇状态 await refreshFans(); } else { - showToast((typeof t === 'function' ? t('fan.clearFailed', { msg: result.message }) : `清除失败: ${result.message}`), 'error'); + reportFailure({ op: 'fan.clearfailed', result, write: true }); } } catch (e) { console.error('清除测试温度失败:', e); - showToast((typeof t === 'function' ? t('fan.clearFailed', { msg: e.message }) : `清除失败: ${e.message}`), 'error'); + reportFailure({ op: 'fan.clearfailed', error: e, write: true }); } } @@ -1640,7 +1640,7 @@ async function setFanSpeed(id, speed) { const result = await api.fanSet(id, requested); if (!result || result.code !== 0 || (result.httpStatus && result.httpStatus >= 400)) { console.error('fan.set rejected', result); - showToast(t('fan.settingFailed', {id, speed: requested}), 'error'); + reportFailure({ op: 'fan.setspeed', params: { id }, result, endpoint: 'fan.set', write: true }); } else { showToast(t(result.data?.enabled === false ? 'fan.settingDisabled' : 'fan.speedSet', {id, speed: requested}), 'success'); } @@ -1650,11 +1650,11 @@ async function setFanSpeed(id, speed) { console.error('fan.set result unavailable', e); if (fanSpeedDrafts.get(id) === draft) fanSpeedDrafts.delete(id); if (e instanceof ApiOperationError && e.code !== undefined && !e.uncertain) { - showToast(t('fan.settingFailed', {id, speed: requested}), 'error'); + reportFailure({ op: 'fan.setspeed', params: { id }, error: e, endpoint: 'fan.set', write: true }); await refreshFans(); return; } - showToast(t('fan.settingUnknown'), 'warning'); + reportFailure({ op: 'fan.setspeed', params: { id }, error: e, endpoint: 'fan.set', write: true }, { type: 'warning' }); // Do not repeat a command whose outcome is unknown. A later status poll // supplies a current snapshot; it cannot prove the outcome of this command. markFanOutputsUnknown(id); @@ -1666,7 +1666,7 @@ async function setFanMode(id, mode) { requireApiSuccess(await api.call('fan.mode', { id: id, mode: mode }), 'call'); showToast(typeof t === 'function' ? t('fan.modeSwitch', { id, mode: t({off:'common.disabled', manual:'common.manual', auto:'fanPage.modeSmart', curve:'fan.curve'}[mode] || 'common.unknown') }) : `风扇 ${id} 模式已切换为 ${mode}`, 'success'); await refreshFans(); - } catch (e) { showToast((typeof t === 'function' ? t('fan.setFanModeFailed', { msg: e.message }) : '设置风扇模式失败: ' + e.message), 'error'); } + } catch (e) { reportFailure({ op: 'fan.setfanmodefailed', error: e, write: true }); } } function markFanOutputsUnknown(id = null) { @@ -2285,11 +2285,11 @@ async function bindTempVariable() { await loadTempSourceStatus(); } else { - showToast(t('fanPage.bindFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'fanpage.bindfailed', result, write: true }); } } catch (e) { console.error('绑定温度变量失败:', e); - showToast(t('fanPage.bindFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'fanpage.bindfailed', error: e, write: true }); } } @@ -2306,11 +2306,11 @@ async function unbindTempVariable() { await loadTempSourceStatus(); } else { - showToast(t('fanPage.unbindFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'fanpage.unbindfailed', result, write: true }); } } catch (e) { console.error('解绑温度变量失败:', e); - showToast(t('fanPage.unbindFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'fanpage.unbindfailed', error: e, write: true }); } } @@ -2357,9 +2357,7 @@ async function applyFanCurve() { max_duty: maxDuty }); - if (limitsResult.code !== 0) { - throw new Error(limitsResult.message || t('dataWidget.setDutyLimitFailed')); - } + requireApiSuccess(limitsResult, 'fan.limits'); // 2. 设置曲线(同时传递 hysteresis 和 min_interval,会自动保存到 NVS) const curveResult = await api.call('fan.curve', { @@ -2369,9 +2367,7 @@ async function applyFanCurve() { min_interval: minInterval }); - if (curveResult.code !== 0) { - throw new Error(curveResult.message || t('dataWidget.setCurveFailed')); - } + requireApiSuccess(curveResult, 'fan.curve'); // 3. 切换到曲线模式 const modeResult = await api.call('fan.mode', { @@ -2379,9 +2375,7 @@ async function applyFanCurve() { mode: 'curve' }); - if (modeResult.code !== 0) { - throw new Error(modeResult.message || t('promptRepair.modeFailed')); - } + requireApiSuccess(modeResult, 'fan.mode'); showToast(typeof t === 'function' ? t('fanPage.curveApplied', { id: fanId }) : `风扇 ${fanId} 曲线已应用并保存`, 'success'); closeFanCurveModal(); @@ -2391,7 +2385,7 @@ async function applyFanCurve() { } catch (e) { console.error('应用曲线失败:', e); - showToast((typeof t === 'function' ? t('fanPage.applyCurveFailed') : '应用曲线失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'fanpage.applycurvefailed', error: e, write: true }); } } @@ -2437,7 +2431,7 @@ async function exportFanCurveConfig() { await api.fileUpload(sdcardPath, blob); showToast(typeof t === 'function' ? t('fanPage.curveExported', { id: fanId, path: sdcardPath }) : `风扇 ${fanId} 曲线配置已导出(本地 + SD 卡 ${sdcardPath})`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('fanPage.curveExportSdFailed', { id: fanId, msg: e.message }) : `风扇 ${fanId} 曲线已保存到本地,SD 卡保存失败: ${e.message}`, 'warning'); + reportFailure({ op: 'fanpage.curveexportsdfailed', params: { id: fanId }, error: e, write: false }, { type: 'warning' }); } } @@ -2502,7 +2496,7 @@ function importFanCurveConfig() { showToast(typeof t === 'function' ? t('fanPage.configImported', { name: file.name }) : `已导入配置文件: ${file.name}`, 'success'); } catch (err) { console.error('导入配置失败:', err); - showToast((typeof t === 'function' ? t('fanPage.importConfigFailed') : '导入配置失败') + ': ' + err.message, 'error'); + reportFailure({ op: 'fanpage.importconfigfailed', error: err, write: true }); } }; @@ -2561,7 +2555,7 @@ async function serviceAction(name, action) { showToast(typeof t === 'function' ? t({start:'promptRepair.serviceStarted', stop:'promptRepair.serviceStopped', restart:'promptRepair.serviceRestarted'}[action], {name}) : `服务 ${name} ${action} 成功`, 'success'); await refreshSystemPage(); } catch (e) { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : `操作失败: ${e.message}`, 'error'); + reportFailure({ op: 'service.' + action, params: { name }, error: e, endpoint: 'service.' + action, write: true }); } } @@ -2575,7 +2569,7 @@ async function confirmReboot() { }) .catch((err) => { console.error('Reboot failed:', err); - showToast(t('system.rebootFailed') + ': ' + err.message, 'error'); + reportFailure({ op: 'device.reboot', error: err, endpoint: 'system.reboot', write: true }); }); } } @@ -2626,7 +2620,7 @@ async function refreshSystemLeds() { } catch (e) { if (!pageCurrent()) return; console.error('LED list error:', e); - container.innerHTML = `

${escapeHtml(typeof t === 'function' ? t('common.loadFailedMsg', { msg: e.message }) : '加载失败: ' + e.message)}

`; + container.innerHTML = `

${failureNote({ op: 'led.list', error: e, endpoint: 'led.list', write: false })}

`; } } @@ -3380,7 +3374,7 @@ async function refreshLogOnce(widgetId) { } catch (e) { if (!pageCurrent()) return; console.warn('获取日志变量失败:', e); - container.innerHTML = '
' + (typeof t === 'function' ? t('fanPage.readFailed') : '读取失败') + '
'; + container.innerHTML = '
' + failureNote({ op: 'fanpage.readlog', error: e, write: false }) + '
'; } } @@ -3874,12 +3868,12 @@ async function refreshQuickActions() { `; } } else { - container.innerHTML = '

' + (typeof t === 'function' ? (t('automationPage.loadQuickActionsFailed') || '无法加载快捷操作') : '无法加载快捷操作') + '

'; + container.innerHTML = '

' + failureNote({ op: 'home.quicklist', result, endpoint: 'automation.rules.list', write: false }) + '

'; } } catch (e) { if (!pageCurrent()) return; console.error('Quick actions error:', e); - container.innerHTML = '

' + (typeof t === 'function' ? t('filePage.loadFailed') : '加载失败') + '

'; + container.innerHTML = '

' + failureNote({ op: 'home.quicklist', error: e, endpoint: 'automation.rules.list', write: false }) + '

'; } } @@ -4009,7 +4003,7 @@ async function verifyServiceState(commandId) { } catch (e) { if (!isCurrent()) return; renderServiceCommand(commandId, {}); - showToast(e.code === 4 ? runtimeText('controlBusy') : e.message || runtimeText('outcomeUnknown'), 'warning'); + reportFailure({ op: 'service.verify', error: e, write: true }, { type: 'warning' }); } finally { advanceServiceState(commandId); serviceVerifyInFlight.delete(commandId); } } @@ -4300,7 +4294,7 @@ async function quickActionRefreshLog(logFile, hostId) { }); if (!contentEl.isConnected || document.getElementById('quick-log-content') !== contentEl) return; if (result.code !== 0 || !result.data) { - contentEl.textContent = (typeof t === 'function' ? t('automationPage.logFetchFailed') : '[获取失败]') + ' ' + (result.message || 'code=' + result.code); + contentEl.textContent = failureText({ op: 'automationpage.logfetchfailed', result, write: false }); return; } const _empty = typeof t === 'function' ? t('automationPage.logEmptyBracket') : '[空]'; @@ -4312,9 +4306,8 @@ async function quickActionRefreshLog(logFile, hostId) { } } catch (e) { if (!contentEl.isConnected || document.getElementById('quick-log-content') !== contentEl) return; - const _err = typeof t === 'function' ? t('automationPage.logError') : '[错误]'; const _retry = typeof t === 'function' ? t('automationPage.deviceBusyRetry') : '若设备繁忙可稍后重试。'; - contentEl.textContent = _err + ' ' + e.message + '\n\n' + _retry; + contentEl.textContent = failureText({ op: 'home.quicklog', error: e, endpoint: 'ssh.exec', write: false }) + '\n\n' + _retry; } finally { contentEl.dataset.loading = 'false'; } @@ -4449,7 +4442,7 @@ async function quickActionStopProcess(commandId, automatic) { const stopped = data.state === 'stopped'; showToast(runtimeText(stopped ? 'stopped' : 'outcomeUnknown'), stopped ? 'success' : 'warning'); } catch (e) { - if (isCurrent()) { renderServiceCommand(commandId, {}); showToast(e.code === 4 ? runtimeText('controlBusy') : e.message || runtimeText('outcomeUnknown'), 'warning'); } + if (isCurrent()) { renderServiceCommand(commandId, {}); reportFailure({ op: 'service.stopprocess', error: e, write: true }, { type: 'warning' }); } } finally { advanceServiceState(commandId); serviceStopInFlight.delete(commandId); await updateQuickActionServiceStatus(); } } @@ -4472,10 +4465,10 @@ async function syncTimeFromBrowser(silent = false) { console.error('Failed to refresh time info:', e); } } else { - if (!silent) showToast(t('toast.timeSyncFailed'), 'error'); + if (!silent) reportFailure({ op: 'toast.syncfailed', result, endpoint: 'time.sync', write: true }); } } catch (e) { - if (!silent) showToast(t('toast.syncFailed') + ': ' + e.message, 'error'); + if (!silent) reportFailure({ op: 'toast.syncfailed', error: e, write: true }); } } @@ -4524,7 +4517,7 @@ async function applyTimezone() { await refreshSystemPage(); } } catch (e) { - showToast((typeof t === 'function' ? t('toast.setFailed') : '设置失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'toast.setfailed', error: e, write: true }); } } @@ -4614,7 +4607,7 @@ async function fillColorFromPicker(device, color) { updateLedCardState(device, true); showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} 已填充 ${color}`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledfillfailed', error: e, write: true }); } } @@ -4627,7 +4620,7 @@ async function quickFillColor(device, color) { updateLedCardState(device, true, null); showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} → ${color}`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledFillFailed') + ': ' + e.message : `填充失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledfillfailed', error: e, write: true }); } } @@ -4641,7 +4634,7 @@ async function allLedsOff() { ledStates[dev.name] = false; updateLedCardState(dev.name, false); } catch (e) { - failures.push({path: dev.name, message: e.message}); + failures.push({path: dev.name, message: failureText({ op: 'led.clear', error: e, endpoint: 'led.clear', write: true })}); } } showOperationSummary('promptRepair.ledSummary', success, failures); @@ -4882,7 +4875,7 @@ async function toggleLedFromModal(device, enabled) { // 更新卡片状态 updateLedCardState(device, ledStates[device], selectedEffects[device]); } catch (e) { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : `操作失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.operationfailedmsg', error: e, write: true }); // 恢复 UI 状态 const cb = document.getElementById(`modal-device-enabled-${device}`); if (cb) cb.checked = ledStates[device]; @@ -4970,7 +4963,7 @@ async function applyEffectFromModal(device) { showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect} 已启动`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStartFailed') + ': ' + e.message : `启动动画失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledeffectstartfailed', error: e, write: true }); } } @@ -4982,7 +4975,7 @@ async function stopEffectFromModal(device) { updateLedCardState(device, ledStates[device], null); showToast(typeof t === 'function' ? t('toast.ledEffectStopped', { device: getDeviceDescription(device) }) : `${device} 动画已停止`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStopFailed') + ': ' + e.message : `停止动画失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledeffectstopfailed', error: e, write: true }); } } @@ -5000,7 +4993,7 @@ async function displayImageFromModal() { requireApiSuccess(await api.call('led.image', { device: 'matrix', path, center }), 'call'); showToast((typeof t === 'function' ? t('toast.imageDisplayed') : '图像已显示'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledImageFailed') + ': ' + e.message : `显示图像失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledimagefailed', error: e, write: true }); } } @@ -5020,7 +5013,7 @@ async function generateQrCodeFromModal() { requireApiSuccess(await api.call('led.qrcode', { device: 'matrix', text, ecc, fg_color: fg, bg_image: bgImage || undefined }), 'call'); showToast((typeof t === 'function' ? t('toast.qrGenerated') : 'QR 码已生成'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledQrFailed') + ': ' + e.message : `生成 QR 码失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledqrfailed', error: e, write: true }); } } @@ -5107,7 +5100,7 @@ async function displayTextFromModal() { requireApiSuccess(await api.call('led.text', params), 'call'); showToast((typeof t === 'function' ? t('toast.textDisplayed') : '文本已显示'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextFailed') + ': ' + e.message : `显示文本失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledtextfailed', error: e, write: true }); } } @@ -5117,7 +5110,7 @@ async function stopTextFromModal() { requireApiSuccess(await api.call('led.text.stop', { device: 'matrix' }), 'call'); showToast((typeof t === 'function' ? t('toast.textScrollStopped') : '文本滚动已停止'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledTextStopFailed') + ': ' + e.message : `停止文本失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledtextstopfailed', error: e, write: true }); } } @@ -5181,7 +5174,7 @@ async function applyFilterFromModal() { requireApiSuccess(await api.call('led.filter.start', params), 'call'); showToast(typeof t === 'function' ? t('toast.filterApplied', { filter: filterDisplayName(selectedModalFilter) }) : `滤镜 ${selectedModalFilter} 已应用`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterApplyFailed') + ': ' + e.message : `应用滤镜失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.filterapplyfailed', error: e, write: true }); } } @@ -5191,7 +5184,7 @@ async function stopFilterFromModal() { requireApiSuccess(await api.call('led.filter.stop', { device: 'matrix' }), 'call'); showToast((typeof t === 'function' ? t('toast.filterStopped') : '滤镜已停止'), 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.filterStopFailed') + ': ' + e.message : `停止滤镜失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.filterstopfailed', error: e, write: true }); } } @@ -5325,10 +5318,10 @@ async function applyColorCorrection() { ccInitialConfig = JSON.parse(JSON.stringify(config)); showToast(t('ledPage.ccApplySuccess'), 'success'); } else { - showToast(t('ledPage.ccApplyFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'ledpage.ccapplyfailed', result, write: true }); } } catch (e) { - showToast(t('ledPage.ccApplyFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'ledpage.ccapplyfailed', error: e, write: true }); } } @@ -5370,10 +5363,10 @@ async function resetColorCorrection() { if (result.code === 0) { showToast(t('ledPage.ccResetSuccess'), 'success'); } else { - showToast(t('ledPage.ccResetFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'ledpage.ccresetfailed', result, write: true }); } } catch (e) { - showToast(t('ledPage.ccResetFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'ledpage.ccresetfailed', error: e, write: true }); } } @@ -5384,10 +5377,10 @@ async function ccExport() { if (result.code === 0) { showToast(t('ledPage.ccExportSuccess'), 'success'); } else { - showToast(t('ledPage.ccExportFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'ledpage.ccexportfailed', result, write: false }); } } catch (e) { - showToast(t('ledPage.ccExportFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'ledpage.ccexportfailed', error: e, write: false }); } } @@ -5400,10 +5393,10 @@ async function ccImport() { await loadColorCorrectionConfig(); showToast(t('ledPage.ccImportSuccess'), 'success'); } else { - showToast(t('ledPage.ccImportFailed') + ': ' + result.message, 'error'); + reportFailure({ op: 'ledpage.ccimportfailed', result, write: true }); } } catch (e) { - showToast(t('ledPage.ccImportFailed') + ': ' + e.message, 'error'); + reportFailure({ op: 'ledpage.ccimportfailed', error: e, write: true }); } } @@ -5434,7 +5427,7 @@ async function setBrightness(device, value) { requireApiSuccess(await api.ledBrightness(device, parseInt(value)), 'ledBrightness'); showToast(typeof t === 'function' ? t('toast.ledBrightnessSet', { device: getDeviceDescription(device), value }) : `${device} 亮度: ${value}`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledBrightnessFailed', { device: getDeviceDescription(device) }) + ': ' + e.message : `设置 ${device} 亮度失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledbrightnessfailed', params: { device: getDeviceDescription(device) }, error: e, write: true }); } } @@ -5459,7 +5452,7 @@ async function toggleLed(device) { showToast(typeof t === 'function' ? t('toast.ledTurnedOn', { device: getDeviceDescription(device) }) : `${device} 已开启`, 'success'); } } catch (e) { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : `操作失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.operationfailedmsg', error: e, write: true }); } } @@ -5475,7 +5468,7 @@ async function stopEffect(device) { delete selectedEffects[device]; showToast(typeof t === 'function' ? t('toast.ledEffectStopped', { device: getDeviceDescription(device) }) : `${device} 动画已停止`, 'success'); } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledEffectStopFailed') + ': ' + e.message : `停止动画失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledeffectstopfailed', error: e, write: true }); } } @@ -5489,7 +5482,7 @@ async function saveLedConfig(device) { showToast(typeof t === 'function' ? t('toast.ledConfigSaved', { device: getDeviceDescription(device) }) : `${device} 配置已保存`, 'success'); } } catch (e) { - showToast(typeof t === 'function' ? t('toast.ledConfigSaveFailed') + ': ' + e.message : `保存配置失败: ${e.message}`, 'error'); + reportFailure({ op: 'toast.ledconfigsavefailed', error: e, write: true }); } } @@ -5598,7 +5591,7 @@ async function loadFilePickerDirectory(path) { return `
${escapeHtml(f.name)}
${isDir ? '-' : formatFileSize(f.size)}
`; }).join(''); } catch (e) { - listContainer.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(e.message)}
`; + listContainer.innerHTML = `
${failureNote({ op: 'common.loadfailed', error: e, write: false })}
`; } } @@ -5608,7 +5601,7 @@ async function createAndOpenDir(path) { requireApiSuccess(await api.storageMkdir(path), 'storageMkdir'); await loadFilePickerDirectory(path); } catch (e) { - showToast((typeof t === 'function' ? t('toast.createDirFailed', { msg: e.message }) : '创建目录失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.createdirfailed', error: e, write: true }); } } @@ -6178,7 +6171,7 @@ function showNetworkLpmuAccessResult(info) { } } catch (error) { finished = true; // Reopening must not automatically retry an invalid transcript. - if (modal.isConnected) output.textContent = technicalDetail + t('networkPage.lpmuAccessLogFailed') + ': ' + error.message; + if (modal.isConnected) output.textContent = failureText({ op: 'networkpage.lpmuaccesslogfailed', error: error, write: false }); } finally { loading = false; } }; modal.querySelector('button').onclick = close; @@ -6234,16 +6227,16 @@ async function submitNetworkLpmuAccess() { if (!pageCurrent()) return; networkLpmuAccessSubmitting = null; stopNetworkLpmuAccessPolling(); - const msg = e.message || (typeof t === 'function' ? t('common.unknown') : '未知错误'); + const spec = { op: 'network.lpmuaccess', error: e, endpoint: 'lpmu.access.start', write: true }; if (typeof e.code === 'number' && e.code !== 0 && !e.uncertain) { - showToast(t('networkPage.lpmuAccessStartFailed') + ': ' + msg, 'error', 5000); + reportFailure(spec); // A rejected start can mean another task is already active. Read // its state rather than inventing a failed remote execution. await refreshNetworkLpmuAccessStatus(false); if (!pageCurrent()) return; if (!networkLpmuAccessUncertain && networkLpmuAccessLastInfo.running) startNetworkLpmuAccessPolling(); } else { - showNetworkLpmuAccessStatusUnknown(msg); + showNetworkLpmuAccessStatusUnknown(failureText(spec)); } } finally { password = ''; @@ -6282,7 +6275,7 @@ async function refreshNetworkLpmuAccessStatus(notifyResult = true) { const result = await api.lpmuAccessStatus(); if (!requestCurrent()) return; if (!result || result.code !== 0) { - throw new Error(result?.message || (typeof t === 'function' ? t('networkPage.lpmuAccessStatusFailed') : '状态获取失败')); + throw new ApiOperationError(result || {}, 'lpmu.access.status'); } const info = renderNetworkLpmuAccessStatus(result.data || {}); @@ -6295,7 +6288,7 @@ async function refreshNetworkLpmuAccessStatus(notifyResult = true) { } catch (e) { if (!requestCurrent()) return; stopNetworkLpmuAccessPolling(); - const msg = e.message || (typeof t === 'function' ? t('common.unknown') : '未知错误'); + const msg = failureText({ op: 'network.lpmuaccessstatus', error: e, endpoint: 'lpmu.access.status', write: false }); showNetworkLpmuAccessStatusUnknown(msg); } finally { if (networkLpmuAccessRequesting === request) networkLpmuAccessRequesting = null; @@ -6478,7 +6471,7 @@ async function setWifiMode() { showToast(typeof t === 'function' ? t('toast.wifiModeChanged', { mode: getWifiModeDisplay(mode) }) : `WiFi 模式已切换为 ${getWifiModeDisplay(mode)}`, 'success'); await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.switchFailedMsg', { msg: e.message }) : '切换失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.switchfailedmsg', error: e, write: true }); } } @@ -6495,7 +6488,7 @@ async function setHostname() { document.getElementById('hostname-input').value = ''; await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.setFailedMsg', { msg: e.message }) : '设置失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.setfailedmsg', error: e, write: true }); } } @@ -6508,6 +6501,7 @@ async function showWifiScan() { try { const result = await api.wifiScan(); + requireApiSuccess(result, 'network.wifi.scan'); if (result.data && result.data.networks) { if (result.data.networks.length === 0) { container.innerHTML = '
' + t('networkPage.noNetwork') + '
'; @@ -6524,12 +6518,7 @@ async function showWifiScan() { `).join(''); } } catch (e) { - const errorMsg = e.message || ''; - if (errorMsg.includes('STA') || errorMsg.includes('APSTA') || errorMsg.includes('mode')) { - container.innerHTML = `
${t('networkPage.needStaMode')}
`; - } else { - container.innerHTML = `
${t('toast.scanFailed')}: ${escapeHtml(errorMsg)}
`; - } + container.innerHTML = `
${failureNote({ op: 'wifi.scan', error: e, endpoint: 'network.wifi.scan', write: false })}
`; } } @@ -6605,7 +6594,7 @@ function submitWifiConnect(ssid) { showToast((typeof t === 'function' ? t('toast.connecting') : '正在连接...'), 'info'); setTimeout(refreshNetworkPage, 3000); }) - .catch(e => showToast((typeof t === 'function' ? t('toast.connectFailedMsg', { msg: e.message }) : '连接失败: ' + e.message), 'error')); + .catch(e => reportFailure({ op: 'toast.connectfailedmsg', error: e, write: true })); } async function disconnectWifi() { @@ -6614,7 +6603,7 @@ async function disconnectWifi() { showToast((typeof t === 'function' ? t('toast.wifiDisconnected') : '已断开 WiFi 连接'), 'success'); await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.disconnectFailedMsg', { msg: e.message }) : '断开失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.disconnectfailedmsg', error: e, write: true }); } } @@ -6638,7 +6627,7 @@ async function showApStations() { `).join(''); } } catch (e) { - container.innerHTML = `
${t('toast.fetchFailed')}: ${escapeHtml(e.message)}
`; + container.innerHTML = `
${failureNote({ op: 'toast.fetchfailed', error: e, write: false })}
`; } } @@ -6677,7 +6666,7 @@ async function applyApConfig() { hideApConfig(); await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.configFailedMsg', { msg: e.message }) : '配置失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.configfailedmsg', error: e, write: true }); } } @@ -6712,7 +6701,7 @@ async function loadDhcpClients() { } } catch (e) { if (!pageCurrent()) return; - container.innerHTML = `
${t('toast.fetchFailed')}: ${escapeHtml(e.message)}
`; + container.innerHTML = `
${failureNote({ op: 'toast.fetchfailed', error: e, write: false })}
`; } } @@ -6728,7 +6717,7 @@ async function toggleNat() { } await refreshNetworkPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.operationFailedMsg', { msg: e.message }) : '操作失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.operationfailedmsg', error: e, write: true }); } } @@ -6737,7 +6726,7 @@ async function saveNatConfig() { requireApiSuccess(await api.natSave(), 'natSave'); showToast(typeof t === 'function' ? t('toast.natConfigSaved') : 'NAT 配置已保存', 'success'); } catch (e) { - showToast((typeof t === 'function' ? t('toast.saveFailed') : '保存失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'toast.savefailed', error: e, write: true }); } } @@ -6909,7 +6898,7 @@ async function batchDelete() { successCount++; } catch (e) { console.error('Delete failed:', path, e); - failures.push({path, message: e.message}); + failures.push({path, message: failureText({ op: 'file.delete', error: e, endpoint: 'storage.delete', write: true })}); } } @@ -6945,7 +6934,7 @@ async function batchDownload() { for (const path of filesToDownload) { const result = await downloadFile(path, true); if (result.started) success++; - else failures.push({path, message: result.error.message}); + else failures.push({path, message: failureText({ op: 'file.download', error: result.error, endpoint: 'storage.download', write: false })}); } return showOperationSummary('promptRepair.downloadSummary', success, failures); } @@ -6958,7 +6947,7 @@ async function mountSdCard() { showToast(typeof t === 'function' ? t('filePage.mountSdSuccess') : 'SD 卡挂载成功', 'success'); await refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('filePage.mountSdFailed') : '挂载失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'filepage.mountsdfailed', error: e, write: true }); } } @@ -6976,7 +6965,7 @@ async function unmountSdCard() { } await refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('filePage.unmountSdFailed') : '卸载失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'filepage.unmountsdfailed', error: e, write: true }); } } @@ -7098,7 +7087,7 @@ async function loadDirectory(path) { `; } else { - listContainer.textContent = t('promptRepair.listRefreshFailed') + '\n' + e.message; + listContainer.textContent = failureText({ op: 'promptrepair.listrefreshfailed', error: e, write: false }); } return {error: e}; } @@ -7309,11 +7298,12 @@ async function uploadFiles() { apply.onclick = () => { if (verified()) showConfigPackApplyConfirm(path, pack, verified); }; row.appendChild(apply); } else if (pack && state) { - state.textContent = t('promptRepair.packInvalid') + '\n' + apiErrorMessage({error: pack.result_message}); + state.textContent = t('promptRepair.packInvalid') + '\n' + failureText({ op: 'pack.check', token: pack.reason || 'pack_verify_failed', result: { code: 7, message: pack.result_message }, write: false }); } } catch (error) { - failures.push({path, message: error.message}); - if (current() && state) state.textContent = error.message; + const message = failureText({ op: 'file.upload', error, endpoint: 'storage.upload', write: true }); + failures.push({path, message}); + if (current() && state) state.textContent = message; } finally { pending.delete(versions[index]); if (!pending.size) uploadPendingPaths.delete(path); @@ -7352,7 +7342,7 @@ async function createNewFolder() { closeNewFolderDialog(); refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.createFailedMsg', { msg: e.message }) : '创建失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.createfailedmsg', error: e, write: true }); } } @@ -7387,7 +7377,7 @@ async function doRename() { closeRenameDialog(); refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.renameFailedMsg', { msg: e.message }) : '重命名失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.renamefailedmsg', error: e, write: true }); } } @@ -7406,7 +7396,7 @@ async function downloadFile(path, silent = false) { if (!silent) showToast(t('promptRepair.downloadStarted'), 'success'); return {started: true}; } catch (error) { - if (!silent) showToast(t('toast.downloadFailedMsg', {msg: error.message}), 'error'); + if (!silent) reportFailure({ op: 'toast.downloadfailedmsg', error: error, write: false }); return {started: false, error}; } finally { if (anchor) anchor.remove(); @@ -7426,7 +7416,7 @@ async function deleteFile(path) { showToast((typeof t === 'function' ? t('toast.deleteSuccess') : '删除成功'), 'success'); refreshFilesPage(); } catch (e) { - showToast((typeof t === 'function' ? t('toast.deleteFailedMsg', { msg: e.message }) : '删除失败: ' + e.message), 'error'); + reportFailure({ op: 'toast.deletefailedmsg', error: e, write: true }); } } @@ -8096,10 +8086,10 @@ async function showCommandVariables(varName) { } renderSourceVarsTable(body, vars); } else { - setSourceVarsMessage(body, escapeHtml(result.message || t('sshPage.getVarFailed')), true); + setSourceVarsMessage(body, escapeHtml(failureText({ op: 'variable.list', result, endpoint: 'automation.variables.list', write: false })), true); } } catch (error) { - setSourceVarsMessage(body, escapeHtml(error.message), true); + setSourceVarsMessage(body, escapeHtml(failureText({ op: 'variable.list', error, endpoint: 'automation.variables.list', write: false })), true); } } @@ -9597,11 +9587,11 @@ async function submitRootPasswordSet() { api.passwordChanged = true; showToast(typeof t === 'function' ? t('securityPage.rootPasswordSetSuccess') : 'root 密码已更新', 'success'); } else { - errorEl.textContent = result.message || result.error || (typeof t === 'function' ? t('toast.saveFailed') : '保存失败'); + errorEl.textContent = failureText({ op: 'account.setroot', result, endpoint: 'auth.root.set_password', write: true }); errorEl.classList.remove('hidden'); } } catch (error) { - errorEl.textContent = error.message || (typeof t === 'function' ? t('login.networkError') : '网络错误'); + errorEl.textContent = failureText({ op: 'account.setroot', error, endpoint: 'auth.root.set_password', write: true }); errorEl.classList.remove('hidden'); } } @@ -9637,11 +9627,11 @@ async function submitAdminPasswordSet() { if (confirmPwdEl) confirmPwdEl.value = ''; showToast(typeof t === 'function' ? t('securityPage.adminPasswordSetSuccess') : 'admin 密码已更新', 'success'); } else { - errorEl.textContent = result.message || result.error || (typeof t === 'function' ? t('toast.saveFailed') : '保存失败'); + errorEl.textContent = failureText({ op: 'account.setadmin', result, endpoint: 'auth.admin.set_password', write: true }); errorEl.classList.remove('hidden'); } } catch (error) { - errorEl.textContent = error.message || (typeof t === 'function' ? t('login.networkError') : '网络错误'); + errorEl.textContent = failureText({ op: 'account.setadmin', error, endpoint: 'auth.admin.set_password', write: true }); errorEl.classList.remove('hidden'); } } @@ -9658,10 +9648,10 @@ async function resetAdminPasswordToDefault() { if (result.success || result.code === 0 || result.code === 'OK') { showToast(typeof t === 'function' ? t('securityPage.adminPasswordResetSuccess') : 'admin 密码已重置为默认密码 rm01', 'success'); } else { - showToast(result.message || result.error || (typeof t === 'function' ? t('toast.saveFailed') : '操作失败'), 'error'); + reportFailure({ op: 'account.resetadmin', result, endpoint: 'auth.admin.reset_password', write: true }); } } catch (error) { - showToast(error.message || (typeof t === 'function' ? t('login.networkError') : '网络错误'), 'error'); + reportFailure({ op: 'account.resetadmin', error, endpoint: 'auth.admin.reset_password', write: true }); } } @@ -11075,7 +11065,7 @@ async function refreshConfigPackStatus() { if (!pageCurrent()) return; console.error('Refresh config pack status error:', e); statusIcon.textContent = ''; - statusText.textContent = (typeof t === 'function' ? t('common.loadFailed') : '加载失败'); + statusText.textContent = failureText({ op: 'pack.status', error: e, endpoint: 'config.pack.status', write: false }); if (deviceTypeBadge) deviceTypeBadge.style.display = 'none'; if (infoDetails) infoDetails.style.display = 'none'; } @@ -11100,7 +11090,7 @@ async function loadConfigPackCert() { try { const result = await api.configPackExportCert(); - if (result.code !== 0) throw new Error(result.message || result.error); + requireApiSuccess(result, 'config.pack.export_cert'); const data = result.data; document.getElementById('pack-cert-fingerprint').value = data.fingerprint || ''; @@ -11110,7 +11100,7 @@ async function loadConfigPackCert() { loading.style.display = 'none'; content.classList.remove('hidden'); } catch (e) { - loading.textContent = typeof t === 'function' ? t('pkiPage.loadFailedMsg', { msg: e.message }) : '加载失败: ' + e.message; + loading.textContent = failureText({ op: 'pack.certload', error: e, endpoint: 'config.pack.export_cert', write: false }); } } @@ -11119,7 +11109,7 @@ function copyPackCertToClipboard() { navigator.clipboard.writeText(pem).then(() => { showToast(typeof t === 'function' ? t('toast.certCopied') : '证书已复制到剪贴板', 'success'); }).catch(e => { - showToast(typeof t === 'function' ? t('toast.copyFailedMsg', { msg: e.message }) : '复制失败: ' + e.message, 'error'); + reportCopyFailure(e); }); } @@ -11170,7 +11160,7 @@ async function verifyConfigPack() { try { const result = await api.configPackVerify(content); - if (result.code !== 0) throw new Error(result.message || result.error); + requireApiSuccess(result, 'config.pack.verify'); const data = result.data; if (data.valid) { @@ -11189,11 +11179,11 @@ async function verifyConfigPack() { } } else { resultBox.className = 'result-box error'; - resultBox.textContent = (typeof t === 'function' ? t('pkiPage.verifyFailed') : '验证失败') + ': ' + (data.result_message || (typeof t === 'function' ? t('pkiPage.signatureInvalid') : '签名无效')); + packBox(resultBox, { op: 'pack.check', token: data.reason || 'pack_verify_failed', result: { code: 7, message: data.result_message }, endpoint: 'config.pack.verify', write: false }); } } catch (e) { resultBox.className = 'result-box error'; - resultBox.textContent = (typeof t === 'function' ? t('pkiPage.verifyFailed') : '验证失败') + ': ' + e.message; + packBox(resultBox, { op: 'pack.check', error: e, endpoint: 'config.pack.verify', write: false }); } } @@ -11215,7 +11205,7 @@ async function importConfigPack() { try { const result = await api.configPackImport(content, null, false); - if (result.code !== 0) throw new Error(result.message || result.error); + requireApiSuccess(result, 'config.pack.import'); const data = result.data; resultBox.className = 'result-box success'; @@ -11239,7 +11229,7 @@ async function importConfigPack() { } catch (e) { resultBox.className = 'result-box error'; - resultBox.textContent = (typeof t === 'function' ? t('toast.importFailed') : 'Import failed') + ': ' + e.message; + packBox(resultBox, { op: 'pack.importtext', error: e, endpoint: 'config.pack.import', write: true }); } } @@ -11306,7 +11296,7 @@ async function applyConfigPackFromPath(path) { try { const result = requireApiSuccess(await api.call('config.pack.apply', { path }, 'POST'), 'config.pack.apply'); const data = result.data; - if (data?.success !== true) throw new ApiOperationError({message: data?.result_message}, 'config.pack.apply'); + if (data?.success !== true) throw Object.assign(new ApiOperationError({ code: 7, message: data?.result_message }, 'config.pack.apply'), { failureToken: data?.reason || 'pack_verify_failed' }); const message = t('promptRepair.packApplied') + (data.applied_modules?.length ? '\n' + t('promptRepair.details', {detail: data.applied_modules.join(', ')}) : ''); record(message); if (current()) { @@ -11314,7 +11304,7 @@ async function applyConfigPackFromPath(path) { dialog?.remove(); } } catch (e) { - const message = e.uncertain ? e.message : t('toast.applyFailedMsg', {msg: e.message}); + const message = failureText({ op: 'pack.apply', error: e, endpoint: 'config.pack.apply', write: true }); record(message); if (current()) showToast(path + ': ' + message, 'error', 8000, {isCurrent: current}); } finally { @@ -11370,7 +11360,7 @@ async function packExportBrowseRefresh() { try { const result = await api.storageList(packExportCurrentPath); - if (result.code !== 0) throw new Error(result.message); + requireApiSuccess(result, 'storage.list'); const entries = result.data?.entries || []; @@ -11415,7 +11405,7 @@ async function packExportBrowseRefresh() { packExportUpdateSelectedDisplay(); } catch (e) { - fileList.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(e.message)}
`; + fileList.innerHTML = `
${failureNote({ op: 'common.loadfailed', error: e, write: false })}
`; } } @@ -11457,7 +11447,7 @@ async function packExportToggleFile(fileName, checked) { try { const result = await api.storageRead(fullPath); - if (result.code !== 0) throw new Error(result.message); + requireApiSuccess(result, 'storage.read'); const rawContent = result.data?.content; if (rawContent === undefined || rawContent === null) throw new Error(t('toast.fileContentEmpty')); @@ -11468,13 +11458,13 @@ async function packExportToggleFile(fileName, checked) { contentStr = JSON.stringify(rawContent, null, 2); } else { contentStr = rawContent; - JSON.parse(contentStr); // 验证 + try { JSON.parse(contentStr); } catch (_) { throw pageFailure('file_not_json'); } // 验证 } packExportSelectedFiles.set(fullPath, { name: fileName, content: contentStr, status: 'ok' }); } catch (e) { - packExportSelectedFiles.set(fullPath, { name: fileName, content: null, status: 'error', error: e.message }); + packExportSelectedFiles.set(fullPath, { name: fileName, content: null, status: 'error', error: failureText({ op: 'pack.readfile', params: { file: fileName }, error: e, endpoint: 'storage.read', write: false }) }); } packExportUpdateSelectedDisplay(); @@ -11615,7 +11605,7 @@ async function exportConfigPack() { } catch (e) { resultBox.className = 'result-box error'; resultBox.style.visibility = 'visible'; - resultBox.textContent = (typeof t === 'function' ? t('pkiPage.invalidJsonConfigMsg', { msg: e.message }) : '配置文件不是有效的 JSON: ' + e.message); + packBox(resultBox, { op: 'pack.generate', token: 'file_not_json', write: false }); return; } @@ -11629,7 +11619,7 @@ async function exportConfigPack() { const savePath = '/sdcard/output_config/' + name + '.tscfg'; const result = await api.configPackExport(name, content, recipientCert, desc || null, savePath); console.log('[ConfigPack] Export result:', result); - if (result.code !== 0) throw new Error(result.message || result.error); + requireApiSuccess(result, 'config.pack.export'); const data = result.data || {}; const tscfgContent = data.tscfg || ''; @@ -11673,7 +11663,7 @@ async function exportConfigPack() { } catch (e) { console.error('[ConfigPack] Export error:', e); resultBox.className = 'result-box error'; - resultBox.textContent = (typeof t === 'function' ? t('pkiPage.generationFailedMsg', { msg: e.message }) : '生成失败: ' + e.message); + packBox(resultBox, { op: 'pack.generate', error: e, endpoint: 'config.pack.export', write: true }); } } @@ -11682,7 +11672,7 @@ function copyPackTscfgToClipboard() { navigator.clipboard.writeText(tscfg).then(() => { showToast(typeof t === 'function' ? t('toast.configPackCopied') : '配置包已复制到剪贴板', 'success'); }).catch(e => { - showToast(typeof t === 'function' ? t('toast.copyFailedMsg', { msg: e.message }) : '复制失败: ' + e.message, 'error'); + reportCopyFailure(e); }); } @@ -11726,7 +11716,7 @@ async function refreshConfigPackList() { try { const result = await api.configPackList(path); if (!pageCurrent()) return; - if (result.code !== 0) throw new Error(result.message || result.error); + requireApiSuccess(result, 'config.pack.list'); const data = result.data; const files = data.files || []; @@ -11755,7 +11745,7 @@ async function refreshConfigPackList() { } catch (e) { if (!pageCurrent()) return; - loading.textContent = (typeof t === 'function' ? t('securityPage.packListLoadFailed') : '加载失败') + ': ' + e.message; + loading.textContent = failureText({ op: 'securitypage.packlistloadfailed', error: e, write: false }); } } @@ -11765,12 +11755,12 @@ async function importPackFromList(filePath) { try { const result = await api.configPackImport(null, filePath, false); - if (result.code !== 0) throw new Error(result.message || result.error); + requireApiSuccess(result, 'config.pack.import'); showToast(typeof t === 'function' ? t('securityPage.importPackSuccess') : '配置包导入成功', 'success'); hideConfigPackListModal(); } catch (e) { - showToast((typeof t === 'function' ? t('securityPage.importPackFailed') : '导入失败') + ': ' + e.message, 'error'); + reportFailure({ op: 'securitypage.importpackfailed', error: e, write: true }); } } @@ -11795,12 +11785,6 @@ function certText(key, fallback, params = {}) { const value = typeof t === 'function' ? t(key, params) : key; return typeof value === 'string' && value !== key ? value : fallback; } -function certError(result, fallback) { - for (const value of [result?.message, result?.error]) { - if (typeof value === 'string' && value.trim()) return value; - } - return fallback; -} function certValidity(value) { const labels = {none: 'No certificate', invalid: 'Certificate cannot be parsed', time_unverified: 'Awaiting device time', not_yet_valid: 'Not yet valid', @@ -11822,7 +11806,8 @@ async function refreshCertStatus() { try { const result = await api.certStatus(); if (!pageCurrent()) return; - if (result.code !== 0 || !result.data) throw new Error(certError(result, 'Status unavailable')); + requireApiSuccess(result, 'cert.status'); + if (!result.data) throw new ApiOperationError(result, 'cert.status', { kind: 'format' }); const data = result.data; window._certPkiStatus = data; for (const [id, disabled] of Object.entries({ @@ -11869,7 +11854,7 @@ async function refreshCertStatus() { } } catch (e) { if (!pageCurrent()) return; - document.getElementById('cert-status-text').textContent = certText('pkiPage.statusLoadFailed', 'Status refresh failed'); + document.getElementById('cert-status-text').textContent = failureText({ op: 'cert.status', error: e, endpoint: 'cert.status', write: false }); } } function updateCertInfoDetails(info) { @@ -11890,10 +11875,10 @@ async function syncCertificateTime() { button.disabled = true; try { const result = await api.timeSync(Date.now()); - if (result.code !== 0) throw new Error(certError(result, 'Time update failed')); + requireApiSuccess(result, 'time.sync'); await refreshCertStatus(); } catch (e) { - document.getElementById('cert-device-time').textContent = certError(e, 'Time update failed'); + document.getElementById('cert-device-time').textContent = failureText({ op: 'cert.timesync', error: e, endpoint: 'time.sync', write: true }); } finally { button.disabled = false; } } @@ -11940,10 +11925,10 @@ async function generateCertKeypair() { refreshCertStatus(); }, 1000); } else { - throw new Error(certError(result, t('toast.generateFailed'))); + throw new ApiOperationError(result, 'cert.generate_keypair'); } } catch (e) { - resultBox.textContent = (certText('pkiPage.generationFailedMsg', '生成失败: ' + e.message, { msg: e.message })); + packBox(resultBox, { op: 'cert.keypair', error: e, endpoint: 'cert.generate_keypair', write: true }); resultBox.classList.add('error'); } finally { btn.disabled = false; @@ -11992,10 +11977,10 @@ async function generateCSR() { document.getElementById('csr-pem-output').value = result.data.csr_pem; showToast(certText('toast.csrGenerated', 'CSR 生成成功'), 'success'); } else { - throw new Error(certError(result, t('toast.generateFailed'))); + throw new ApiOperationError(result, 'cert.generate_csr', result.code === 0 ? { kind: 'format' } : {}); } } catch (e) { - resultBox.textContent = (certText('pkiPage.generationFailedMsg', '生成失败: ' + e.message, { msg: e.message })); + packBox(resultBox, { op: 'cert.csr', error: e, endpoint: 'cert.generate_csr', write: false }); resultBox.classList.add('error'); } finally { btn.disabled = false; @@ -12007,7 +11992,7 @@ function copyCSRToClipboard() { navigator.clipboard.writeText(csr).then(() => { showToast(certText('toast.csrCopied', 'CSR 已复制到剪贴板'), 'success'); }).catch(e => { - showToast(certText('toast.copyFailedMsg', '复制失败: ' + e.message, { msg: e.message }), 'error'); + reportCopyFailure(e); }); } @@ -12043,13 +12028,12 @@ async function installCertMaterial(ca) { resultBox.textContent = certText('pkiRepair.saved', 'Saved. Check validity and HTTPS status below.'); resultBox.classList.add('success'); } else { - const msg = certError(result, certText('pkiPage.installFailed', 'Installation failed')); - resultBox.textContent = certText('pkiPage.installFailedMsg', `Installation failed: ${msg}`, {msg}); + packBox(resultBox, { op: ca ? 'cert.installca' : 'cert.install', result, endpoint: ca ? 'cert.install_ca' : 'cert.install', write: true }); resultBox.classList.add('error'); } - } catch (_) { + } catch (e) { // A lost response is not proof that the server did not persist the request. - resultBox.textContent = certText('pkiRepair.unconfirmed', 'Installation result unconfirmed. Refresh status to check before retrying.'); + packBox(resultBox, { op: ca ? 'cert.installca' : 'cert.install', error: e, endpoint: ca ? 'cert.install_ca' : 'cert.install', write: true }); resultBox.classList.add('error'); } finally { button.disabled = false; } if (saved) { @@ -12088,10 +12072,10 @@ async function showCertViewModal() { loading.style.display = 'none'; content.classList.remove('hidden'); } else { - throw new Error(certError(result, t('toast.getCertFailed'))); + throw new ApiOperationError(result, 'cert.get', result.code === 0 ? { kind: 'format' } : {}); } } catch (e) { - loading.textContent = certText('pkiPage.loadFailedMsg', '加载失败: ' + e.message, { msg: e.message }); + loading.textContent = failureText({ op: 'cert.view', error: e, endpoint: 'cert.get', write: false }); } } @@ -12104,7 +12088,7 @@ function copyCertToClipboard() { navigator.clipboard.writeText(cert).then(() => { showToast(certText('toast.certCopied', '证书已复制到剪贴板'), 'success'); }).catch(e => { - showToast(certText('toast.copyFailedMsg', '复制失败: ' + e.message, { msg: e.message }), 'error'); + reportCopyFailure(e); }); } @@ -12119,10 +12103,10 @@ async function deleteCertCredentials() { showToast(certText('toast.pkiDeleted', 'PKI 凭证已删除'), 'success'); await refreshCertStatus(); } else { - throw new Error(certError(result, t('errors.deleteFailed'))); + throw new ApiOperationError(result, 'cert.delete'); } } catch (e) { - showToast((certText('toast.deleteFailedMsg', '删除失败: ' + e.message, { msg: e.message })), 'error'); + reportFailure({ op: 'cert.delete', error: e, endpoint: 'cert.delete', write: true }); } } @@ -12196,15 +12180,13 @@ async function generateKey() { trace('api_return', {code: typeof result?.code === 'number' ? result.code : null}); if (typeof result?.code === 'number' && result.code !== 0 && (!result.httpStatus || result.httpStatus < 400)) { const detail = result.data; - const reasons = {'key_storage_full':'storageFull', 'key_id_occupied':'occupied', 'key_limit_reached':'limit', - 'key_invalid_id':'invalidId', 'Memory allocation failed':'memory'}; const reason = typeof result.rawMessage === 'string' ? result.rawMessage : apiErrorReason(result); - let message = reasons[reason] ? t('keyGeneration.' + reasons[reason]) : apiErrorMessage(result, 'keyGenerate'); - if (detail?.failed_stage) message += '\n' + t('keyGeneration.stage', {stage: detail.failed_stage, error: detail.esp_error || ''}); - if (detail?.cleanup_complete === false) message += '\n' + t('keyGeneration.cleanupFailed', {error: detail.cleanup_error || ''}); if (reason === 'key_response_failed' || (!reason && !detail?.failed_stage)) throw new ApiOperationError(result, 'keyGenerate', {kind: 'format', uncertain: true}); trace('business_failed', {stage: detail?.failed_stage, cleanupComplete: detail?.cleanup_complete}); - if (ownsModal()) { setKeyGenerationStatus(message); showToast(message, 'error'); } + const spec = { op: 'key.generate', params: { id }, endpoint: 'key.generate', write: true, result: {...result, data: {...detail, + stage: detail?.failed_stage ? detail.failed_stage + (detail.esp_error ? ' · ' + detail.esp_error : '') : undefined, + detail: detail?.cleanup_complete === false ? t('keyGeneration.cleanupFailed', {error: detail.cleanup_error || ''}) : undefined}} }; + if (ownsModal()) { setKeyGenerationStatus(failureText(spec)); reportFailure(spec); } return; } if (result?.code !== 0 || (result.httpStatus && result.httpStatus >= 400) || result.data?.generated !== true || result.data?.id !== id) { @@ -13122,7 +13104,7 @@ function inferSdcardWwwPath(source) { async function resolveSdcardOtaPaths(input, includeWww) { const source = input.trim(); if (!source) { - throw new Error(t('promptRepair.enterOtaPath')); + throw pageFailure('ota_path_empty'); } if (source.match(/\.bin$/i)) { @@ -13138,14 +13120,14 @@ async function resolveSdcardOtaPaths(input, includeWww) { const firmwareInfo = await api.storageInfo(source); if (firmwareInfo.code !== 0 || firmwareInfo.data?.type !== 'file') { - throw new Error(t('promptRepair.firmwareMissing', {path: source})); + throw pageFailure('ota_firmware_missing', {path: source}); } const wwwPath = inferSdcardWwwPath(source); if (includeWww) { const wwwInfo = await api.storageInfo(wwwPath); if (wwwInfo.code !== 0 || wwwInfo.data?.type !== 'file') { - throw new Error(t('promptRepair.wwwMissing', {path: wwwPath})); + throw pageFailure('ota_www_missing', {path: wwwPath}); } } @@ -13155,7 +13137,7 @@ async function resolveSdcardOtaPaths(input, includeWww) { const dir = source.replace(/\/+$/, '') || '/sdcard'; const list = await api.storageList(dir); if (list.code !== 0 || !Array.isArray(list.data?.entries)) { - throw new Error(t('promptRepair.directoryUnreadable', {path: dir})); + throw pageFailure('directory_unreadable', {path: dir}); } const files = list.data.entries.filter(entry => entry.type === 'file'); @@ -13164,14 +13146,14 @@ async function resolveSdcardOtaPaths(input, includeWww) { files.find(entry => entry.name.toLowerCase().endsWith('.bin') && entry.name.toLowerCase() !== 'www.bin'); if (!firmware) { - throw new Error(t('promptRepair.firmwareDirMissing', {path: dir})); + throw pageFailure('ota_firmware_dir_missing', {path: dir}); } let wwwPath = ''; if (includeWww) { const www = files.find(entry => entry.name.toLowerCase() === 'www.bin'); if (!www) { - throw new Error(t('promptRepair.wwwDirMissing', {path: dir})); + throw pageFailure('ota_www_dir_missing', {path: dir}); } wwwPath = `${dir}/${www.name}`; } @@ -13277,7 +13259,7 @@ async function refreshOtaProgress() { // 开始检测设备重启 startRebootDetection(); } else if (state === 'error') { - showToast(typeof t === 'function' ? t('toast.upgradeFailedMsg', { msg: message }) : '升级失败: ' + message, 'error'); + reportFailure({ op: 'ota.upgrade', token: 'ota_failed', result: { code: 7, data: { detail: message } }, endpoint: 'ota.progress', write: true }); clearInterval(refreshInterval); refreshInterval = null; otaStep = 'idle'; @@ -13356,7 +13338,7 @@ async function startWwwOta() { } if (result.code !== 0) { - showToast(typeof t === 'function' ? t('toast.webuiUpgradeStartFailed') + ': ' + result.message : 'WebUI 升级启动失败: ' + result.message, 'error'); + reportFailure({ op: 'ota.startwww', result, endpoint: 'ota.www.start', write: true }); otaStep = 'idle'; clearInterval(refreshInterval); refreshInterval = null; @@ -13383,7 +13365,7 @@ async function startWwwOta() { console.error('Failed to start WWW OTA:', error); otaStep = 'idle'; document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = error.message || t('toast.webuiUpgradeStartFailed'); + document.getElementById('ota-message').textContent = failureText({ op: 'ota.startwww', error, endpoint: 'ota.www.start', write: true }); document.getElementById('ota-abort-btn').style.display = 'none'; } } @@ -13500,16 +13482,16 @@ async function otaFromUrl() { // 立即刷新一次 await refreshOtaProgress(); } else { - showToast(typeof t === 'function' ? t('toast.upgradeStartFailedMsg', { msg: result.message }) : '启动升级失败: ' + result.message, 'error'); + reportFailure({ op: 'ota.start', result, endpoint: 'ota.upgrade_url', write: true }); // 显示错误状态 document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = result.message || t('promptRepair.startFailed'); + document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', result, endpoint: 'ota.upgrade_url', write: true }); document.getElementById('ota-abort-btn').style.display = 'none'; } } catch (error) { - showToast(typeof t === 'function' ? t('toast.upgradeStartFailedMsg', { msg: error.message }) : '启动升级失败: ' + error.message, 'error'); + reportFailure({ op: 'ota.start', error, endpoint: 'ota.upgrade_url', write: true }); document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = error.message || t('login.networkError'); + document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', error, endpoint: 'ota.upgrade_url', write: true }); document.getElementById('ota-abort-btn').style.display = 'none'; } } @@ -13527,7 +13509,7 @@ async function otaFromFile() { try { paths = await resolveSdcardOtaPaths(inputPath, includeWww); } catch (error) { - showToast(error.message || t('promptRepair.otaPathFailed'), 'error'); + reportFailure({ op: 'ota.path', error, write: false }); return; } @@ -13566,15 +13548,15 @@ async function otaFromFile() { } await refreshOtaProgress(); } else { - showToast(typeof t === 'function' ? t('toast.upgradeStartFailedMsg', { msg: result.message }) : '启动升级失败: ' + result.message, 'error'); + reportFailure({ op: 'ota.start', result, endpoint: 'ota.upgrade_file', write: true }); document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = result.message || t('promptRepair.startFailed'); + document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', result, endpoint: 'ota.upgrade_file', write: true }); document.getElementById('ota-abort-btn').style.display = 'none'; } } catch (error) { - showToast(typeof t === 'function' ? t('toast.upgradeStartFailedMsg', { msg: error.message }) : '启动升级失败: ' + error.message, 'error'); + reportFailure({ op: 'ota.start', error, endpoint: 'ota.upgrade_file', write: true }); document.getElementById('ota-state-text').textContent = typeof t === 'function' ? t('otaPage.stateError') : '错误'; - document.getElementById('ota-message').textContent = error.message || t('login.networkError'); + document.getElementById('ota-message').textContent = failureText({ op: 'ota.start', error, endpoint: 'ota.upgrade_file', write: true }); document.getElementById('ota-abort-btn').style.display = 'none'; } } @@ -13591,10 +13573,10 @@ async function validateOta() { showToast(typeof t === 'function' ? t('toast.firmwareValidated') : '固件已标记为有效', 'success'); await refreshOtaInfo(); } else { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: result.message }) : '操作失败: ' + result.message, 'error'); + reportFailure({ op: 'toast.operationfailedmsg', result, write: true }); } } catch (error) { - showToast(typeof t === 'function' ? t('toast.operationFailedMsg', { msg: error.message }) : '操作失败: ' + error.message, 'error'); + reportFailure({ op: 'toast.operationfailedmsg', error: error, write: true }); } } @@ -13615,10 +13597,10 @@ async function rollbackOta() { showToast(typeof t === 'function' ? t('toast.rollbackSuccess') : '回滚成功!系统将在 3 秒后重启...', 'success'); // 3秒后页面会因为重启而断开连接 } else { - showToast(typeof t === 'function' ? t('toast.rollbackFailedMsg', { msg: result.message }) : '回滚失败: ' + result.message, 'error'); + reportFailure({ op: 'toast.rollbackfailedmsg', result, write: true }); } } catch (error) { - showToast(typeof t === 'function' ? t('toast.rollbackFailedMsg', { msg: error.message }) : '回滚失败: ' + error.message, 'error'); + reportFailure({ op: 'toast.rollbackfailedmsg', error: error, write: true }); } } @@ -13643,10 +13625,10 @@ async function abortOta() { clearInterval(refreshInterval); refreshInterval = null; } else { - showToast(typeof t === 'function' ? t('toast.abortFailedMsg', { msg: result.message }) : '中止失败: ' + result.message, 'error'); + reportFailure({ op: 'toast.abortfailedmsg', result, write: true }); } } catch (error) { - showToast(typeof t === 'function' ? t('toast.abortFailedMsg', { msg: error.message }) : '中止失败: ' + error.message, 'error'); + reportFailure({ op: 'toast.abortfailedmsg', error: error, write: true }); } } @@ -13747,10 +13729,10 @@ async function saveOtaServer() { showToast(typeof t === 'function' ? t('toast.otaServerCleared') : 'OTA 服务器地址已清除', 'info'); } } else { - showToast(typeof t === 'function' ? t('toast.saveFailedMsg', { msg: result.message }) : '保存失败: ' + result.message, 'error'); + reportFailure({ op: 'toast.savefailedmsg', result, write: true }); } } catch (error) { - showToast(typeof t === 'function' ? t('toast.saveFailedMsg', { msg: error.message }) : '保存失败: ' + error.message, 'error'); + reportFailure({ op: 'toast.savefailedmsg', error: error, write: true }); } } @@ -13798,12 +13780,12 @@ async function checkForUpdates() { const versionUrl = serverUrl.replace(/\/$/, '') + '/version'; console.log('Checking for updates:', versionUrl); - const response = await fetch(versionUrl); + const response = await fetch(versionUrl).catch(() => { throw pageFailure('ota_server_unreachable'); }); if (!response.ok) { - throw new Error(t('promptRepair.httpStatus', {status: response.status})); + throw pageFailure('ota_server_http', {status: response.status}); } - const serverInfo = await response.json(); + const serverInfo = await response.json().catch(() => { throw pageFailure('ota_server_invalid'); }); console.log('Server version info:', serverInfo); // 获取当前版本 @@ -13878,7 +13860,7 @@ async function checkForUpdates() { statusDiv.innerHTML = `
${t('otaPage.checkUpdateFailed')}
-
${escapeHtml(error.message)}
+
${escapeHtml(failureText({ op: 'ota.check', error, write: false }))}
`; } @@ -13972,11 +13954,7 @@ async function upgradeViaProxy(serverUrl) { // 调用 ESP32 上传接口(复用现有的 /api/v1/ota/firmware) // 注意:不自动重启,等 www 也完成后再重启 - const uploadResult = await uploadFirmwareToDevice(firmwareData, false); - - if (!uploadResult.success) { - throw new Error(uploadResult.error || t('otaPage.uploadFirmwareFailed')); - } + await uploadFirmwareToDevice(firmwareData, false); console.log('Proxy OTA: Firmware uploaded to device'); showToast(typeof t === 'function' ? t('toast.firmwareWriteComplete') : '固件写入完成!', 'success'); @@ -14011,11 +13989,7 @@ async function upgradeViaProxy(serverUrl) { progressPercent.textContent = ''; progressSize.textContent = typeof t === 'function' ? t('ui.writingSpiffs') : '正在写入 SPIFFS...'; - const wwwResult = await uploadWwwToDevice(wwwData); - - if (!wwwResult.success) { - throw new Error(wwwResult.error || t('promptRepair.webUploadFailed')); - } + await uploadWwwToDevice(wwwData); console.log('Proxy OTA: WWW uploaded to device'); showToast(typeof t === 'function' ? t('toast.webuiWriteComplete') : 'WebUI 写入完成!', 'success'); @@ -14023,7 +13997,8 @@ async function upgradeViaProxy(serverUrl) { progressPercent.textContent = ''; } catch (wwwError) { console.warn('WWW download/upload failed:', wwwError); - throw new Error(t('promptRepair.webUpgradeFailed') + (wwwError.message || wwwError)); + wwwError.otaStage = 'www'; + throw wwwError; } } @@ -14054,11 +14029,12 @@ async function upgradeViaProxy(serverUrl) { } catch (error) { console.error('Proxy OTA failed:', error); stateEl.textContent = typeof t === 'function' ? t('ui.upgradeFailed') : '升级失败'; - messageEl.textContent = error.message; + const failure = { op: error.otaStage === 'www' ? 'ota.upgradewww' : 'ota.upgrade', error, endpoint: 'ota.proxy', write: true }; + messageEl.textContent = failureText(failure); progressBar.style.width = '0%'; progressPercent.textContent = ''; otaStep = 'idle'; - showToast(typeof t === 'function' ? t('toast.upgradeFailedMsg', { msg: error.message }) : '升级失败: ' + error.message, 'error'); + reportFailure(failure); } } @@ -14066,9 +14042,9 @@ async function upgradeViaProxy(serverUrl) { * 带进度的文件下载 */ async function downloadWithProgress(url, onProgress) { - const response = await fetch(url); + const response = await fetch(url).catch(() => { throw pageFailure('ota_server_unreachable'); }); if (!response.ok) { - throw new Error(t('promptRepair.downloadHttpStatus', {status: response.status})); + throw pageFailure('ota_server_http', {status: response.status}); } const contentLength = response.headers.get('content-length'); @@ -14079,7 +14055,7 @@ async function downloadWithProgress(url, onProgress) { let loaded = 0; while (true) { - const { done, value } = await reader.read(); + const { done, value } = await reader.read().catch(() => { throw pageFailure('ota_download_interrupted'); }); if (done) break; chunks.push(value); @@ -14105,53 +14081,31 @@ async function downloadWithProgress(url, onProgress) { * 上传固件到设备 */ async function uploadFirmwareToDevice(firmwareData, autoReboot = false) { - try { - const url = getApiUrl(`/ota/firmware?auto_reboot=${autoReboot}`); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream' - }, - body: firmwareData - }); - - const result = await response.json(); - - if (response.ok && result.status === 'success') { - return { success: true, data: result }; - } else { - return { success: false, error: result.message || result.error || t('files.uploadFailed') }; - } - } catch (error) { - return { success: false, error: error.message }; - } + return uploadImageToDevice(getApiUrl(`/ota/firmware?auto_reboot=${autoReboot}`), firmwareData, 'ota.firmware'); } /** * 上传 WebUI 到设备 */ async function uploadWwwToDevice(wwwData) { + return uploadImageToDevice(getApiUrl('/ota/www'), wwwData, 'ota.www'); +} + +// Sends an image to the device. Throws an ApiOperationError: with the device's own words when it did not take the image, +// or an unconfirmed one when the connection broke before the answer came. +async function uploadImageToDevice(url, body, operation) { try { - const url = getApiUrl('/ota/www'); - const response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream' - }, - body: wwwData + headers: { 'Content-Type': 'application/octet-stream' }, + body }); - const result = await response.json(); - - if (response.ok && result.status === 'success') { - return { success: true, data: result }; - } else { - return { success: false, error: result.message || result.error || t('files.uploadFailed') }; - } + if (response.ok && result.status === 'success') return { success: true, data: result }; + throw Object.assign(new ApiOperationError({ code: 7, message: result?.message || result?.error || '' }, operation), { failureToken: 'ota_failed' }); } catch (error) { - return { success: false, error: error.message }; + if (error instanceof ApiOperationError) throw error; + throw new ApiOperationError({ code: 10 }, operation, { kind: 'network', uncertain: true }); } } @@ -14328,7 +14282,7 @@ async function refreshMemoryDetail() { try { const result = await api.getMemoryDetail(); if (result.code !== 0 || !result.data) { - throw new Error(result.message || t('memoryPage.getDataFailed')); + throw new ApiOperationError(result, 'memory.detail'); } const data = result.data; @@ -14451,7 +14405,7 @@ async function refreshMemoryDetail() { } catch (error) { console.error('Memory detail error:', error); - body.innerHTML = `
${t('memoryPage.loadFailed')}
${escapeHtml(error.message)}
`; + body.innerHTML = `
${t('memoryPage.loadFailed')}
${escapeHtml(failureText({ op: 'memory.load', error, write: false }))}
`; } } @@ -16166,10 +16120,10 @@ async function showSourceVariables(sourceId) { } renderSourceVarsTable(body, vars); } else { - setSourceVarsMessage(body, escapeHtml(result.message || t('sshPage.getVarFailed')), true); + setSourceVarsMessage(body, escapeHtml(failureText({ op: 'variable.list', result, endpoint: 'automation.variables.list', write: false })), true); } } catch (error) { - setSourceVarsMessage(body, escapeHtml(error.message), true); + setSourceVarsMessage(body, escapeHtml(failureText({ op: 'variable.list', error, endpoint: 'automation.variables.list', write: false })), true); } } @@ -16270,11 +16224,11 @@ async function saveShutdownSettings() { showToast(typeof t === 'function' ? t('toast.shutdownSettingsSaved') : '关机设置已保存', 'success'); closeShutdownSettingsModal(); } else { - errorDiv.textContent = result.message || t('errors.saveFailed'); + errorDiv.textContent = failureText({ op: 'errors.savefailed', result, write: false }); errorDiv.classList.remove('hidden'); } } catch (e) { - errorDiv.textContent = typeof t === 'function' ? t('toast.saveFailedMsg', { msg: e.message }) : '保存失败: ' + e.message; + errorDiv.textContent = failureText({ op: 'toast.savefailedmsg', error: e, write: false }); errorDiv.classList.remove('hidden'); } } @@ -16305,10 +16259,10 @@ async function resetShutdownSettings() { document.getElementById('ss-fan-stop-delay').value = 60; showToast(typeof t === 'function' ? t('toast.defaultsRestored') : '已恢复默认设置', 'success'); } else { - showToast(typeof t === 'function' ? t('toast.restoreFailedMsg', { msg: result.message || t('common.unknown') }) : '恢复失败: ' + (result.message || '未知错误'), 'error'); + reportFailure({ op: 'toast.restorefailedmsg', result, write: true }); } } catch (e) { - showToast(typeof t === 'function' ? t('toast.restoreFailedMsg', { msg: e.message }) : '恢复失败: ' + e.message, 'error'); + reportFailure({ op: 'toast.restorefailedmsg', error: e, write: true }); } } diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index 7ecd379..bdacff8 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -4148,6 +4148,30 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, ops: { generic: { failed: 'The operation failed', unknown: 'The result of the operation is unconfirmed' }, + network: { lpmuaccess: { failed: 'Failed to start the LPMU access setup', unknown: 'The result of starting the LPMU access setup is unconfirmed' }, lpmuaccessstatus: { failed: 'Failed to read the LPMU access status' } }, + wifi: { scan: { failed: 'Failed to scan for WiFi networks' } }, + cert: { status: { failed: 'Failed to refresh the certificate status' }, timesync: { failed: 'Failed to sync the device time', unknown: 'The result of syncing the device time is unconfirmed' }, keypair: { failed: 'Failed to generate the key pair', unknown: 'The result of generating the key pair is unconfirmed' }, csr: { failed: 'Failed to generate the certificate request (CSR)' }, install: { failed: 'Failed to install the certificate', unknown: 'The result of installing the certificate is unconfirmed' }, installca: { failed: 'Failed to install the CA chain', unknown: 'The result of installing the CA chain is unconfirmed' }, view: { failed: 'Failed to read the device certificate' }, delete: { failed: 'Failed to delete the PKI credentials', unknown: 'The result of deleting the PKI credentials is unconfirmed' } }, + account: { login: { failed: 'Login failed' }, changepassword: { failed: 'Failed to change the password', unknown: 'The result of changing the password is unconfirmed' }, setroot: { failed: 'Failed to set the root password', unknown: 'The result of setting the root password is unconfirmed' }, setadmin: { failed: 'Failed to set the admin password', unknown: 'The result of setting the admin password is unconfirmed' }, resetadmin: { failed: 'Failed to reset the admin password', unknown: 'The result of resetting the admin password is unconfirmed' } }, + ota: { upgrade: { failed: 'The upgrade failed', unknown: 'The result of the upgrade is unconfirmed' }, startwww: { failed: 'The WebUI upgrade could not be started', unknown: 'The result of starting the WebUI upgrade is unconfirmed' }, check: { failed: 'Failed to check for updates' }, start: { failed: 'Failed to start the firmware upgrade', unknown: 'The result of starting the firmware upgrade is unconfirmed' }, upgradewww: { failed: 'The WebUI upgrade failed (the firmware was already written)', unknown: 'The result of the WebUI upgrade is unconfirmed (the firmware was already written)' }, path: { failed: 'Could not find the file to upgrade from' } }, + device: { reboot: { failed: 'Failed to restart the device', unknown: 'The result of restarting the device is unconfirmed' } }, + led: { list: { failed: 'Failed to read the LED device list' }, clear: { failed: 'Failed to turn off the LED', unknown: 'The result of turning off the LED is unconfirmed' } }, + home: { quicklist: { failed: 'Failed to read the quick actions' }, quicklog: { failed: 'Failed to read the run log' } }, + file: { upload: { failed: 'Failed to upload the file', unknown: 'The result of uploading the file is unconfirmed' }, delete: { failed: 'Failed to delete', unknown: 'The result of the deletion is unconfirmed' }, download: { failed: 'Failed to download' } }, + memory: { load: { failed: 'Failed to read the memory details' } }, + automationpage: { logfetchfailed: { failed: '[Fetch failed]' } }, + common: { loadfailed: { failed: 'Load Failed' }, loadfailedmsg: { failed: 'Load failed' } }, + errors: { savefailed: { failed: 'Save failed' } }, + fan: { clearfailed: { failed: 'Clear failed' }, setfailed: { failed: 'Setting failed' }, setfanmodefailed: { failed: 'Failed to set fan mode' }, setspeed: { failed: 'Failed to set the speed of fan {id}', unknown: 'The result of setting the speed of fan {id} is unconfirmed' } }, + fanpage: { applycurvefailed: { failed: 'Apply curve failed' }, bindfailed: { failed: 'Bind failed' }, curveexportsdfailed: { failed: 'Fan {id} curve saved locally, SD save failed' }, importconfigfailed: { failed: 'Import config failed' }, unbindfailed: { failed: 'Unbind failed' }, readlog: { failed: 'Failed to read the log variable' } }, + filepage: { mountsdfailed: { failed: 'Mount failed' }, unmountsdfailed: { failed: 'Unmount failed' } }, + ledpage: { ccapplyfailed: { failed: 'Failed to apply color correction' }, ccexportfailed: { failed: 'Failed to export config' }, ccimportfailed: { failed: 'Failed to import config' }, ccresetfailed: { failed: 'Failed to reset color correction' } }, + login: { networkerror: { failed: 'Network error' } }, + networkpage: { lpmuaccesslogfailed: { failed: 'Could not load the execution log' } }, + pkipage: { generationfailedmsg: { failed: 'Generation failed' }, loadfailedmsg: { failed: 'Load failed' }, verifyfailed: { failed: 'Verification failed' } }, + promptrepair: { listrefreshfailed: { failed: 'The file list could not be refreshed. Refresh it manually. Confirmed upload results are unchanged' }, otapathfailed: { failed: 'The SD card upgrade file could not be read. Check its path' }, startfailed: { failed: 'The operation could not be started' } }, + securitypage: { importpackfailed: { failed: 'Import failed' }, packlistloadfailed: { failed: 'Load failed' } }, + system: { agxpowerfail: { failed: 'AGX operation failed' }, lpmutriggerfail: { failed: 'LPMU trigger failed' }, switchfailed: { failed: 'Switch failed' } }, + toast: { abortfailedmsg: { failed: 'Abort failed' }, configfailedmsg: { failed: 'Config failed' }, connectfailedmsg: { failed: 'Connection failed' }, createdirfailed: { failed: 'Failed to create directory' }, createfailedmsg: { failed: 'Create failed' }, deletefailedmsg: { failed: 'Delete failed' }, disconnectfailedmsg: { failed: 'Disconnect failed' }, downloadfailedmsg: { failed: 'Download failed' }, fetchfailed: { failed: 'Fetch failed' }, filterapplyfailed: { failed: 'Failed to apply filter' }, filterstopfailed: { failed: 'Failed to stop filter' }, importfailed: { failed: 'Import failed' }, ledbrightnessfailed: { failed: 'Failed to set {device} brightness' }, ledconfigsavefailed: { failed: 'Failed to save config' }, ledeffectstartfailed: { failed: 'Failed to start effect' }, ledeffectstopfailed: { failed: 'Failed to stop effect' }, ledfillfailed: { failed: 'Fill failed' }, ledimagefailed: { failed: 'Failed to display image' }, ledqrfailed: { failed: 'Failed to generate QR code' }, ledtextfailed: { failed: 'Failed to display text' }, ledtextstopfailed: { failed: 'Failed to stop text' }, operationfailedmsg: { failed: 'Operation failed' }, renamefailedmsg: { failed: 'Rename failed' }, restorefailedmsg: { failed: 'Restore failed' }, rollbackfailedmsg: { failed: 'Rollback failed' }, savefailed: { failed: 'Save failed' }, savefailedmsg: { failed: 'Save failed' }, setfailed: { failed: 'Setting failed' }, setfailedmsg: { failed: 'Setting failed' }, switchfailedmsg: { failed: 'Switch failed' }, syncfailed: { failed: 'Sync failed' }, upgradestartfailedmsg: { failed: 'Upgrade start failed' } }, // -- Lists read by several pages -- image: { list: { failed: 'Failed to read the image list' } }, variable: { list: { failed: 'Failed to read the variable list' } }, @@ -4163,6 +4187,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, key: { delete: { failed: 'Failed to delete key “{name}”', unknown: 'The result of deleting key “{name}” is unconfirmed', done: 'Key “{name}” deleted' }, + generate: { failed: 'Failed to generate key “{id}”', unknown: 'The result of generating key “{id}” is unconfirmed' }, exportPublic: { failed: 'Failed to export the public key of “{name}”' }, exportPrivate: { failed: 'Failed to export the private key of “{name}”' }, deploy: { failed: 'Failed to deploy key “{key}” to “{host}”', unknown: 'The result of deploying key “{key}” to “{host}” is unconfirmed' }, @@ -4170,7 +4195,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, clipboard: { copy: { failed: 'Failed to copy to the clipboard' } }, // -- SSH commands -- - service: { start: { failed: 'Failed to start service “{name}”', unknown: 'The result of starting service “{name}” is unconfirmed' } }, + service: { start: { failed: 'Failed to start service “{name}”', unknown: 'The result of starting service “{name}” is unconfirmed' }, stop: { failed: 'Failed to stop service “{name}”', unknown: 'The result of stopping service “{name}” is unconfirmed' }, restart: { failed: 'Failed to restart service “{name}”', unknown: 'The result of restarting service “{name}” is unconfirmed' }, verify: { failed: 'Failed to verify the service state', unknown: 'The result of verifying the service state is unconfirmed' }, stopprocess: { failed: 'Failed to stop the background process', unknown: 'The result of stopping the background process is unconfirmed' } }, command: { lookup: { blocked: 'Can\'t find this command' }, save: { failed: 'Failed to save command “{name}”', unknown: 'The result of saving command “{name}” is unconfirmed', done: 'Command “{name}” saved' }, @@ -4180,6 +4205,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { pack: { export: { failed: 'Failed to export “{name}” ({kind})', done: 'Exported “{name}” ({kind}): {file}' }, verify: { failed: 'Failed to verify the config pack “{file}”' }, + status: { failed: 'Failed to read the config pack status' }, + check: { failed: 'Failed to verify the config pack' }, + apply: { failed: 'Failed to apply the config pack', unknown: 'The result of applying the config pack is unconfirmed' }, + readfile: { failed: 'Failed to read the config file “{file}”' }, + generate: { failed: 'Failed to generate the config pack', unknown: 'The result of generating the config pack is unconfirmed' }, + importtext: { failed: 'Failed to import the config pack', unknown: 'The result of importing the config pack is unconfirmed' }, + certload: { failed: 'Failed to read the device certificate' }, import: { failed: 'Failed to import the config pack “{file}”', unknown: 'The result of importing the config pack “{file}” is unconfirmed', done: 'Imported the config pack “{file}”. It takes effect after a restart.' } }, // -- Automation -- @@ -4275,6 +4307,58 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { command_capacity: { why: 'the limit for commands has been reached', hint: 'Delete commands you no longer need.' }, ssh_output_incomplete: { why: 'the output of the command did not reach this page completely, so the result on the host is unconfirmed', hint: 'Check on that host whether the command has finished, and do not simply run it again.' }, ssh_result_unknown: { why: 'the result of the command did not reach this page, so it is not known whether it succeeded', hint: 'Check on that host whether the command has finished, and do not simply run it again.' }, + // -- Accounts, OTA, files, network, LED and more -- + credentials_invalid: { why: 'the username or password is wrong', hint: 'Check them and try again.' }, + account_locked: { why: 'too many wrong attempts have locked this account for a while', hint: 'Wait 5 minutes and try again.' }, + password_length: { why: 'the new password must be 4 to 64 characters long', hint: 'Choose another password.' }, + old_password_wrong: { why: 'the current password is wrong', hint: 'Check it and try again.' }, + root_required: { why: 'this action needs the root account', hint: 'Log in as root and try again.' }, + ota_failed: { why: 'the upgrade did not succeed', hint: 'Click Details to see the reason the device gave.' }, + ota_no_rollback: { why: 'there is no older version to roll back to', hint: 'An older version only exists after an upgrade.' }, + ota_server_not_set: { why: 'no OTA server address has been set', hint: 'Save a server address on the OTA page first, or enter a full address.' }, + url_invalid: { why: 'the address is not valid, or it is too long', hint: 'Enter a complete, valid address.' }, + root_directory_protected: { why: 'the root directory cannot be deleted', hint: 'Delete only the files or folders inside it.' }, + file_not_found: { why: 'the file was not found', hint: 'It may have been deleted; refresh the file list.' }, + directory_not_found: { why: 'the folder was not found', hint: 'It may have been deleted; refresh the file list.' }, + sd_already_mounted: { why: 'the SD card is already mounted', hint: 'There is no need to mount it again.' }, + sd_mount_failed: { why: 'the SD card could not be mounted', hint: 'Make sure the card is inserted, not damaged, and uses a file system the device supports.' }, + sd_unmount_failed: { why: 'the SD card could not be unmounted', hint: 'Close whatever is using it, then try once more.' }, + usb_mux_unconfigured: { why: 'the USB switch is not configured', hint: 'This device’s hardware configuration has no USB switch.' }, + ap_mode_required: { why: 'access point (AP) mode is not on', hint: 'Switch the WiFi mode to AP or AP+STA first.' }, + sta_mode_required: { why: 'station mode is not on', hint: 'Switch the WiFi mode to STA or AP+STA first.' }, + wifi_sta_not_connected: { why: 'the device is not connected to an upstream WiFi network', hint: 'Connect the device to WiFi first, then turn on NAT.' }, + fan_curve_too_many: { why: 'there are too many curve points', hint: 'Use fewer curve points and save again.' }, + fan_limits_invalid: { why: 'the duty limits are not valid', hint: 'Both limits must be between 0 and 100, and the lower one cannot be above the upper one.' }, + temp_too_many_variables: { why: 'too many variables are bound (at most 8)', hint: 'Use fewer variables.' }, + temp_name_missing: { why: 'a variable has no name', hint: 'Give every variable a name.' }, + temp_weight_required: { why: 'no variable has a weight above 0', hint: 'Give at least one variable a weight above 0.' }, + cert_no_keypair: { why: 'the device has no key pair yet', hint: 'Generate a key pair first.' }, + cert_keypair_exists: { why: 'the device already has a key pair', hint: 'To generate a new one, delete the existing PKI credentials first.' }, + cert_not_installed: { why: 'no certificate is installed', hint: 'Install a certificate first.' }, + csr_invalid: { why: 'the certificate request (CSR) is not valid', hint: 'Check fields such as the device ID (at most 63 bytes).' }, + led_device_not_found: { why: 'the LED device was not found', hint: 'Refresh the page, or check the device configuration.' }, + led_effect_not_found: { why: 'the animation was not found', hint: 'Choose the animation again.' }, + led_filter_not_found: { why: 'the filter was not found', hint: 'Choose the filter again.' }, + led_font_not_found: { why: 'the font was not found', hint: 'Choose the font again.' }, + led_matrix_only: { why: 'only the LED matrix supports this', hint: 'Use the matrix device instead.' }, + qr_text_too_long: { why: 'the QR code content is too long', hint: 'Shorten it and try again.' }, + led_image_unreadable: { why: 'the image could not be read', hint: 'Check the image path and that the SD card is inserted.' }, + key_storage_full: { why: 'the device ran out of storage, so the key was not saved', hint: 'Free up some device storage, then generate the key again.' }, + key_id_occupied: { why: 'this ID is already in use', hint: 'Choose another ID.' }, + key_limit_reached: { why: 'the key limit has been reached', hint: 'Delete keys you no longer need first.' }, + key_invalid_id: { why: 'the key ID is not valid (at most 10 UTF-8 bytes, no commas)', hint: 'Choose another ID.' }, + key_storage_failed: { why: 'the device hit an error while saving the key', hint: 'Check the key list for this key before trying again; see Terminal → System log for details.' }, + ota_path_empty: { why: 'no upgrade file path was entered', hint: 'Enter the path of the firmware file on the SD card, or of the folder that holds it.' }, + ota_firmware_missing: { why: 'the firmware file {path} was not found on the SD card', hint: 'Check the path and that the SD card is inserted.' }, + ota_www_missing: { why: 'the WebUI file {path} was not found on the SD card', hint: 'Check the path, or untick the option to upgrade the WebUI as well.' }, + directory_unreadable: { why: 'the folder {path} could not be read', hint: 'Check the path and that the SD card is inserted.' }, + ota_firmware_dir_missing: { why: 'there is no firmware file (.bin) in the folder {path}', hint: 'Put the firmware into this folder, or enter the path of the firmware file itself.' }, + ota_www_dir_missing: { why: 'there is no WebUI file (www.bin) in the folder {path}', hint: 'Put www.bin into this folder, or untick the option to upgrade the WebUI as well.' }, + ota_server_unreachable: { why: 'your browser could not reach the OTA server', hint: 'Check the server address, that the server is online, and that it allows cross-origin access.' }, + ota_server_http: { why: 'the OTA server answered with HTTP {status}', hint: 'Check the server address and that the file exists.' }, + ota_server_invalid: { why: 'the version information from the OTA server is not recognized', hint: 'Make sure the address points to a TianShanOS OTA server.' }, + ota_download_interrupted: { why: 'the connection broke during the download', hint: 'Check the network and the OTA server, then try again.' }, + file_not_json: { why: 'this file is not valid JSON', hint: 'Check the file; only config files (.json) can be used here.' }, // -- Config packs -- pack_parse_failed: { why: 'this file is not a valid config pack (its content cannot be parsed)', hint: 'Make sure you picked a .tscfg file exported from TianshanOS and that it has not been modified.' }, pack_version_unsupported: { why: 'this device does not support the version of the config pack', hint: 'Export it again with a matching firmware version, or update this device.' }, diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 1528f01..ad96fcc 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -4111,6 +4111,30 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, ops: { generic: { failed: '操作失败', unknown: '操作结果未确认' }, + network: { lpmuaccess: { failed: '启动 LPMU 接入失败', unknown: '启动 LPMU 接入的结果未确认' }, lpmuaccessstatus: { failed: '读取 LPMU 接入状态失败' } }, + wifi: { scan: { failed: '扫描 WiFi 失败' } }, + cert: { status: { failed: '刷新证书状态失败' }, timesync: { failed: '同步设备时间失败', unknown: '同步设备时间的结果未确认' }, keypair: { failed: '生成密钥对失败', unknown: '生成密钥对的结果未确认' }, csr: { failed: '生成证书请求(CSR)失败' }, install: { failed: '安装证书失败', unknown: '安装证书的结果未确认' }, installca: { failed: '安装 CA 证书链失败', unknown: '安装 CA 证书链的结果未确认' }, view: { failed: '读取设备证书失败' }, delete: { failed: '删除 PKI 凭证失败', unknown: '删除 PKI 凭证的结果未确认' } }, + account: { login: { failed: '登录失败' }, changepassword: { failed: '修改密码失败', unknown: '修改密码的结果未确认' }, setroot: { failed: '设置 root 密码失败', unknown: '设置 root 密码的结果未确认' }, setadmin: { failed: '设置 admin 密码失败', unknown: '设置 admin 密码的结果未确认' }, resetadmin: { failed: '重置 admin 密码失败', unknown: '重置 admin 密码的结果未确认' } }, + ota: { upgrade: { failed: '升级失败', unknown: '升级的结果未确认' }, startwww: { failed: 'WebUI 升级没能启动', unknown: '启动 WebUI 升级的结果未确认' }, check: { failed: '检查更新失败' }, start: { failed: '启动固件升级失败', unknown: '启动固件升级的结果未确认' }, upgradewww: { failed: 'WebUI 升级失败(固件已经写入)', unknown: 'WebUI 升级的结果未确认(固件已经写入)' }, path: { failed: '找不到要升级的文件' } }, + device: { reboot: { failed: '重启设备失败', unknown: '重启设备的结果未确认' } }, + led: { list: { failed: '读取 LED 设备列表失败' }, clear: { failed: '关闭 LED 失败', unknown: '关闭 LED 的结果未确认' } }, + home: { quicklist: { failed: '读取快捷操作失败' }, quicklog: { failed: '读取运行日志失败' } }, + file: { upload: { failed: '上传文件失败', unknown: '上传文件的结果未确认' }, delete: { failed: '删除失败', unknown: '删除的结果未确认' }, download: { failed: '下载失败' } }, + memory: { load: { failed: '读取内存详情失败' } }, + automationpage: { logfetchfailed: { failed: '[获取失败]' } }, + common: { loadfailed: { failed: '加载失败' }, loadfailedmsg: { failed: '加载失败' } }, + errors: { savefailed: { failed: '保存失败' } }, + fan: { clearfailed: { failed: '清除失败' }, setfailed: { failed: '设置失败' }, setfanmodefailed: { failed: '设置风扇模式失败' }, setspeed: { failed: '设置风扇 {id} 的转速失败', unknown: '设置风扇 {id} 转速的结果未确认' } }, + fanpage: { applycurvefailed: { failed: '应用曲线失败' }, bindfailed: { failed: '绑定失败' }, curveexportsdfailed: { failed: '风扇 {id} 曲线已保存到本地,SD 卡保存失败' }, importconfigfailed: { failed: '导入配置失败' }, unbindfailed: { failed: '解绑失败' }, readlog: { failed: '读取日志变量失败' } }, + filepage: { mountsdfailed: { failed: '挂载失败' }, unmountsdfailed: { failed: '卸载失败' } }, + ledpage: { ccapplyfailed: { failed: '应用色彩校正失败' }, ccexportfailed: { failed: '导出配置失败' }, ccimportfailed: { failed: '导入配置失败' }, ccresetfailed: { failed: '重置色彩校正失败' } }, + login: { networkerror: { failed: '网络错误' } }, + networkpage: { lpmuaccesslogfailed: { failed: '执行日志读取失败' } }, + pkipage: { generationfailedmsg: { failed: '生成失败' }, loadfailedmsg: { failed: '加载失败' }, verifyfailed: { failed: '验证失败' } }, + promptrepair: { listrefreshfailed: { failed: '文件列表刷新失败,请手动刷新。已确认的上传结果不受影响' }, otapathfailed: { failed: '无法读取 SD 卡升级文件,请检查文件路径' }, startfailed: { failed: '未能启动操作' } }, + securitypage: { importpackfailed: { failed: '导入失败' }, packlistloadfailed: { failed: '加载失败' } }, + system: { agxpowerfail: { failed: 'AGX 操作失败' }, lpmutriggerfail: { failed: 'LPMU 触发失败' }, switchfailed: { failed: '切换失败' } }, + toast: { abortfailedmsg: { failed: '中止失败' }, configfailedmsg: { failed: '配置失败' }, connectfailedmsg: { failed: '连接失败' }, createdirfailed: { failed: '创建目录失败' }, createfailedmsg: { failed: '创建失败' }, deletefailedmsg: { failed: '删除失败' }, disconnectfailedmsg: { failed: '断开失败' }, downloadfailedmsg: { failed: '下载失败' }, fetchfailed: { failed: '获取失败' }, filterapplyfailed: { failed: '应用滤镜失败' }, filterstopfailed: { failed: '停止滤镜失败' }, importfailed: { failed: '导入失败' }, ledbrightnessfailed: { failed: '设置 {device} 亮度失败' }, ledconfigsavefailed: { failed: '保存配置失败' }, ledeffectstartfailed: { failed: '启动动画失败' }, ledeffectstopfailed: { failed: '停止动画失败' }, ledfillfailed: { failed: '填充失败' }, ledimagefailed: { failed: '显示图像失败' }, ledqrfailed: { failed: '生成 QR 码失败' }, ledtextfailed: { failed: '显示文本失败' }, ledtextstopfailed: { failed: '停止文本失败' }, operationfailedmsg: { failed: '操作失败' }, renamefailedmsg: { failed: '重命名失败' }, restorefailedmsg: { failed: '恢复失败' }, rollbackfailedmsg: { failed: '回滚失败' }, savefailed: { failed: '保存失败' }, savefailedmsg: { failed: '保存失败' }, setfailed: { failed: '设置失败' }, setfailedmsg: { failed: '设置失败' }, switchfailedmsg: { failed: '切换失败' }, syncfailed: { failed: '同步失败' }, upgradestartfailedmsg: { failed: '启动升级失败' } }, // —— 通用的列表读取(各页都会用到)—— image: { list: { failed: '读取图片列表失败' } }, variable: { list: { failed: '读取变量列表失败' } }, @@ -4126,6 +4150,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, key: { delete: { failed: '删除密钥“{name}”失败', unknown: '删除密钥“{name}”的结果未确认', done: '已删除密钥“{name}”' }, + generate: { failed: '生成密钥“{id}”失败', unknown: '生成密钥“{id}”的结果未确认' }, exportPublic: { failed: '导出密钥“{name}”的公钥失败' }, exportPrivate: { failed: '导出密钥“{name}”的私钥失败' }, deploy: { failed: '向“{host}”部署密钥“{key}”失败', unknown: '向“{host}”部署密钥“{key}”的结果未确认' }, @@ -4133,7 +4158,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, clipboard: { copy: { failed: '复制到剪贴板失败' } }, // —— SSH 指令 —— - service: { start: { failed: '启动服务“{name}”失败', unknown: '启动服务“{name}”的结果未确认' } }, + service: { start: { failed: '启动服务“{name}”失败', unknown: '启动服务“{name}”的结果未确认' }, stop: { failed: '停止服务“{name}”失败', unknown: '停止服务“{name}”的结果未确认' }, restart: { failed: '重启服务“{name}”失败', unknown: '重启服务“{name}”的结果未确认' }, verify: { failed: '核验服务状态失败', unknown: '核验服务状态的结果未确认' }, stopprocess: { failed: '停止后台进程失败', unknown: '停止后台进程的结果未确认' } }, command: { lookup: { blocked: '找不到这条指令' }, save: { failed: '保存指令“{name}”失败', unknown: '保存指令“{name}”的结果未确认', done: '已保存指令“{name}”' }, @@ -4143,6 +4168,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { pack: { export: { failed: '导出“{name}”({kind})失败', done: '已导出“{name}”({kind}):{file}' }, verify: { failed: '验证配置包“{file}”失败' }, + status: { failed: '读取配置包状态失败' }, + check: { failed: '验证配置包失败' }, + apply: { failed: '应用配置包失败', unknown: '应用配置包的结果未确认' }, + readfile: { failed: '读取配置文件“{file}”失败' }, + generate: { failed: '生成配置包失败', unknown: '生成配置包的结果未确认' }, + importtext: { failed: '导入配置包失败', unknown: '导入配置包的结果未确认' }, + certload: { failed: '读取设备证书失败' }, import: { failed: '导入配置包“{file}”失败', unknown: '导入配置包“{file}”的结果未确认', done: '已导入配置包“{file}”,重启后生效' } }, // —— 自动化 —— @@ -4238,6 +4270,58 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { command_capacity: { why: '指令数量已达上限', hint: '请先删除不用的指令。' }, ssh_output_incomplete: { why: '命令的输出没能完整送到页面,远端的执行结果未确认', hint: '请到那台主机上确认命令是否已经执行完,不要直接重复执行。' }, ssh_result_unknown: { why: '命令的结果没能送到页面,是否成功未知', hint: '请到那台主机上确认命令是否已经执行完,不要直接重复执行。' }, + // —— 账户、OTA、文件、网络、LED 等 —— + credentials_invalid: { why: '用户名或密码不对', hint: '请检查后重新输入。' }, + account_locked: { why: '连续输错次数太多,这个账户暂时被锁定', hint: '请等 5 分钟后再试。' }, + password_length: { why: '新密码的长度不对(需要 4 到 64 个字符)', hint: '请重新设置密码。' }, + old_password_wrong: { why: '当前密码不对', hint: '请检查后重新输入。' }, + root_required: { why: '这项操作需要 root 账户', hint: '请换用 root 账户登录后再操作。' }, + ota_failed: { why: '升级没有成功', hint: '点“详情”可以看到设备给出的原因。' }, + ota_no_rollback: { why: '没有可以回滚的旧版本', hint: '只有升级过之后,才会有旧版本可以回滚。' }, + ota_server_not_set: { why: '还没有设置 OTA 服务器地址', hint: '请先在 OTA 页保存服务器地址,或直接填写完整的地址。' }, + url_invalid: { why: '地址的格式不对,或者太长了', hint: '请填写完整、正确的地址。' }, + root_directory_protected: { why: '根目录不能删除', hint: '请只删除里面的文件或文件夹。' }, + file_not_found: { why: '找不到这个文件', hint: '它可能已被删除,请刷新文件列表。' }, + directory_not_found: { why: '找不到这个文件夹', hint: '它可能已被删除,请刷新文件列表。' }, + sd_already_mounted: { why: 'SD 卡已经挂载了', hint: '无需再次挂载。' }, + sd_mount_failed: { why: 'SD 卡没能挂载', hint: '请确认卡已插好、没有损坏,并且是设备支持的文件系统。' }, + sd_unmount_failed: { why: 'SD 卡没能卸载', hint: '请先关闭正在使用它的操作,再试一次。' }, + usb_mux_unconfigured: { why: 'USB 切换器还没有配置', hint: '这台设备的硬件配置里没有 USB 切换器。' }, + ap_mode_required: { why: '热点(AP)模式还没有开启', hint: '请先把 WiFi 模式切到热点,或“热点+客户端”。' }, + sta_mode_required: { why: 'WiFi 客户端模式还没有开启', hint: '请先把 WiFi 模式切到客户端,或“热点+客户端”。' }, + wifi_sta_not_connected: { why: '设备还没有连上上级 WiFi', hint: '请先让设备连上 WiFi,再开启 NAT。' }, + fan_curve_too_many: { why: '曲线点太多', hint: '请减少曲线点的数量后再保存。' }, + fan_limits_invalid: { why: '占空比的上下限不合法', hint: '上下限都要在 0 到 100 之间,且下限不能大于上限。' }, + temp_too_many_variables: { why: '绑定的变量太多(最多 8 个)', hint: '请减少变量的数量。' }, + temp_name_missing: { why: '有变量没有填名称', hint: '请给每个变量填上名称。' }, + temp_weight_required: { why: '没有一个变量的权重大于 0', hint: '请至少让一个变量的权重大于 0。' }, + cert_no_keypair: { why: '设备上还没有密钥对', hint: '请先生成密钥对。' }, + cert_keypair_exists: { why: '设备上已经有密钥对了', hint: '如要重新生成,请先删除现有的 PKI 凭证。' }, + cert_not_installed: { why: '设备上还没有安装证书', hint: '请先安装证书。' }, + csr_invalid: { why: '证书请求(CSR)的内容不合要求', hint: '请检查设备 ID 等字段(设备 ID 不能超过 63 个字节)。' }, + led_device_not_found: { why: '找不到这个 LED 设备', hint: '请刷新页面,或检查设备配置。' }, + led_effect_not_found: { why: '找不到这个动画', hint: '请重新选择动画。' }, + led_filter_not_found: { why: '找不到这个滤镜', hint: '请重新选择滤镜。' }, + led_font_not_found: { why: '找不到这个字体', hint: '请重新选择字体。' }, + led_matrix_only: { why: '这项功能只有 LED 矩阵屏支持', hint: '请改用矩阵屏设备。' }, + qr_text_too_long: { why: '二维码的内容太长', hint: '请缩短内容后再试。' }, + led_image_unreadable: { why: '读不到这张图片', hint: '请检查图片路径,并确认 SD 卡已插好。' }, + key_storage_full: { why: '设备存储空间不足,密钥没有保存', hint: '请先清理设备存储,再重新生成。' }, + key_id_occupied: { why: '这个 ID 已经被使用', hint: '请换一个 ID。' }, + key_limit_reached: { why: '密钥数量已达上限', hint: '请先删除不用的密钥。' }, + key_invalid_id: { why: '密钥 ID 不合要求(最多 10 个 UTF-8 字节,且不能包含逗号)', hint: '请换一个 ID。' }, + key_storage_failed: { why: '设备在保存密钥时出错', hint: '请先在密钥列表里核对这把密钥是否已经出现,再决定是否重试;详情见「终端」→「系统日志」。' }, + ota_path_empty: { why: '还没有填写升级文件的路径', hint: '请填写 SD 卡上固件文件,或存放固件的文件夹的路径。' }, + ota_firmware_missing: { why: 'SD 卡上找不到固件文件 {path}', hint: '请检查路径是否正确、SD 卡是否已插好。' }, + ota_www_missing: { why: 'SD 卡上找不到 WebUI 文件 {path}', hint: '请检查路径,或取消勾选同时升级 WebUI 的选项。' }, + directory_unreadable: { why: '读不到文件夹 {path}', hint: '请检查路径是否正确、SD 卡是否已插好。' }, + ota_firmware_dir_missing: { why: '文件夹 {path} 里没有固件文件(.bin)', hint: '请把固件放进这个文件夹,或直接填写固件文件的路径。' }, + ota_www_dir_missing: { why: '文件夹 {path} 里没有 WebUI 文件(www.bin)', hint: '请把 www.bin 放进这个文件夹,或取消勾选同时升级 WebUI 的选项。' }, + ota_server_unreachable: { why: '你的浏览器连不上 OTA 服务器', hint: '请检查服务器地址是否正确、服务器是否在线,并确认服务器允许跨域访问。' }, + ota_server_http: { why: 'OTA 服务器返回了错误状态 HTTP {status}', hint: '请检查服务器地址,以及要下载的文件是否存在。' }, + ota_server_invalid: { why: 'OTA 服务器返回的版本信息无法识别', hint: '请确认这个地址指向的是天山 OS 的 OTA 服务器。' }, + ota_download_interrupted: { why: '下载固件时连接中断了', hint: '请检查网络和 OTA 服务器,再重试。' }, + file_not_json: { why: '这个文件不是有效的 JSON', hint: '请检查文件内容;这里只能选择配置文件(.json)。' }, // —— 配置包 —— pack_parse_failed: { why: '这个文件不是有效的配置包(内容无法解析)', hint: '请确认选的是从天山 OS 导出的 .tscfg 文件,并且没有被改动过。' }, pack_version_unsupported: { why: '配置包的版本这台设备不支持', hint: '请换用匹配的固件版本重新导出,或把这台设备升级到较新的版本。' }, diff --git a/docs/repair/failure-messages/README.md b/docs/repair/failure-messages/README.md index e395ed9..df8e64a 100644 --- a/docs/repair/failure-messages/README.md +++ b/docs/repair/failure-messages/README.md @@ -1,8 +1,8 @@ # 失败提示:统一目录与写法 用户点按钮、做操作失败时看到的提示,按同一套方式生成:**动作+对象 → 原因 → 下一步**。 -固件只返回一个固定的「原因代号」,网页按当前语言把它翻成句子。已接入三个样板间(规则启用/禁用/保存、 -首页快捷操作、SSH 测试连接),其余页面按计划逐页迁移。 +固件只返回一个固定的「原因代号」,网页按当前语言把它翻成句子。所有页面的操作失败提示都走这一套: +自动化、SSH、系统与服务、账户登录与改密、文件、网络与 WiFi、风扇、LED、证书与密钥、配置包、OTA 升级。 ``` 禁用规则“夜间关灯”失败:它正在执行中。等执行完成后再试。 @@ -47,6 +47,21 @@ 中文里没有多余英文、英文里没有中文、原因无句末标点、下一步是完整句子;并且**从 C 源码读出固件会发出的每个原因代号**, 没有对应说法就失败。反过来,目录里没有任何地方会产生的条目也会失败。 +## 页面里怎么用(`spec = { op, params, result | error, token, endpoint, write }`) + +| 场景 | 调用 | +|---|---| +| 提示条(8 秒,带「详情」) | `reportFailure(spec)`;结果未确认时 `reportFailure(spec, { type: 'warning' })` | +| 一段内嵌文字(不带详情) | `failureText(spec)` | +| 内嵌 HTML(鼠标悬停显示详情) | `failureNote(spec)` | +| 弹窗里的结果框(带折叠详情) | `packBox(box, spec)` | +| 页面自己发现的问题(没有发请求,或设备的回答不能用) | `throw pageFailure('原因代号', { 占位符 })`,调用处照常 `reportFailure({ op, error })` | +| 要给出设备的原因 | 用 `requireApiSuccess(result, '接口名')` 抛错;**不要**写 `throw new Error(result.message)`:那会被当成「页面错误」,设备给的原因就丢了 | +| 浏览器拒绝写剪贴板 | `reportCopyFailure(e)` | +| 旧接口只发英文原文 | 在 `js/api.js` 的 `FAILURE_LEGACY_TEXT`(整句)或 `FAILURE_LEGACY_PREFIX`(开头)里加一条对照 | + +`write: true` 表示这个操作会改变设备上的东西:拿不到设备回答时说「结果未确认」,而不是「失败」;读取、测试、校验传 `write: false`。 + ## 失败提示条(`showFailureToast` / `reportFailure`,`js/app.js`) - 停留 8 秒;鼠标或键盘焦点在上面、或「详情」展开时暂停;× 可以直接关闭。 diff --git a/tests/prompts/failure-catalog.test.cjs b/tests/prompts/failure-catalog.test.cjs index 84cc657..a6ec3f2 100644 --- a/tests/prompts/failure-catalog.test.cjs +++ b/tests/prompts/failure-catalog.test.cjs @@ -10,7 +10,7 @@ const failure = lang => langs[lang].failure; const slots = text => [...new Set([...text.matchAll(/(? m[1]))].sort(); const CJK = /[㐀-鿿＀-￯ -〿]/; // "automation" is the service name exactly as the System page's service list shows it; "json" is in a file name; "ID" is the field's label -const ALLOWED_LATIN = new Set(['SSH', 'SD', 'DNS', 'IP', 'WebUI', 'automation', 'json', 'ID', 'HTTP', 'WebSocket', 'Socket', 'IO', 'REST', 'OS', 'tscfg']); +const ALLOWED_LATIN = new Set(['SSH', 'SD', 'DNS', 'IP', 'WebUI', 'automation', 'json', 'ID', 'HTTP', 'WebSocket', 'Socket', 'IO', 'REST', 'OS', 'tscfg', 'JSON', 'UTF', 'LPMU', 'CA', 'bin', 'www', 'root', 'admin', 'OTA', 'USB', 'LED', 'NAT', 'WiFi', 'AP', 'STA', 'CSR', 'PKI', 'FAT', 'URL', 'QR']); const flat = (object, prefix = '') => Object.entries(object).flatMap(([k, v]) => typeof v === 'string' ? [[prefix + k, v]] : flat(v, prefix + k + '.')); test('both languages have the same reasons, the same parts of each reason and the same placeholders', () => { @@ -155,7 +155,7 @@ test('every reason token the firmware can send has a message in both languages', }); test('the catalogue has no reason that nothing can produce (a sentence nobody can reach is a trap for the next edit)', () => { - const sources = [component('ts_api/src/ts_api_automation.c'), component('ts_api/src/ts_api_ssh.c'), component('ts_automation/src/ts_rule_engine.c'), + const sources = [component('ts_api/src/ts_api_automation.c'), component('ts_api/src/ts_api_ssh.c'), component('ts_api/src/ts_api_key.c'), component('ts_automation/src/ts_rule_engine.c'), component('ts_automation/src/ts_rule_store.c'), component('ts_config_pack/src/ts_config_pack.c'), component('ts_security/src/ts_ssh_client.c'), component('ts_webui/src/ts_webui_ws.c'), component('ts_webui/src/ts_ws_operation.c'), read('js/app.js'), read('js/api.js')].join('\n'); // reasons the page produces from the error class, from a transport state, or from the API envelope of other modules const GENERIC = new Set(['busy', 'no_memory', 'not_found', 'no_permission', 'timeout', 'invalid_request', 'invalid_argument', 'not_supported', 'hardware_failed', diff --git a/tests/prompts/flows.browser.test.cjs b/tests/prompts/flows.browser.test.cjs index 819a449..7c613f7 100644 --- a/tests/prompts/flows.browser.test.cjs +++ b/tests/prompts/flows.browser.test.cjs @@ -429,13 +429,35 @@ scenarioTests('ssh-commands', [ // --------------------------------------------------------------------------------------------------------------------- // Behaviour the sentences depend on (not text). -test('a data source switch asks the device about the source itself, not about a rule with the same id', async () => { - await kit.withPage('zh-CN', call => call.endpoint === 'automation/sources/enable' ? '{"code":0,"message":"Source enabled"}' : undefined, async ({page, calls}) => { - await page.evaluate(() => toggleSource('temp', true)); - assert.deepEqual(calls.filter(c => c.endpoint.startsWith('automation/')).map(c => [c.endpoint, c.params]).slice(0, 1), [['automation/sources/enable', {id: 'temp'}]]); - assert(!calls.some(c => c.endpoint === 'automation/rules/get'), 'a source must not be looked up as a rule'); +for (const language of ['zh-CN', 'en-US']) { + test(`${language}: a data source switch enables and disables without a same-id rule, then displays the device state`, async () => { + let enabled = false; + await kit.withPage(language, call => { + if (call.endpoint === 'automation/rules/get') return {code: 4, message: 'Rule not found'}; + if (call.endpoint === 'automation/sources/list') return ok({sources: [{id: 'temp', label: 'Temperature', type: 'rest', enabled}]}); + if (call.endpoint === 'automation/sources/enable' || call.endpoint === 'automation/sources/disable') { + enabled = call.endpoint.endsWith('/enable'); + return ok(); + } + }, async ({page, calls}) => { + await page.evaluate(async () => { + document.body.insertAdjacentHTML('beforeend', '
'); + await refreshSources(); + }); + for (const next of [true, false]) { + const count = calls.length; + await page.locator('#sources-list [role="switch"]').click(); + await page.waitForFunction(next => document.querySelector('#sources-list [role="switch"]')?.getAttribute('aria-checked') === String(next), next); + assert.equal(enabled, next); + assert.deepEqual(calls.slice(count).filter(c => c.endpoint.startsWith('automation/')).map(c => [c.endpoint, c.params]), [ + [`automation/sources/${next ? 'enable' : 'disable'}`, {id: 'temp'}], + ['automation/sources/list', {}], + ]); + } + assert(!calls.some(c => c.endpoint === 'automation/rules/get'), 'a source must not be looked up as a rule'); + }); }); -}); +} test('engine buttons: the engine status is read again whatever the answer was', async () => { for (const answer of [wire('wire-engine-start-ok.json'), wire('wire-engine-start-paused.json'), DROP]) { From df540286a671840e03260e995a2a32da4927537c Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 10:19:07 +0800 Subject: [PATCH 19/29] fix(automation): close rule-pack deletion and dependency review gaps Classify active/saved union states and block new admission after confirmed pending deletion while preserving existing runtime objects and leases. Keep ordinary rule loading compatible, retain published source identity, and scope strict dependencies to active and saved package versions. Share effective-action/proposed-object validation for direct/reference SSH, internal-first host fallbacks and unsupported Webhook actions. Add the directed A1-A3, B1-B3 and C1-C3 production-path regressions and bilingual pending-delete/error UI checks. Preserve v3 storage, encrypted trust and save-only/reboot activation. Record red/green evidence and unverified device/deployment prerequisites in R1-R3-review.md. --- components/ts_api/src/ts_api_automation.c | 11 +- .../ts_automation/include/ts_action_manager.h | 1 + .../ts_automation/include/ts_rule_pack.h | 3 + .../ts_automation/src/ts_action_manager.c | 9 +- components/ts_automation/src/ts_rule_engine.c | 211 +++++++++------ components/ts_automation/src/ts_rule_pack.c | 191 ++++++++++---- components/ts_webui/web/js/app.js | 15 +- components/ts_webui/web/js/lang/en-US.js | 2 + components/ts_webui/web/js/lang/zh-CN.js | 2 + tests/prompts/rule-pack.browser.test.cjs | 34 ++- tests/runtime/extract_engine.py | 3 +- tests/runtime/rule_pack/R1-R3-review.md | 125 +++++++++ .../rule_pack/stubs/ts_action_manager.h | 6 + tests/runtime/rule_pack/test_engine_pack.c | 243 +++++++++++++++++- tests/runtime/rule_pack/test_store_set.c | 12 +- tests/runtime/rule_pack_contract.txt | 23 ++ tests/runtime/run_rule_pack.sh | 4 + tests/runtime/test_action_pack.py | 2 +- tests/runtime/test_rule_pack_crypto.py | 22 +- 19 files changed, 766 insertions(+), 153 deletions(-) create mode 100644 tests/runtime/rule_pack/R1-R3-review.md diff --git a/components/ts_api/src/ts_api_automation.c b/components/ts_api/src/ts_api_automation.c index 583184d..a1aa6b1 100644 --- a/components/ts_api/src/ts_api_automation.c +++ b/components/ts_api/src/ts_api_automation.c @@ -716,7 +716,11 @@ static esp_err_t api_automation_rules_trigger(const cJSON *params, ts_api_result result->message = strdup("Rule not found"); } else { result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to trigger rule"); + result->data=cJSON_CreateObject(); + if(result->data)ts_rule_saved_status(id_param->valuestring,result->data); + const cJSON *change=cJSON_GetObjectItemCaseSensitive(result->data,"pending_change"); + result->message = strdup(cJSON_IsString(change)&&!strcmp(change->valuestring,"delete")? + "restart_pending":"Failed to trigger rule"); } return ESP_OK; @@ -731,8 +735,12 @@ static esp_err_t api_automation_rules_trigger(const cJSON *params, ts_api_result */ static esp_err_t api_automation_rules_get(const cJSON *params, ts_api_result_t *result) { const cJSON *id = cJSON_GetObjectItemCaseSensitive(params, "id"); + if(!ts_rule_edit_begin()){ + ts_api_result_error(result,TS_API_ERR_INTERNAL,"configuration_busy");return ESP_OK; + } ts_auto_rule_t rule; if (!cJSON_IsString(id) || ts_rule_acquire(id->valuestring, &rule) != ESP_OK) { + ts_rule_edit_end(); ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "Rule not found"); return ESP_OK; } @@ -741,6 +749,7 @@ static esp_err_t api_automation_rules_get(const cJSON *params, ts_api_result_t * if(result->data)ts_rule_saved_status(rule.id,result->data); ts_rule_release(&rule); result->code = result->data ? TS_API_OK : TS_API_ERR_INTERNAL; + ts_rule_edit_end(); return ESP_OK; } diff --git a/components/ts_automation/include/ts_action_manager.h b/components/ts_automation/include/ts_action_manager.h index ba17668..f8b0fca 100644 --- a/components/ts_automation/include/ts_action_manager.h +++ b/components/ts_automation/include/ts_action_manager.h @@ -162,6 +162,7 @@ esp_err_t ts_action_manager_resume(void); */ bool ts_action_manager_is_initialized(void); bool ts_action_templates_ready(void); +esp_err_t ts_action_templates_load_state(void); /*===========================================================================*/ /* SSH Host Management */ diff --git a/components/ts_automation/include/ts_rule_pack.h b/components/ts_automation/include/ts_rule_pack.h index 49fd714..13faf4e 100644 --- a/components/ts_automation/include/ts_rule_pack.h +++ b/components/ts_automation/include/ts_rule_pack.h @@ -1,9 +1,12 @@ #pragma once #include "ts_config_pack.h" #include "ts_automation_types.h" +#include "ts_rule_engine.h" /* Strict envelope adapter; plain JSON continues to use ts_rule_decode directly. */ esp_err_t ts_rule_pack_decode(const char *content, size_t length, ts_auto_rule_t *rule); const char *ts_rule_pack_error(ts_config_pack_result_t error); /* Caller holds the SSH configuration binding lock. No execution, connection or pin. */ esp_err_t ts_rule_pack_dependencies(const ts_auto_rule_t *rule, const char **reason, cJSON **warnings); +esp_err_t ts_rule_pack_dependency_change(const ts_auto_rule_t *rule, ts_rule_dependency_t kind, + const char *id, const void *next, const char **reason); diff --git a/components/ts_automation/src/ts_action_manager.c b/components/ts_automation/src/ts_action_manager.c index 723a0a5..9bd53b9 100644 --- a/components/ts_automation/src/ts_action_manager.c +++ b/components/ts_automation/src/ts_action_manager.c @@ -123,6 +123,7 @@ typedef struct { /* Initialization state */ bool initialized; atomic_bool templates_ready; + atomic_int templates_load_result; } action_manager_ctx_t; static action_manager_ctx_t *s_ctx = NULL; @@ -290,6 +291,7 @@ esp_err_t ts_action_manager_init(void) goto cleanup; } + s_ctx->templates_load_result=ESP_ERR_INVALID_STATE; s_ctx->initialized = true; /* 延迟加载模板(等待 SD 卡挂载,避免栈溢出)*/ @@ -355,6 +357,9 @@ void ts_action_deferred_load_task(void *arg) } bool ts_action_templates_ready(void) { return s_ctx && s_ctx->initialized && s_ctx->templates_ready; } +esp_err_t ts_action_templates_load_state(void) { + return s_ctx&&s_ctx->initialized?atomic_load(&s_ctx->templates_load_result):ESP_ERR_INVALID_STATE; +} esp_err_t ts_action_manager_deinit(void) { @@ -504,7 +509,7 @@ esp_err_t ts_action_get_ssh_host_ex(const char *host_id, ts_action_ssh_host_t *h return ESP_OK; } - return ESP_ERR_NOT_FOUND; + return ret; } esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *out) { @@ -3331,6 +3336,7 @@ esp_err_t ts_action_templates_load(void) { esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,NULL,NULL); if (ret == ESP_OK) { ret = templates_load_impl(); + s_ctx->templates_load_result=ret; s_ctx->templates_ready = ret == ESP_OK; } ts_ssh_binding_unlock(); @@ -3342,6 +3348,7 @@ esp_err_t ts_action_templates_load_from_file(const char *path) { esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,NULL,NULL); if (ret == ESP_OK) { ret = templates_load_file_impl(path); + s_ctx->templates_load_result=ret; s_ctx->templates_ready = ret == ESP_OK; } ts_ssh_binding_unlock(); diff --git a/components/ts_automation/src/ts_rule_engine.c b/components/ts_automation/src/ts_rule_engine.c index 53e2321..4558bdb 100644 --- a/components/ts_automation/src/ts_rule_engine.c +++ b/components/ts_automation/src/ts_rule_engine.c @@ -92,6 +92,8 @@ typedef struct { SemaphoreHandle_t transaction; struct { bool committing, executing, readonly; + bool pending_delete; + int source; /* Published version's source; -1 means legacy/unknown. */ } meta[CONFIG_TS_AUTOMATION_MAX_RULES]; ts_rule_engine_stats_t stats; // 统计信息 } ts_rule_engine_ctx_t; @@ -104,6 +106,11 @@ static ts_rule_engine_ctx_t s_rule_ctx = { .initialized = false, }; +typedef enum { + RULE_SYNCED, RULE_PENDING_ADD, RULE_PENDING_UPDATE, RULE_PENDING_DELETE, + RULE_ABSENT, RULE_UNKNOWN +} rule_state_t; + typedef struct { unsigned refs; bool retired; @@ -399,7 +406,6 @@ void ts_rule_deferred_load_task(void *arg) esp_err_t previous = ESP_OK; while (s_rule_ctx.initialized) { /* Readiness is explicit. The sampling interval is not evidence of ready. */ - if (!ts_action_templates_ready()) { vTaskDelay(pdMS_TO_TICKS(100)); continue; } esp_err_t ret = ts_rules_load(); if (ret == ESP_OK) { ESP_LOGI(TAG, "Deferred rule loading complete: %d rules", s_rule_ctx.count); @@ -539,10 +545,50 @@ static bool protected_bindings(const ts_auto_rule_t *old, const ts_auto_rule_t * } return false; } +/* Caller holds the configuration transaction and the short runtime mutex. + * This reads only confirmed in-memory metadata, never decrypts/re-encodes rules. */ +static rule_state_t classify_rule_state(const char *id, int active_index, bool confirmed, + ts_rule_saved_info_t *info) { + if (!confirmed) return RULE_UNKNOWN; + if (!ts_rule_store_set_present()) return active_index < 0 ? RULE_ABSENT : RULE_SYNCED; + esp_err_t e=ts_rule_store_set_info(id,info); + if(e==ESP_ERR_NOT_FOUND) return active_index < 0 ? RULE_ABSENT : RULE_PENDING_DELETE; + if(e!=ESP_OK) return RULE_UNKNOWN; + if(active_index<0) return RULE_PENDING_ADD; + return info->revision!=s_rule_ctx.rules[active_index].revision || + (s_rule_ctx.meta[active_index].source>=0 && s_rule_ctx.meta[active_index].source!=(int)info->source) + ? RULE_PENDING_UPDATE : RULE_SYNCED; +} +static bool state_pending(rule_state_t state) { + return state==RULE_PENDING_ADD||state==RULE_PENDING_UPDATE||state==RULE_PENDING_DELETE; +} static bool pending_id(const char *id, int active_index) { - ts_rule_saved_info_t info = {0}; - return ts_rule_store_set_available() && ts_rule_store_set_info(id, &info) == ESP_OK && - (active_index < 0 || info.revision != s_rule_ctx.rules[active_index].revision); + ts_rule_saved_info_t info={0}; + return state_pending(classify_rule_state(id,active_index, + s_rule_ctx.loaded&&!s_rule_ctx.recovery_error,&info)); +} +static int rule_union_count_locked(void) { + int count=s_rule_ctx.count; + for(int i=0;ierror_code = "revision_conflict"; } else if (i < 0 && !input) { ret = ESP_ERR_NOT_FOUND; - } else if (i < 0 && (ts_rule_store_set_available() ? ts_rule_store_set_count() : s_rule_ctx.count) == s_rule_ctx.capacity) { + } else if (occupied < 0) { + ret = ESP_ERR_INVALID_STATE; + result->error_code = "recovery_required"; + } else if (i < 0 && occupied >= s_rule_ctx.capacity) { ret = ESP_ERR_NO_MEM; result->error_code = "capacity"; } else if (i >= 0 && s_rule_ctx.meta[i].readonly) { @@ -684,6 +734,9 @@ static esp_err_t rule_commit_impl(const ts_auto_rule_t *input, const char *id, u } if (input) { s_rule_ctx.rules[i] = candidate; + ts_rule_saved_info_t info={0}; + s_rule_ctx.meta[i].source=ts_rule_store_set_info(id,&info)==ESP_OK?(int)info.source:-1; + s_rule_ctx.meta[i].pending_delete=false; candidate.lease = NULL; result->revision = s_rule_ctx.rules[i].revision; } else { @@ -871,6 +924,7 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); int preliminary = find_rule_index(id); bool unavailable = preliminary < 0 || !s_rule_ctx.loaded || s_rule_ctx.recovery_error || + s_rule_ctx.meta[preliminary].pending_delete || s_rule_ctx.meta[preliminary].committing || s_rule_ctx.meta[preliminary].executing || (!manual && r.cooldown_ms && r.last_trigger_ms && @@ -914,6 +968,7 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); int i = find_rule_index(id); bool valid = i >= 0 && !s_rule_ctx.recovery_error && s_rule_ctx.loaded && + !s_rule_ctx.meta[i].pending_delete && !s_rule_ctx.meta[i].committing && !s_rule_ctx.meta[i].executing && s_rule_ctx.rules[i].revision == r.revision && s_rule_ctx.rules[i].instance == r.instance; @@ -2070,6 +2125,10 @@ static esp_err_t load_rules(const char *file) { if (e == ESP_OK) { ts_ssh_binding_lock(); for (int i = 0; i < count && e == ESP_OK; ++i) { + ts_rule_saved_info_t info={0}; + e=ts_rule_store_set_info(rules[i].id,&info); + if(e!=ESP_OK)break; + if(info.source!=TS_RULE_SET_SD_PACK)continue; const char *reason = NULL; e = ts_rule_pack_dependencies(&rules[i], &reason, NULL); if(e!=ESP_OK)s_rule_ctx.load_error=reason; @@ -2104,8 +2163,11 @@ static esp_err_t load_rules(const char *file) { s_rule_ctx.count = count; s_rule_ctx.source = source; memset(s_rule_ctx.meta, 0, sizeof(s_rule_ctx.meta)); - for (int i = 0; i < count; ++i) + for (int i = 0; i < count; ++i) { s_rule_ctx.meta[i].readonly = readonly[i]; + ts_rule_saved_info_t info={0}; + s_rule_ctx.meta[i].source=source!=TS_RULE_SOURCE_READONLY&&ts_rule_store_set_info(rules[i].id,&info)==ESP_OK?(int)info.source:-1; + } s_rule_ctx.loaded = true; s_rule_ctx.load_error="ok"; s_rule_ctx.recovery_error = false; @@ -2159,17 +2221,20 @@ void ts_rule_config_status(bool *loaded, bool *recovery) { } bool ts_rule_restart_pending(void) { - if (!s_rule_ctx.initialized || !s_rule_ctx.loaded) return false; - xSemaphoreTakeRecursive(s_rule_ctx.transaction, portMAX_DELAY); - bool pending = false; - for (int i=0; i=0&&s_rule_ctx.meta[i].readonly); - xSemaphoreGive(s_rule_ctx.mutex); - cJSON_AddNumberToObject(object,"saved_revision",info.revision); - cJSON_AddNumberToObject(object,"saved_generation",info.generation); - cJSON_AddNumberToObject(object,"active_revision",active); - cJSON_AddBoolToObject(object,"runtime_active",i>=0); - cJSON_AddBoolToObject(object,"restart_required",active!=info.revision); - cJSON_AddBoolToObject(object,"readonly",readonly); + xSemaphoreTake(s_rule_ctx.mutex,portMAX_DELAY); + int i=find_rule_index(id);uint32_t active=i<0?0:s_rule_ctx.rules[i].revision; + bool confirmed=s_rule_ctx.loaded&&!s_rule_ctx.recovery_error; + rule_state_t state=classify_rule_state(id,i,confirmed,&info); + bool exists=state==RULE_SYNCED||state==RULE_PENDING_ADD||state==RULE_PENDING_UPDATE; + bool readonly=(exists&&info.readonly)||(i>=0&&s_rule_ctx.meta[i].readonly); + xSemaphoreGive(s_rule_ctx.mutex); + const char *change=state==RULE_PENDING_ADD?"add":state==RULE_PENDING_UPDATE?"update": + state==RULE_PENDING_DELETE?"delete":state==RULE_UNKNOWN?"unknown":"none"; + cJSON_AddStringToObject(object,"pending_change",change); + if(state==RULE_UNKNOWN)cJSON_AddNullToObject(object,"saved_exists"); + else cJSON_AddBoolToObject(object,"saved_exists",exists); + if(exists)cJSON_AddNumberToObject(object,"saved_revision",info.revision); + else cJSON_AddNullToObject(object,"saved_revision"); + if(confirmed)cJSON_AddNumberToObject(object,"saved_generation",ts_rule_store_set_generation()); + else cJSON_AddNullToObject(object,"saved_generation"); + cJSON_AddNumberToObject(object,"active_revision",active); + cJSON_AddBoolToObject(object,"runtime_active",i>=0); + cJSON_AddBoolToObject(object,"restart_required",state_pending(state)); + cJSON_AddBoolToObject(object,"readonly",readonly); + if(exists){ cJSON_AddStringToObject(object,"saved_source",info.source==TS_RULE_SET_SD_PACK?"encrypted_pack":info.source==TS_RULE_SET_SD_JSON?"sd_json":"nvs_json"); cJSON_AddStringToObject(object,"package_digest",info.digest); + }else{ + cJSON_AddNullToObject(object,"saved_source");cJSON_AddNullToObject(object,"package_digest"); } xSemaphoreGiveRecursive(s_rule_ctx.transaction); } @@ -2209,6 +2289,9 @@ cJSON *ts_rule_pending_list(void) { cJSON *array=cJSON_CreateArray(); if(!array || !s_rule_ctx.initialized) return array; xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); + if(!s_rule_ctx.loaded||s_rule_ctx.recovery_error){ + xSemaphoreGiveRecursive(s_rule_ctx.transaction);return array; + } for(int i=0;i=s_rule_ctx.capacity){*reason="capacity";ret=ESP_ERR_NO_MEM;goto done;} xSemaphoreTake(s_rule_ctx.mutex,portMAX_DELAY); int active=find_rule_index(rule.id); + rule_state_t state=classify_rule_state(rule.id,active,true,&old); + int occupied=rule_union_count_locked(); xSemaphoreGive(s_rule_ctx.mutex); + if(state==RULE_PENDING_DELETE){*reason="restart_pending";ret=ESP_ERR_INVALID_STATE;goto done;} + if(state==RULE_UNKNOWN||occupied<0){*reason="recovery_required";ret=ESP_ERR_INVALID_STATE;goto done;} + if(!exists&&active<0&&occupied>=s_rule_ctx.capacity){*reason="capacity";ret=ESP_ERR_NO_MEM;goto done;} + ret=ts_rule_pack_dependencies(&rule,reason,&warnings); + if(ret!=ESP_OK)goto done; if(active>=0&&protected_bindings(&s_rule_ctx.rules[active],&rule)){ *reason="service_busy";ret=ESP_ERR_INVALID_STATE;goto done; } @@ -2328,72 +2415,26 @@ esp_err_t ts_rule_import_pack(const char *bytes,size_t length,bool preview,bool static esp_err_t dependency_change_rule(const ts_auto_rule_t *rule,ts_rule_dependency_t kind, const char *id,const void *next) { - for(unsigned i=0;iaction_count;++i){ - const ts_auto_action_t *a=&rule->actions[i]; - ts_action_template_t *tpl=NULL; - if(a->template_id[0]){ - if(kind==TS_RULE_DEP_TEMPLATE&&(!id||!strcmp(id,a->template_id))){ - if(!next)return ESP_ERR_INVALID_STATE; - const ts_action_template_t *proposed=next; - if(rule->enabled&&!proposed->enabled)return ESP_ERR_INVALID_STATE; - ts_auto_rule_t check=*rule;check.actions=(ts_auto_action_t *)&proposed->action;check.action_count=1; - const char *reason=NULL; - esp_err_t e=ts_rule_pack_dependencies(&check,&reason,NULL); - if(e!=ESP_OK)return e; - } - tpl=heap_caps_calloc(1,sizeof(*tpl),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); - if(!tpl)return ESP_ERR_NO_MEM; - if(ts_action_template_get(a->template_id,tpl)!=ESP_OK){free(tpl);return ESP_ERR_INVALID_STATE;} - a=&tpl->action; - } - if(a->type==TS_AUTO_ACT_SSH_CMD_REF){ - if(kind==TS_RULE_DEP_COMMAND&&(!id||!strcmp(id,a->ssh_ref.cmd_id))){ - if(!next){free(tpl);return ESP_ERR_INVALID_STATE;} - const ts_ssh_command_config_t *cmd=next;ts_action_ssh_host_t host; - if(ts_action_get_ssh_host(cmd->host_id,&host)!=ESP_OK || - !host.host[0]||!host.username[0]||!host.port || - (rule->enabled&&!cmd->enabled)){free(tpl);return ESP_ERR_INVALID_STATE;} - } - if(kind==TS_RULE_DEP_HOST||kind==TS_RULE_DEP_ACTION_HOST){ - ts_ssh_command_config_t *cmd=heap_caps_calloc(1,sizeof(*cmd),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); - if(!cmd){free(tpl);return ESP_ERR_NO_MEM;} - esp_err_t got=ts_ssh_commands_config_get(a->ssh_ref.cmd_id,cmd); - bool matches=got==ESP_OK&&(!id||!strcmp(cmd->host_id,id)); - if(got!=ESP_OK){free(cmd);free(tpl);return got;} - if(matches){ - ts_action_ssh_host_t actual;bool internal=false; - got=ts_action_get_ssh_host_ex(cmd->host_id,&actual,&internal); - if(got!=ESP_OK){free(cmd);free(tpl);return got;} - if(kind==TS_RULE_DEP_HOST&&!internal){ - const ts_ssh_host_config_t *proposed=next; - if(!proposed||!proposed->host[0]||!proposed->username[0]||!proposed->port){free(cmd);free(tpl);return ESP_ERR_INVALID_STATE;} - }else if(kind==TS_RULE_DEP_ACTION_HOST){ - const ts_action_ssh_host_t *proposed=next; - ts_ssh_host_config_t fallback; - if((proposed&&(!proposed->host[0]||!proposed->username[0]||!proposed->port)) || - (!proposed&&ts_ssh_hosts_config_get(cmd->host_id,&fallback)!=ESP_OK)){ - free(cmd);free(tpl);return ESP_ERR_INVALID_STATE; - } - } - } - free(cmd); - } - } - free(tpl); - } - return ESP_OK; + const char *reason=NULL; + return ts_rule_pack_dependency_change(rule,kind,id,next,&reason); } esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next) { - if(!s_rule_ctx.initialized||!s_rule_ctx.loaded)return ESP_OK; + /* Bootstrap loaders must be able to read their own persisted dependencies. + * Retained running versions after a failed reload still keep protection. */ + if(!s_rule_ctx.initialized||(!s_rule_ctx.loaded&&!s_rule_ctx.count))return ESP_OK; xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); esp_err_t ret=ESP_OK; if(s_rule_ctx.recovery_error)ret=ESP_ERR_INVALID_STATE; for(int i=0;iconditions.count!=0; - for (unsigned i = 0; i < rule->action_count; ++i) { - const ts_auto_action_t *a = &rule->actions[i]; - dynamic_warning |= a->condition.has_condition; - ts_action_template_t *tpl = NULL; - if (a->template_id[0]) { - if (!ts_action_templates_ready()) { *reason = "dependency_loading"; return ESP_ERR_INVALID_STATE; } - tpl = heap_caps_calloc(1, sizeof(*tpl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - if (!tpl) { *reason = "no_memory"; return ESP_ERR_NO_MEM; } - esp_err_t got = ts_action_template_get(a->template_id, tpl); - if (got != ESP_OK) { free(tpl); *reason = "dependency_missing"; return got; } - if (rule->enabled && !tpl->enabled) { free(tpl); *reason = "dependency_disabled"; return ESP_ERR_INVALID_STATE; } - disabled_warning |= !tpl->enabled; - a = &tpl->action; +/* One proposed object overlays the read-only configuration view. The actual + * executor/snapshot path is never called by static validation. */ +typedef struct { + ts_rule_dependency_t kind; + const char *id; + const void *next; +} dependency_view_t; +static bool view_targets(const dependency_view_t *view, ts_rule_dependency_t kind, const char *id) { + return view && view->kind==kind && (!view->id || !strcmp(view->id,id)); +} +static esp_err_t dependency_error(esp_err_t error, const char **reason) { + *reason=error==ESP_ERR_NO_MEM?"no_memory":error==ESP_ERR_NOT_FOUND||error==ESP_ERR_INVALID_ARG? + "dependency_missing":"dependency_unavailable"; + return error; +} +static esp_err_t dependency_ready(esp_err_t state,const char **reason) { + if(state==ESP_OK)return ESP_OK; + *reason=state==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; + return ESP_ERR_INVALID_STATE; +} +static esp_err_t read_command(const char *id,const dependency_view_t *view, + ts_ssh_command_config_t *out,const char **reason) { + esp_err_t ret=dependency_ready(ts_ssh_commands_config_load_state(),reason); + if(ret!=ESP_OK)return ret; + if(view_targets(view,TS_RULE_DEP_COMMAND,id)) { + if(!view->next)return dependency_error(ESP_ERR_NOT_FOUND,reason); + *out=*(const ts_ssh_command_config_t *)view->next; + return ESP_OK; + } + ret=ts_ssh_commands_config_get(id,out); + return ret==ESP_OK?ret:dependency_error(ret,reason); +} +static esp_err_t read_config_host(const char *id,const dependency_view_t *view, + ts_action_ssh_host_t *out,const char **reason) { + esp_err_t ret=dependency_ready(ts_ssh_hosts_config_load_state(),reason); + if(ret!=ESP_OK)return ret; + ts_ssh_host_config_t *config=heap_caps_calloc(1,sizeof(*config),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!config)return dependency_error(ESP_ERR_NO_MEM,reason); + if(view_targets(view,TS_RULE_DEP_HOST,id)) { + if(view->next)*config=*(const ts_ssh_host_config_t *)view->next; + else ret=ESP_ERR_NOT_FOUND; + }else ret=ts_ssh_hosts_config_get(id,config); + if(ret==ESP_OK){ + /* Static checks use only fields the selected runtime host requires. */ + memset(out,0,sizeof(*out)); + memcpy(out->host,config->host,sizeof(out->host)); + memcpy(out->username,config->username,sizeof(out->username));out->port=config->port; + } + free(config);return ret==ESP_OK?ret:dependency_error(ret,reason); +} +static esp_err_t read_host(const char *id,const dependency_view_t *view, + ts_action_ssh_host_t *out,const char **reason) { + if(view_targets(view,TS_RULE_DEP_ACTION_HOST,id)) { + if(view->next){*out=*(const ts_action_ssh_host_t *)view->next;return ESP_OK;} + /* The removed internal host cannot satisfy its own fallback lookup. */ + return read_config_host(id,NULL,out,reason); + } + bool internal=false; + esp_err_t ret=ts_action_get_ssh_host_ex(id,out,&internal); + if(internal)return ret==ESP_OK?ret:dependency_error(ret,reason); + if(view_targets(view,TS_RULE_DEP_HOST,id))return read_config_host(id,view,out,reason); + esp_err_t ready=dependency_ready(ts_ssh_hosts_config_load_state(),reason); + if(ready!=ESP_OK)return ready; + return ret==ESP_OK?ret:dependency_error(ret,reason); +} +static esp_err_t validate_dependencies(const ts_auto_rule_t *rule,const dependency_view_t *view, + const char **reason,cJSON **warnings) { + *reason="invalid_configuration";if(warnings)*warnings=NULL; + bool disabled_warning=false,dynamic_warning=rule->conditions.count!=0; + for(unsigned i=0;iaction_count;++i){ + const ts_auto_action_t *source=&rule->actions[i],*a=source; + dynamic_warning|=source->condition.has_condition; + bool template_changed=source->template_id[0]&&view_targets(view,TS_RULE_DEP_TEMPLATE,source->template_id); + if(view&&view->kind==TS_RULE_DEP_TEMPLATE&&!template_changed)continue; + ts_action_template_t *owned=NULL;const ts_action_template_t *tpl=NULL; + ts_ssh_command_config_t *cmd=NULL;esp_err_t ret=ESP_OK; + if(source->template_id[0]){ + if(template_changed){ + tpl=view->next; + if(!tpl)return dependency_error(ESP_ERR_NOT_FOUND,reason); + }else{ + ret=dependency_ready(ts_action_templates_load_state(),reason);if(ret!=ESP_OK)return ret; + owned=heap_caps_calloc(1,sizeof(*owned),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!owned)return dependency_error(ESP_ERR_NO_MEM,reason); + ret=ts_action_template_get(source->template_id,owned); + if(ret!=ESP_OK){free(owned);return dependency_error(ret,reason);}tpl=owned; + } + a=&tpl->action; } - if (a->type == TS_AUTO_ACT_SSH_CMD_REF) { - esp_err_t commands=ts_ssh_commands_config_load_state(); - if (commands!=ESP_OK) { - free(tpl); *reason=commands==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; - return ESP_ERR_INVALID_STATE; + /* Establish the effective reference before testing its proposed state. + * Known-unaffected references do not freeze unrelated configuration. */ + if(view&&!template_changed){ + if(view->kind==TS_RULE_DEP_COMMAND){ + if(a->type!=TS_AUTO_ACT_SSH_CMD_REF||!view_targets(view,TS_RULE_DEP_COMMAND,a->ssh_ref.cmd_id)){ + free(owned);continue; + } + }else if(view->kind==TS_RULE_DEP_HOST||view->kind==TS_RULE_DEP_ACTION_HOST){ + const char *host_id=NULL; + if(a->type==TS_AUTO_ACT_SSH_CMD)host_id=a->ssh.host_ref; + else if(a->type==TS_AUTO_ACT_SSH_CMD_REF){ + cmd=heap_caps_calloc(1,sizeof(*cmd),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!cmd){free(owned);return dependency_error(ESP_ERR_NO_MEM,reason);} + ret=read_command(a->ssh_ref.cmd_id,NULL,cmd,reason); + if(ret!=ESP_OK){free(cmd);free(owned);return ret;}host_id=cmd->host_id; + }else {free(owned);continue;} + if(view->id&&strcmp(view->id,host_id)){free(cmd);free(owned);continue;} + ts_action_ssh_host_t actual;bool internal=false; + ret=ts_action_get_ssh_host_ex(host_id,&actual,&internal); + if(view->kind==TS_RULE_DEP_HOST&&ret==ESP_OK&&internal){free(cmd);free(owned);continue;} + if(view->kind==TS_RULE_DEP_ACTION_HOST&&!view->next&&ret==ESP_OK&&!internal){free(cmd);free(owned);continue;} } - ts_ssh_command_config_t *cmd = heap_caps_calloc(1, sizeof(*cmd), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - ts_action_ssh_host_t host; - if (!cmd) { free(tpl); *reason = "no_memory"; return ESP_ERR_NO_MEM; } - esp_err_t got = ts_ssh_commands_config_get(a->ssh_ref.cmd_id, cmd); - bool internal=false; - if (got == ESP_OK) got = ts_action_get_ssh_host_ex(cmd->host_id, &host,&internal); - esp_err_t hosts=ts_ssh_hosts_config_load_state(); - if (!internal && hosts!=ESP_OK) { - free(cmd);free(tpl);*reason=hosts==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; - return ESP_ERR_INVALID_STATE; + } + if(tpl&&rule->enabled&&!tpl->enabled){*reason="dependency_disabled";ret=ESP_ERR_INVALID_STATE;goto action_done;} + if(tpl)disabled_warning|=!tpl->enabled; + if(a->type==TS_AUTO_ACT_WEBHOOK){*reason="action_unsupported";ret=ESP_ERR_NOT_SUPPORTED;goto action_done;} + if(a->type==TS_AUTO_ACT_SSH_CMD||a->type==TS_AUTO_ACT_SSH_CMD_REF){ + const char *host_id=a->ssh.host_ref; + if(a->type==TS_AUTO_ACT_SSH_CMD_REF){ + if(!cmd){ + cmd=heap_caps_calloc(1,sizeof(*cmd),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!cmd){ret=dependency_error(ESP_ERR_NO_MEM,reason);goto action_done;} + ret=read_command(a->ssh_ref.cmd_id,view,cmd,reason); + if(ret!=ESP_OK)goto action_done; + } + if(rule->enabled&&!cmd->enabled){*reason="dependency_disabled";ret=ESP_ERR_INVALID_STATE;goto action_done;} + disabled_warning|=!cmd->enabled;host_id=cmd->host_id; } - if (got == ESP_OK && (!host.host[0] || !host.username[0] || !host.port)) got=ESP_ERR_INVALID_ARG; - bool disabled = got == ESP_OK && !cmd->enabled; - disabled_warning |= disabled; - free(cmd); free(tpl); - if (got != ESP_OK) { *reason = "dependency_missing"; return got; } - if (rule->enabled && disabled) { *reason = "dependency_disabled"; return ESP_ERR_INVALID_STATE; } - } else free(tpl); + ts_action_ssh_host_t host; + ret=read_host(host_id,view,&host,reason); + if(ret==ESP_OK&&(!host.host[0]||!host.username[0]||!host.port))ret=dependency_error(ESP_ERR_INVALID_ARG,reason); + } +action_done: + free(cmd);free(owned);if(ret!=ESP_OK)return ret; } - /* Dynamic variables are not a static existence proof. Their values remain runtime inputs. */ - if (warnings && (dynamic_warning || disabled_warning)) { - *warnings = cJSON_CreateArray(); - if (!*warnings || (dynamic_warning && !cJSON_AddItemToArray(*warnings,cJSON_CreateString("dynamic_inputs"))) || - (disabled_warning && !cJSON_AddItemToArray(*warnings,cJSON_CreateString("dependency_disabled")))) { - cJSON_Delete(*warnings); *warnings = NULL; *reason = "no_memory"; return ESP_ERR_NO_MEM; + if(warnings&&(dynamic_warning||disabled_warning)){ + *warnings=cJSON_CreateArray(); + if(!*warnings||(dynamic_warning&&!cJSON_AddItemToArray(*warnings,cJSON_CreateString("dynamic_inputs")))|| + (disabled_warning&&!cJSON_AddItemToArray(*warnings,cJSON_CreateString("dependency_disabled")))){ + cJSON_Delete(*warnings);*warnings=NULL;return dependency_error(ESP_ERR_NO_MEM,reason); } } - *reason = "ok"; - return ESP_OK; + *reason="ok";return ESP_OK; +} +esp_err_t ts_rule_pack_dependencies(const ts_auto_rule_t *rule,const char **reason,cJSON **warnings) { + return validate_dependencies(rule,NULL,reason,warnings); +} +esp_err_t ts_rule_pack_dependency_change(const ts_auto_rule_t *rule,ts_rule_dependency_t kind, + const char *id,const void *next,const char **reason) { + dependency_view_t view={kind,id,next}; + return validate_dependencies(rule,&view,reason,NULL); } diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index c7884ea..eed4f70 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -3889,13 +3889,15 @@ async function refreshQuickActions() {
${escapeHtml(cleanName)}
-
${(!nohupInfo?.serviceMode || !rule.enabled || !rule.manual_trigger) ? runtimeText(!rule.enabled ? 'disabled' : rule.manual_trigger ? 'manual' : 'automatic') : ''}${(rule.reference_unresolved || nohupInfo?.unresolved) ? ' · ' + runtimeText('referenceUnresolved') : ''}${statusHtml && (!rule.enabled || !rule.manual_trigger) ? ' · ' : ''}${statusHtml}${nohupBtns}
+
${rule.pending_change === 'delete' ? t('rulePack.pendingDelete') + ' · ' : ''}${(!nohupInfo?.serviceMode || !rule.enabled || !rule.manual_trigger) ? runtimeText(!rule.enabled ? 'disabled' : rule.manual_trigger ? 'manual' : 'automatic') : ''}${(rule.reference_unresolved || nohupInfo?.unresolved) ? ' · ' + runtimeText('referenceUnresolved') : ''}${statusHtml && (!rule.enabled || !rule.manual_trigger) ? ' · ' : ''}${statusHtml}${nohupBtns}
`); } @@ -4124,7 +4126,7 @@ async function triggerQuickAction(ruleId) { } catch (_) { card.dataset.state = 'unknown'; } } if (card.dataset.allowed !== 'true' || (card.dataset.service && card.dataset.state !== 'stopped')) { - showToast(runtimeText('startBlocked'), 'warning'); return; + showToast(card.dataset.pendingChange === 'delete' ? t('rulePack.pendingDelete') : runtimeText('startBlocked'), 'warning'); return; } if (card.dataset.service) card.dataset.state = 'starting'; @@ -15290,16 +15292,17 @@ async function refreshRules() { const label = r.enabled ? t('common.disabled') : t('common.enabled'); const locked = r.restart_required || r.readonly; const active = r.runtime_active !== false; + const deleting = r.pending_change === 'delete'; const handler = (name, ...args) => escapeHtml(name + '(' + args.map(x => JSON.stringify(x)).join(',') + ')'); return `
${escapeHtml(r.id)}
-
${escapeHtml(r.name || r.id)}${r.restart_required ? ` ${t('rulePack.pending')}` : ''}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
+
${escapeHtml(r.name || r.id)}${r.restart_required ? ` ${t(deleting ? 'rulePack.pendingDelete' : 'rulePack.pending')}` : ''}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
${r.conditions_count || 0}
${r.actions_count || 0}
${r.trigger_count || 0}
-
${icoBtn('ri-play-line', t(r.restart_required ? 'rulePack.runCurrent' : 'automation.manualTrigger'), handler('triggerRule', r.id), '', !active)}${icoBtn('ri-edit-line', t('common.edit'), handler('editRule', r.id), '', locked)}${icoBtn('ri-download-line', t('rulePack.exportCurrent'), handler('showExportRuleModal', r.id), '', !active)}${icoBtn('ri-delete-bin-line', t('common.delete'), handler('deleteRule', r.id), 'dg', locked)}
+
${icoBtn('ri-play-line', t(deleting ? 'rulePack.pendingDelete' : r.restart_required ? 'rulePack.runCurrent' : 'automation.manualTrigger'), handler('triggerRule', r.id), '', !active || deleting)}${icoBtn('ri-edit-line', t('common.edit'), handler('editRule', r.id), '', locked)}${icoBtn('ri-download-line', t('rulePack.exportCurrent'), handler('showExportRuleModal', r.id), '', !active)}${icoBtn('ri-delete-bin-line', t('common.delete'), handler('deleteRule', r.id), 'dg', locked)}
`; }).join('')} `; @@ -19113,7 +19116,7 @@ async function confirmRuleImport() { try { const state = await api.call('automation.rules.list'); if (attempt !== rulePackPreviewAttempt) return; - const saved = state.code === 0 && state.data?.loaded !== false && state.data?.recovery_required !== true && state.data?.rules?.find(r => r.id === preview.id && r.package_digest === preview.package_digest); + const saved = state.code === 0 && state.data?.loaded !== false && state.data?.recovery_required !== true && state.data?.rules?.find(r => r.id === preview.id && r.saved_exists !== false && r.pending_change !== 'delete' && r.package_digest === preview.package_digest); if (saved && Number.isInteger(saved.saved_revision) && saved.saved_generation >= preview.expected_generation) { box.className = 'result-box success'; box.textContent = t(saved.restart_required ? 'rulePack.saved' : 'rulePack.alreadyActive'); await refreshRules(); diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index fae56e6..d658a90 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -3,6 +3,7 @@ */ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { rulePack: { + pendingDelete: "Deletion saved; removal requires restart", content: "View rule content", overwriteLabel: "Overwrite impact", overwriteImpact: "Replaces saved revision {revision}; the running version continues until restart.", @@ -20,6 +21,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { "summary": "{conditions} conditions, {actions} actions", "verifyFailed": "Verification did not complete. Check the connection and select the file again.", "errors": { + "action_unsupported": "This firmware does not support an action used by the package.", "config_loading": "Rule configuration is still loading. Check again shortly.", "invalid_pack": "The package format or content is invalid. Export it again.", "invalid_rule_pack": "This is not a valid rule configuration package.", diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index c7c9adb..b93de19 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -3,6 +3,7 @@ */ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { rulePack: { + pendingDelete: "已保存删除,待重启移除", content: "查看规则内容", overwriteLabel: "覆盖影响", overwriteImpact: "替换已保存的第 {revision} 版;重启前仍运行当前版本。", @@ -20,6 +21,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { "summary": "{conditions} 项条件,{actions} 项动作", "verifyFailed": "未能完成验证,请检查连接后重新选择文件。", "errors": { + "action_unsupported": "配置包使用了当前固件尚不支持的动作。", "config_loading": "规则配置仍在加载,请稍后刷新。", "invalid_pack": "配置包格式或内容无效,请重新导出。", "invalid_rule_pack": "此文件不是有效的规则配置包。", diff --git a/tests/prompts/rule-pack.browser.test.cjs b/tests/prompts/rule-pack.browser.test.cjs index f84be2e..35dac3d 100644 --- a/tests/prompts/rule-pack.browser.test.cjs +++ b/tests/prompts/rule-pack.browser.test.cjs @@ -32,6 +32,7 @@ async function pageFor(language,mode,run){ if(endpoint==='automation/rules/import'){ if(params.preview){ const data={...preview,exists:mode==='old',expected_revision:mode==='old'?4:0}; + if(params.tscfg==='unsupported'){res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({code:1,message:'action_unsupported'}));return;} if(params.tscfg==='untrusted')data.trusted=false; if(params.tscfg==='slow'||params.tscfg==='fast')data.rule={...preview.rule,name:params.tscfg}; reply={code:0,data}; @@ -48,7 +49,7 @@ async function pageFor(language,mode,run){ if(mode==='conflict')reply={code:1,message:'revision_conflict'}; else{ saved=true; - if(mode==='lost'||mode==='unknown'){ + if(mode==='lost'||mode==='unknown'||mode==='deleted'){ res.writeHead(200,{'Content-Type':'application/json'});res.flushHeaders(); res.write('{"code":0,');setImmediate(()=>res.destroy());return; } @@ -59,6 +60,12 @@ async function pageFor(language,mode,run){ if(endpoint==='automation/rules/list')reply={code:0,data:{loaded:true,recovery_required:mode==='unknown',rules:saved?[{id,name:'Night test',enabled:false, readonly:true,restart_required:true,runtime_active:mode==='old',saved_revision:mode==='old'?5:1,active_revision:mode==='old'?4:0,saved_generation:8, package_digest:hash,conditions_count:1,actions_count:1}]:[]}}; + if(endpoint==='ssh/hosts/list')reply={code:0,data:{hosts:[]}}; + if(endpoint==='ssh/commands/list')reply={code:0,data:{commands:[]}}; + if(endpoint==='automation/rules/list'&&mode==='deleted')reply={code:0,data:{loaded:true,recovery_required:false,rules:[{ + id,name:'Old rule',enabled:true,readonly:false,allow_manual_trigger:true,manual_trigger:true,show_on_dashboard:true, + pending_change:'delete',saved_exists:false,saved_revision:null,saved_generation:8,saved_source:null,package_digest:null, + restart_required:true,runtime_active:true,active_revision:4,revision:4,conditions_count:0,actions_count:1,actions:[{type:'log',message:'old'}]}]}}; res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify(reply)); });return; } @@ -160,6 +167,31 @@ for(const language of ['zh-CN','en-US']){ assert(await page.locator('#import-rule-btn').isDisabled());assert.equal(writes(),0); }); }); + test(language+': confirmed deletion row and quick action cannot trigger or confirm an uncertain pack save',async()=>{ + await pageFor(language,'deleted',async({page,calls,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.unknown')); + assert.equal(writes(),1); + await page.evaluate(async()=>{await refreshRules();document.body.insertAdjacentHTML('beforeend','
');await refreshQuickActions();}); + assert.equal(await page.locator('#rules-list .tr:not(.th)').count(),1); + assert.match(await page.locator('#rules-list').innerText(),language==='zh-CN'?/已保存删除,待重启移除/:/Deletion saved; removal requires restart/); + const buttons=page.locator('#rules-list .tr:not(.th) button'); + for(const [i,disabled] of [[0,true],[1,true],[2,true],[3,false],[4,true]])assert.equal(await buttons.nth(i).isDisabled(),disabled); + const card=page.locator('#quick-actions-grid .quick-action-card');assert.equal(await card.count(),1); + assert.equal(await card.getAttribute('data-allowed'),'false');assert.equal(await card.getAttribute('aria-disabled'),'true'); + assert.match(await card.innerText(),language==='zh-CN'?/已保存删除/:/Deletion saved/); + await page.evaluate(id=>triggerQuickAction(id),id); + assert.equal(calls.filter(c=>c.endpoint==='automation/rules/trigger').length,0); + }); + }); + test(language+': unsupported action preview has a translated error and cannot save',async()=>{ + await pageFor(language,'ok',async({page,writes})=>{ + await page.locator('#import-rule-file').setInputFiles({name:'unsupported.tscfg',buffer:Buffer.from('unsupported')}); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.errors.action_unsupported')); + assert(await page.locator('#import-rule-btn').isDisabled());assert.equal(writes(),0); + assert.doesNotMatch(await page.locator('#import-rule-result').innerText(),/rulePack\.errors/); + }); + }); test(language+': datasource switch works without a same-name rule',async()=>{ await pageFor(language,'ok',async({page,calls})=>{ await page.evaluate(async()=>{hideImportRuleModal();document.body.insertAdjacentHTML('beforeend','
');await refreshSources();}); diff --git a/tests/runtime/extract_engine.py b/tests/runtime/extract_engine.py index 861f003..b7b8d9b 100644 --- a/tests/runtime/extract_engine.py +++ b/tests/runtime/extract_engine.py @@ -13,11 +13,12 @@ int ts_rule_store_set_count(void){return 0;} uint32_t ts_rule_store_set_generation(void){return 0;} esp_err_t ts_rule_store_set_info(const char *id,ts_rule_saved_info_t *out){return ESP_ERR_NOT_FOUND;} +esp_err_t ts_rule_store_set_info_at(int i,ts_rule_saved_info_t *out){return ESP_ERR_NOT_FOUND;} esp_err_t ts_rule_store_set_commit(const ts_auto_rule_t*r,const char*id,uint32_t v,uint32_t g,const char*p,size_t n,const ts_config_pack_acceptance_t*a,ts_rule_commit_t*out){assert(!"unexpected saved-set commit in legacy engine test");return ESP_FAIL;} #endif """ parts=[header,stubs,s[a:b]] -for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','pending_id','rule_commit_impl','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','classify_rule_state','state_pending','pending_id','rule_union_count_locked','rule_commit_impl','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name end=s.index('\n}',m.start())+2;parts.append(s[m.start():end]) Path('/tmp/tianshan-runtime-tests/engine.inc').write_text('\n'.join(parts)) diff --git a/tests/runtime/rule_pack/R1-R3-review.md b/tests/runtime/rule_pack/R1-R3-review.md new file mode 100644 index 0000000..a0f00a1 --- /dev/null +++ b/tests/runtime/rule_pack/R1-R3-review.md @@ -0,0 +1,125 @@ +# R1–R3 本地修复候选及验证证据 + +基线:`af6fcf262c38b7fc466dfb3e2180791d94f2f173`。独立分支:`fix/rule-pack-import-standalone`。 +开始时本地 HEAD、个人仓库远端分支均为该基线,工作区干净;本次增量不包含他人修改。 +最终提交身份以包含此文件的提交 SHA 为准,可直接比较基线与该提交。没有合并主线或改写历史。 + +## 闭合表 + +| 项目 | 修复前的实际路径 | 共同语义与改动入口 | 定向证据 | 保留的行为 | +|---|---|---|---|---| +| R1 | 删除 selector 已提交、回读未知;恢复后只从保存集合查差异,遗漏运行集合中的 A,仍可新触发且少算容量 | `ts_rule_engine.c` 的统一状态分类覆盖 IDs(A)∪IDs(S);恢复完整读回后先发布 pending_delete,再清恢复状态;`execute_rule` 最终准入检查、普通 commit/包导入共用状态与并集容量;API/list/get 与双语列表、快捷卡片表达同一状态 | A1:未知→失败恢复→确认删除;只有一行,保存字段为 null;自动/手动拒绝,同 ID 普通写及包预览/正式导入拒绝;B 编辑不复活 A;重建启动 A 消失。A2:selector 写前失败及已知成功立即删除。A3:持有旧 lease/已准入动作不受破坏、最终准入屏障、小容量拒绝 C,重启后 C 可新增 | 无持久化 tombstone,无 GET 热删除;旧对象、租约、已准入快照保持;已知成功普通删除仍立即生效,待新增/待更新保持原行为 | +| R2 | 普通 API 启用 A 后,统一包级依赖检查因禁用模板拒绝整个普通集合;延迟入口还全局等待模板 | 按保存记录 source 只校验包;运行 meta 保留发布版本 source,保护扫描运行包与保存包;移除无条件模板等待,增加组件内有界加载结果;真实首次依赖初始化可进入,保留运行版本时不得破坏性重载 | B1:实际 enable→生产加载,A 启用/动作/source/revision 保留,B 可执行,A 因原有执行检查拒绝。B2:实际有上限延迟入口、无模板日志包、普通模板引用先加载后真实初始化 guard 放行。B3:混合集合包等待/失败时无部分发布,依赖就绪后完整加载;普通来源不被待重启包重标记;运行包阻止模板全量重载 | 普通 NVS/SD JSON 原有加载兼容;包来源仍严格,外部运行成功不由静态加载承诺;既有快照/服务保护保留 | +| R3 | 预检漏直接 SSH 和 Webhook;变更保护重复解析,忽略直接引用并仅检查回退 ID 存在 | `ts_rule_pack.c` 唯一只读有效动作/拟对象覆盖视图供预检和变更检查使用;模板一级优先;两条 SSH 统一实际 internal-first 主机解析;必要字段、加载/失败/缺失分开;getter 保留真实读取错误 | C1:真实包直接及模板 SSH 缺主机时预览/正式导入拒绝,存在时通过;直接路径不依赖命令就绪,模板 LOG 覆盖内联 SSH。C2:保存待重启与已运行两个时点,实际主机删除/坏更新拒绝;真实 getter 优先级、被遮蔽配置允许、内部删除后的有效/无效/未就绪/读取错误回退;真实内部注册/删除 guard 在检查失败时未调用写入;旧运行包与新保存包保护各自依赖。C3:直接/模板/禁用 Webhook 拒绝、保护模板不可改为 Webhook;普通旧 Webhook 仍加载;snapshot、pin、执行、连接、入队、变量写入计数不增加(C 组均为 0) | 没有调用 snapshot 做预检;没有连接或执行探测;没有实现 Webhook、增加 host enabled/auth 约束或改执行器 | + +## 先红后绿 + +以下在基线生产代码、仅增加最小测试入口时采集;Python 包装器均退出 1(测试断言失败,原生进程 SIGABRT),不是编译失败。之后才修改生产语义。 + +`red-r1.log`: + +```text +R1: saved_count=1 active_count=2 restart_pending=0 +Assertion failed: (ts_rule_restart_pending()), function main, file test_engine_pack.c, line 89. +``` + +`red-r2.log`: + +```text +R2: reload=259 loaded=0 count=2 template_enabled=0 +Assertion failed: (loaded==ESP_OK&&s_rule_ctx.loaded&&s_rule_ctx.count==2), function main, file test_engine_pack.c, line 94. +``` + +`red-r3_ssh.log`: + +```text +R3 r3_ssh: preview=0 reason=ok +Assertion failed: (got!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing")), function main, file test_engine_pack.c, line 100. +``` + +`red-r3_webhook.log`: + +```text +R3 r3_webhook: preview=0 reason=ok +Assertion failed: (got!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing")), function main, file test_engine_pack.c, line 100. +``` + +最小反例命令:`python3 tests/runtime/test_rule_pack_crypto.py --engine --case=`。 +当前修复已扩展同一夹具为 A1–A3/B1–B3/C1–C3。该当前夹具包含新增 helper 的提取清单;复现历史红证据需使用当时最小夹具,不能将最终清单机械套到缺少 helper 的旧代码上。 + +## 最终验证 + +环境:macOS arm64,Apple Clang 21.0.0,Python 3.14.2,Node v24.10.0。 +固定 SDK:ESP-IDF v5.5.2,`30aaf64524299d3bde422ca9a2848090d1bc5d0f`;使用 SDK 原生 Mbed TLS 3.6.5 和临时 OpenSSL PKI。 +本地测试 CA PEM SHA-256:`a9c2a6c7b202c4acfa34b58e44d257505e5833e3fe70fcd24ad5aa19df4f7371`;没有上传测试私钥。 + +| 实际命令 | 退出码 | 结果与边界 | +|---|---:|---| +| `IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf bash tests/runtime/run_all.sh` | 0 | 既有相关 runtime/codec/store/快照/动作提交/SSH/规则包与新增定向组全部通过;ASan/UBSan 原生夹具,硬件/RTOS/存储出口模拟 | +| `IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf bash tests/runtime/run_rule_pack.sh` | 0 | 最后补齐模板正式导入及无副作用计数后复跑受影响共享夹具:795 保存、620 迁移、436 恢复故障场景;原包加密/信任/跨设备/CAS/幂等与 A/B/C 均通过 | +| `node --test tests/prompts/rule-pack.browser.test.cjs` | 0 | 20/20;实际双语页面+本地模拟 API,覆盖待删除单行、触发禁用、未知保存回读与 action_unsupported;非真机浏览器验收 | +| `CERT_MBEDTLS_BUILD=/tmp/tianshan-pack-mbedtls CERT_TEST_PYTHON=/Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf bash tests/certificate/run_host.sh` | 0 | 既有证书 subject/material/time/API/HTTPS 生命周期等回归通过;不等同于部署 PKI 验收 | +| `source /Users/massif/esp/v5.5.2/esp-idf/export.sh && idf.py -B /tmp/tianshan-rule-pack-firmware-build -DSDKCONFIG=/tmp/tianshan-rule-pack-sdkconfig build` | 0 | ESP32-S3、16MB 隔离构建,app 与 www.bin 成功;原生产配置/设备未修改。构建签名根为空,尚不能作为部署后导入验收证据 | +| `git diff --check` | 0 | 增量无空白错误 | + +固件 `TianShanOS.bin` SHA-256:`7b2295d87fc60b1376cb3997c5089bc6f26c8c48bbd934daf6e00da77121c8ea`。 +WebUI `www.bin` SHA-256:`85344da490c52dd1de75ac41dcabd6e1e3ecee8f513dd9758ee11beab9c19d25`。 + +原始输出保存在本机 `/tmp/tianshan-rule-pack-r1-r3-evidence/`;下面摘要哈希对应采集文件,临时目录可能被系统清理。真实 PASS 摘录与失败断言已纳入本报告,不上传凭证。 + +- `red-r1.log` SHA-256 `cd766716c0cd5e13032539a0046737dd119b06f99061cfe18d39de1c0ed6fc5f` +- `red-r2.log` SHA-256 `865be96c6f59c69c1e2c81ffa40c00ee3fe245a9afe0572ced228053ef525f2c` +- `red-r3_ssh.log` SHA-256 `a509289e12ffb31e44c382698e53f805a68cdb2cd2af9a78bf31bff9726e7d36` +- `red-r3_webhook.log` SHA-256 `b33a34cd3b04eb1df5fe9489cf8c40d4dae5c47d5c39e828681300b3ed877720` +- `runtime.log` SHA-256 `71576e556a5488dd9c8dc91c35282865453212c1a57c0901268e028758ac235a` +- `rule-pack-final.log` SHA-256 `4e18f9adda283011930a21cdf6a7172af7ec032ec9c55cdf2514fe1f9848e250` +- `browser.log` SHA-256 `99110bec0b9fd51a18b54891144a774eb20224791480dea5972dcb1b2aa8e9ce` +- `certificate.log` SHA-256 `3df50d403d90be2292a1f031fb3384dd9a4af027b0b23ffd29453ed0caa10082` +- `build.log` SHA-256 `05d8a435f50ac365b22938f6be8c376d76e5878b4beea514f585948e3877c828` + +规则包最终真实输出: + +```text +PASS real production create/trust/signature/ECDH/HKDF/GCM, cross-device PEM, offline accepted reload, malformed inputs +PASS unconfigured signing root refuses a cryptographically valid package +PASS production codec -> real pack -> complete-set store -> offline boot: 795 save, 620 migration and 436 recovery fault cases; full sources/revisions/readonly, idempotence, unrelated edit, SD absence and same-generation JSON mirror +PASS production import + engine + crypto + store: saved/active isolation, held old lease, pending edit/reload/deinit guards, unrelated merge, CAS/credential conflicts, offline idempotence/boot, dependency union, capacity and missing SD recovery +PASS A1-A3: confirmed deletion, unknown/old selector, unchanged active lease/admitted action, final admission barrier, union capacity, sibling edit and reboot +PASS B1-B3: actual enable/execute, ordinary source compatibility, bounded deferred loading, first template load, mixed complete publication and published source identity +PASS C1-C2: direct SSH preview/formal, real host source priority, proposed view/fallback fields and readiness, effective template, active/saved source protection; no snapshot/pin/execute +PASS C3: direct/template/disabled Webhook refused, protected template unchanged, ordinary compatibility, no snapshot/pin/execute side effects +``` + +## 实际修改文件 + +- `components/ts_api/src/ts_api_automation.c` +- `components/ts_automation/include/ts_action_manager.h` +- `components/ts_automation/include/ts_rule_pack.h` +- `components/ts_automation/src/ts_action_manager.c` +- `components/ts_automation/src/ts_rule_engine.c` +- `components/ts_automation/src/ts_rule_pack.c` +- `components/ts_webui/web/js/app.js` +- `components/ts_webui/web/js/lang/en-US.js` +- `components/ts_webui/web/js/lang/zh-CN.js` +- `tests/prompts/rule-pack.browser.test.cjs` +- `tests/runtime/extract_engine.py` +- `tests/runtime/rule_pack/stubs/ts_action_manager.h` +- `tests/runtime/rule_pack/test_engine_pack.c` +- `tests/runtime/rule_pack/test_store_set.c` +- `tests/runtime/rule_pack_contract.txt` +- `tests/runtime/run_rule_pack.sh` +- `tests/runtime/test_action_pack.py` +- `tests/runtime/test_rule_pack_crypto.py` +- `tests/runtime/rule_pack/R1-R3-review.md` + +## 范围与未执行项 + +本轮只有 R1–R3 因果相关代码、既有夹具提取适配及双语 UI 联动,没有存储格式、selector、信任政策、执行器重写,没有实现 Webhook、操作设备、合并主线或覆盖其他 worktree。 +没有新增需要另行修复的范围外问题。为保证读取错误不被伪装成主机不存在,仅联动无副作用 host getter 的错误透传。 + +未执行:刷机、物理重启、真机导入/SSH/服务/浏览器验收;真实设备签名根、Developer OU/KU/EKU、可信时间、NVS/SD 可用空间核验。没有仍失败或环境阻塞的本轮本地检查。 + +发布前提沿用原契约:必须安装并明确配置真实签名根,确认 Developer 证书用途、可信时间及足够存储空间。 +**启动即可发生旧集合到 v3 的迁移,不以用户点击导入为前提。** 老版本不理解 `set_select`,不可承诺原地降级;需明确备份/恢复策略,不双写旧权威、不增加降级框架。 + +结论:本地修复候选及证据已完成,设备验收未完成。 diff --git a/tests/runtime/rule_pack/stubs/ts_action_manager.h b/tests/runtime/rule_pack/stubs/ts_action_manager.h index bbe2f38..baf541f 100644 --- a/tests/runtime/rule_pack/stubs/ts_action_manager.h +++ b/tests/runtime/rule_pack/stubs/ts_action_manager.h @@ -7,8 +7,14 @@ typedef struct { } ts_action_template_t; esp_err_t ts_action_template_get(const char *, ts_action_template_t *); bool ts_action_templates_ready(void); +esp_err_t ts_action_templates_load_state(void); typedef struct { char id[TS_AUTO_NAME_MAX_LEN],host[64];uint16_t port;char username[32],password[64],key_path[96];bool use_key_auth; } ts_action_ssh_host_t; esp_err_t ts_action_get_ssh_host(const char *,ts_action_ssh_host_t *); esp_err_t ts_action_get_ssh_host_ex(const char *,ts_action_ssh_host_t *,bool *); + +/* Side-effect boundaries counted by the integration fixture; preflight must not use them. */ +typedef struct {int status;} ts_action_result_t; +typedef void (*ts_action_callback_t)(const ts_auto_action_t *,const ts_action_result_t *,void *); +esp_err_t ts_action_queue(const ts_auto_action_t *,ts_action_callback_t,void *,uint8_t); diff --git a/tests/runtime/rule_pack/test_engine_pack.c b/tests/runtime/rule_pack/test_engine_pack.c index 0d5c155..ccf0e18 100644 --- a/tests/runtime/rule_pack/test_engine_pack.c +++ b/tests/runtime/rule_pack/test_engine_pack.c @@ -3,12 +3,16 @@ #define RULE_ENGINE_INTEGRATION #include "test_store_set.c" #include +#include +#include +#include "esp_log.h" #include "ts_rule_engine.h" #include "ts_rule_pack.h" #define pdTRUE 1 typedef struct {int code;char *message;cJSON *data;} ts_api_result_t; #define TS_API_OK 0 #define TS_API_ERR_INTERNAL 500 +#define TS_API_ERR_INVALID_ARG 400 static void ts_api_result_error(ts_api_result_t *r,int code,const char *why){r->code=code;r->message=strdup(why);} typedef pthread_mutex_t *SemaphoreHandle_t; #define portMAX_DELAY 0xffffffff @@ -18,6 +22,23 @@ static int xSemaphoreGive(SemaphoreHandle_t m){return !pthread_mutex_unlock(m);} #define xSemaphoreGiveRecursive xSemaphoreGive static pthread_mutex_t binding,lock,transaction; static bool sd_mounted=true; +static unsigned action_calls,snapshot_calls,service_pins,connection_calls,queue_calls,variable_writes; +struct ts_ssh_session_s; +esp_err_t ts_ssh_connect(struct ts_ssh_session_s *session){++connection_calls;return ESP_FAIL;} +esp_err_t ts_action_queue(const ts_auto_action_t *a,ts_action_callback_t cb,void *data,uint8_t priority){++queue_calls;return ESP_FAIL;} +esp_err_t ts_variable_set(const char *id,const ts_auto_value_t *value){++variable_writes;return ESP_FAIL;} +static ts_auto_action_t admitted_action; +static void (*before_admission)(void); +static int64_t esp_timer_get_time(void){return 1000000;} +static const char *esp_err_to_name(int e){return "fixture";} +bool ts_action_manager_accepting(void){return true;} +bool ts_ssh_service_start_admissible(const char *id){return true;} +void ts_ssh_service_set_owner(const char *id,uint32_t r,const char *owner){assert(!"unexpected service owner");} +void ts_ssh_service_unpin(const char *id,uint32_t r){assert(!"unexpected service pin");} +esp_err_t ts_ssh_service_pin(const ts_ssh_command_config_t *c,const char *host,uint16_t port,uint32_t *r){++service_pins;return ESP_ERR_INVALID_STATE;} +esp_err_t ts_variable_get(const char *id,ts_auto_value_t *out){out->type=TS_AUTO_VAL_BOOL;out->bool_val=true;return ESP_OK;} +static void record_execution(const char *id,ts_rule_exec_status_t st,ts_rule_trigger_source_t source,const char *why,uint8_t count,uint8_t failed){} +static esp_err_t execute_actions_with_stats(const ts_auto_action_t *a,int n,int *ok,int *failed){action_calls+=n;admitted_action=a[0];*ok=n;*failed=0;return ESP_OK;} void ts_ssh_binding_lock(void){assert(!pthread_mutex_lock(&binding));} void ts_ssh_binding_unlock(void){assert(!pthread_mutex_unlock(&binding));} bool ts_storage_sd_mounted(void){return sd_mounted;} @@ -34,11 +55,39 @@ static esp_err_t load_legacy_nvs(ts_auto_rule_t *r,int *n){assert(0);return ESP_ static esp_err_t load_directory(ts_auto_rule_t *r,int *n,bool *ro){assert(0);return ESP_FAIL;} #define RULES_SDCARD_DIR DIR_RULES static int find_rule_index(const char *id); +static struct {bool initialized,templates_ready;atomic_int templates_load_result;SemaphoreHandle_t ssh_hosts_mutex;ts_action_ssh_host_t ssh_hosts[1];int ssh_host_count;} manager; +static __typeof__(manager) *s_ctx=&manager; +static unsigned template_load_calls,delay_calls,host_mutations; +bool ts_ssh_service_host_protected(const char *id){return false;} +static esp_err_t register_ssh_host_impl(const ts_action_ssh_host_t *h){++host_mutations;internal_host=true;internal_valid=h->port!=0;return ESP_OK;} +static esp_err_t unregister_ssh_host_impl(const char *id){++host_mutations;internal_host=false;return ESP_OK;} +static esp_err_t templates_load_impl(void){++template_load_calls;templates_ready=true;template_state=ESP_OK;return ESP_OK;} +#define ts_action_get_ssh_host_ex host_get_impl +#define ts_action_snapshot snapshot_impl +#include "pack_snapshot.inc" +#undef ts_action_snapshot +#undef ts_action_get_ssh_host_ex +esp_err_t ts_action_get_ssh_host_ex(const char *id,ts_action_ssh_host_t *out,bool *internal){ + manager.ssh_hosts_mutex=&lock;manager.ssh_host_count=internal_host?1:0; + manager.ssh_hosts[0]=(ts_action_ssh_host_t){.id="host",.host="192.0.2.2",.username="internal",.port=internal_valid?2222:0}; + return host_get_impl(id,out,internal); +} +esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *out){return ts_action_get_ssh_host_ex(id,out,NULL);} +#define pdMS_TO_TICKS(ms) (ms) +static void (*delay_hook)(void); +static void vTaskDelay(unsigned ms){assert(++delay_calls<=3);assert(ms==3000||ms==1000);if(ms==1000&&delay_hook)delay_hook();} +static void vTaskDelete(void *task){} +esp_err_t ts_action_snapshot(const ts_auto_action_t *src,ts_auto_action_t *out){ + ++snapshot_calls;if(before_admission){void (*hook)(void)=before_admission;before_admission=NULL;hook();} + return snapshot_impl(src,out); +} #include "pack_engine.inc" +static int engine_capacity=4; +static ts_auto_condition_t ready_condition={.variable="ready",.value={.type=TS_AUTO_VAL_BOOL,.bool_val=true}}; static void boot(void){ for(int i=0;ivalueint,cJSON_GetObjectItem(pre,"expected_generation")->valueint, cJSON_GetObjectItem(pre,"credential_generation")->valueint,cJSON_GetObjectItem(pre,"package_digest")->valuestring,data,reason); } +static void seed_pair(void){ + reset_disk();ts_auto_rule_t a=ordinary("a","Old A"),b=ordinary("b","B");ts_rule_commit_t result; + a.allow_manual_trigger=b.allow_manual_trigger=true; + a.presentation_fields=b.presentation_fields=3; + a.conditions.conditions=&ready_condition;a.conditions.count=1; + assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); + assert(ts_rule_store_set_commit(&a,"a",0,0,NULL,0,NULL,&result)==ESP_OK); + assert(ts_rule_store_set_commit(&b,"b",0,1,NULL,0,NULL,&result)==ESP_OK);boot(); +} +static void confirm_delete(void){ + ts_rule_commit_result_t result;fail_selector_read=true; + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"commit_unknown")); + fail_selector_read=false;assert(ts_rule_refresh_saved()==ESP_OK); +} +static void r1_cases(const char *dir){ + ts_auto_rule_t a=ordinary("a","A edit"),b=ordinary("b","B edit"),c=ordinary("c","C");ts_rule_commit_result_t result; + char *raw=make_pack(dir,"a","Replacement",0);ts_auto_rule_t held;assert(ts_rule_acquire("a",&held)==ESP_OK); + assert(ts_rule_trigger("a")==ESP_OK);unsigned submitted=action_calls;ts_auto_action_t prior=admitted_action; + fail_selector_read=true; + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"commit_unknown")); + assert(ts_rule_refresh_saved()!=ESP_OK&&s_rule_ctx.recovery_error&&!s_rule_ctx.meta[0].pending_delete); + cJSON *unknown=cJSON_CreateObject();ts_rule_saved_status("a",unknown); + assert(!strcmp(cJSON_GetObjectItem(unknown,"pending_change")->valuestring,"unknown")&&cJSON_IsNull(cJSON_GetObjectItem(unknown,"saved_exists")));cJSON_Delete(unknown); + fail_selector_read=false;assert(ts_rule_refresh_saved()==ESP_OK&&ts_rule_refresh_saved()==ESP_OK); + assert(!s_rule_ctx.recovery_error&&ts_rule_restart_pending()&&s_rule_ctx.count==2&&ts_rule_store_set_count()==1); + assert(!strcmp(held.name,"Old A")&&!memcmp(&prior,&admitted_action,sizeof prior)); + ts_api_result_t view={0};assert(api_automation_rules_list(NULL,&view)==ESP_OK&&view.code==0); + cJSON *rows=cJSON_GetObjectItem(view.data,"rules"),*first=cJSON_GetArrayItem(rows,0); + assert(cJSON_GetArraySize(rows)==2&&!strcmp(cJSON_GetObjectItem(first,"pending_change")->valuestring,"delete")); + assert(cJSON_IsTrue(cJSON_GetObjectItem(first,"runtime_active"))&&cJSON_IsFalse(cJSON_GetObjectItem(first,"saved_exists"))); + assert(cJSON_IsNull(cJSON_GetObjectItem(first,"saved_revision"))&&cJSON_IsNull(cJSON_GetObjectItem(first,"saved_source"))&&cJSON_IsNull(cJSON_GetObjectItem(first,"package_digest"))); + assert(cJSON_GetObjectItem(first,"active_revision")->valueint==1);cJSON_Delete(view.data);free(view.message); + unsigned triggers=s_rule_ctx.rules[0].trigger_count;bool triggered=true; + assert(ts_rule_trigger("a")==ESP_ERR_INVALID_STATE);assert(execute_rule("a",false,&triggered)==ESP_OK&&!triggered); + assert(action_calls==submitted&&s_rule_ctx.rules[0].trigger_count==triggers); + assert(ts_rule_commit(&a,"a",0,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rule_commit(&a,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + cJSON *data=NULL;const char *reason;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"restart_pending")); + ts_config_pack_t *decoded=NULL;ts_config_pack_acceptance_t accepted;uint32_t credential; + assert(ts_config_pack_load_verified_mem(raw,strlen(raw),NULL,&decoded,&accepted,&credential)==TS_CONFIG_PACK_OK);ts_config_pack_free(decoded); + assert(ts_rule_import_pack(raw,strlen(raw),false,true,0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,"restart_pending")); + assert(ts_rule_commit(&c,"c",0,&result)!=ESP_OK&&!strcmp(result.error_code,"capacity")); + char *newraw=make_pack(dir,"c","C pack",0); + assert(ts_rule_import_pack(newraw,strlen(newraw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"capacity")); + assert(ts_config_pack_load_verified_mem(newraw,strlen(newraw),NULL,&decoded,&accepted,&credential)==TS_CONFIG_PACK_OK);ts_config_pack_free(decoded); + assert(ts_rule_import_pack(newraw,strlen(newraw),false,true,0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,"capacity")); + assert(ts_rule_commit(&b,"b",1,&result)==ESP_OK);ts_rule_saved_info_t info; + assert(ts_rule_store_set_info("a",&info)==ESP_ERR_NOT_FOUND&&ts_rule_store_set_info("b",&info)==ESP_OK&&info.revision==2&&info.source==TS_RULE_SET_NVS_JSON); + ts_rule_release(&held);boot();assert(s_rule_ctx.count==1&&!strcmp(s_rule_ctx.rules[0].id,"b")&&!ts_rule_restart_pending()); + assert(ts_rule_commit(&c,"c",0,&result)==ESP_OK);free(raw);free(newraw); + /* A2: known old selector and known successful deletion preserve old semantics. */ + seed_pair();fail_selector_write=true;assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!result.applied);fail_selector_write=false; + s_rule_ctx.recovery_error=true;assert(ts_rule_refresh_saved()==ESP_OK&&!ts_rule_restart_pending()&&!s_rule_ctx.meta[0].pending_delete); + assert(ts_rule_store_set_info("a",&info)==ESP_OK&&info.revision==1); + assert(ts_rule_commit(NULL,"a",1,&result)==ESP_OK&&s_rule_ctx.count==1&&!ts_rule_restart_pending()); + assert(ts_rule_acquire("a",&held)==ESP_ERR_NOT_FOUND&&ts_rule_commit(&c,"c",0,&result)==ESP_OK); + /* A3: deletion is published after snapshot preparation but before final admission. */ + seed_pair();submitted=action_calls;before_admission=confirm_delete; + triggered=false; + assert(execute_rule("a",false,&triggered)==ESP_OK&&!triggered&&action_calls==submitted); + assert(s_rule_ctx.meta[0].pending_delete&&s_rule_ctx.rules[0].trigger_count==0); + assert(ts_rule_trigger("b")==ESP_OK&&action_calls==submitted+1);boot(); + puts("PASS A1-A3: confirmed deletion, unknown/old selector, unchanged active lease/admitted action, final admission barrier, union capacity, sibling edit and reboot"); +} +static void empty_runtime(void){ + for(int i=0;icontent,pack->content_len,&decoded)==ESP_OK); + ts_rule_saved_info_t info={0};bool exists=ts_rule_store_set_info(decoded.id,&info)==ESP_OK;ts_rule_dispose(&decoded);ts_config_pack_free(pack); + assert(ts_rule_import_pack(raw,strlen(raw),false,true,exists?info.revision:0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,wanted));cJSON_Delete(data); +} +static void r3_cases(const char *dir,bool webhook){ + char *raw=make_pack(dir,"probe","Probe",webhook?4:3);cJSON *data=NULL,*pre=NULL;const char *reason=NULL; + unsigned snapshots=snapshot_calls,pins=service_pins,actions=action_calls; + assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing"));cJSON_Delete(data);data=NULL; + ts_config_pack_t *decoded=NULL;ts_config_pack_acceptance_t accepted;uint32_t credential; + assert(ts_config_pack_load_verified_mem(raw,strlen(raw),NULL,&decoded,&accepted,&credential)==TS_CONFIG_PACK_OK);ts_config_pack_free(decoded); + assert(ts_rule_import_pack(raw,strlen(raw),false,true,0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing"));cJSON_Delete(data);data=NULL; + ts_auto_rule_t check=ordinary("check","Check");ts_auto_action_t direct={.type=TS_AUTO_ACT_SSH_CMD};strcpy(direct.ssh.host_ref,"host");strcpy(direct.ssh.command,"true");check.actions=&direct; + if(webhook){ + direct.type=TS_AUTO_ACT_WEBHOOK;check.enabled=false;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"action_unsupported")); + direct.type=TS_AUTO_ACT_LOG;strcpy(direct.template_id,"template");template_action.type=TS_AUTO_ACT_WEBHOOK; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"action_unsupported")); + char *unsupported=make_pack(dir,"template-webhook","Unsupported template",1);reject_pack(unsupported,"action_unsupported");free(unsupported); + template_action.type=TS_AUTO_ACT_LOG;char *templated=make_pack(dir,"probe","Template package",1);pre=preview(templated); + assert(save(templated,pre,&reason,&data)==ESP_OK);cJSON_Delete(data);cJSON_Delete(pre);free(templated); + ts_action_template_t next={.enabled=true,.action={.type=TS_AUTO_ACT_WEBHOOK}}; + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&next)!=ESP_OK&&template_action.type==TS_AUTO_ACT_LOG); + boot();assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&next)!=ESP_OK); + /* Ordinary legacy Webhook remains load-compatible. */ + seed_pair();ts_rule_commit_result_t result;ts_auto_rule_t old=ordinary("a","Old Webhook");old.actions=&direct;direct.template_id[0]=0;direct.type=TS_AUTO_ACT_WEBHOOK; + assert(ts_rule_commit(&old,"a",1,&result)==ESP_OK);templates_ready=false;assert(ts_rules_load()==ESP_OK&&s_rule_ctx.rules[0].actions[0].type==TS_AUTO_ACT_WEBHOOK); + }else{ + host_exists=true;command_state=ESP_ERR_INVALID_STATE;pre=preview(raw);assert(save(raw,pre,&reason,&data)==ESP_OK);cJSON_Delete(pre);cJSON_Delete(data); + ts_ssh_host_config_t proposed;assert(ts_ssh_hosts_config_get("host",&proposed)==ESP_OK);proposed.port=0; + for(int stage=0;stage<2;++stage){ + if(stage)boot();assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)!=ESP_OK); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",&proposed)!=ESP_OK&&config_valid&&host_exists); + internal_host=true;ts_action_ssh_host_t selected;bool internal=false; + assert(ts_action_get_ssh_host_ex("host",&selected,&internal)==ESP_OK&&internal&&selected.port==2222); + host_state=ESP_ERR_INVALID_STATE;assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",&proposed)==ESP_OK); /* shadowed config */ + assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)!=ESP_OK);host_state=ESP_OK; + config_valid=false;assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)!=ESP_OK);config_valid=true; + assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)==ESP_OK); + config_read_error=ESP_FAIL;assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)!=ESP_OK);config_read_error=ESP_OK; + unsigned mutations=host_mutations;config_valid=false; + assert(ts_action_unregister_ssh_host("host")!=ESP_OK&&internal_host&&host_mutations==mutations);config_valid=true; + assert(ts_action_unregister_ssh_host("host")==ESP_OK&&!internal_host&&host_mutations==mutations+1); + selected.port=0;assert(ts_action_register_ssh_host(&selected)!=ESP_OK&&!internal_host&&host_mutations==mutations+1); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"unrelated",NULL)==ESP_OK); + } + /* Effective template wins over stale inline fields; template SSH uses same direct path. */ + strcpy(direct.template_id,"template");host_exists=false;template_action.type=TS_AUTO_ACT_LOG; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK); + template_action=direct;template_action.template_id[0]=0; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_missing")); + char *template_ssh=make_pack(dir,"probe","Template SSH",1);reject_pack(template_ssh,"dependency_missing");host_exists=true; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK); + template_enabled=false;reject_pack(template_ssh,"dependency_disabled");template_enabled=true; + pre=preview(template_ssh);data=NULL;assert(save(template_ssh,pre,&reason,&data)==ESP_OK);cJSON_Delete(pre);cJSON_Delete(data);free(template_ssh); + /* Retained package and pending package can protect different effective references. */ + template_action.type=TS_AUTO_ACT_LOG;command_state=ESP_OK;char *templated=make_pack(dir,"probe","Pending template",1);pre=preview(templated);data=NULL; + assert(save(templated,pre,&reason,&data)==ESP_OK);cJSON_Delete(pre);cJSON_Delete(data);free(templated); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)!=ESP_OK); /* old running direct SSH */ + ts_action_template_t next={.enabled=true,.action=direct};next.action.template_id[0]=0;strcpy(next.action.ssh.host_ref,"missing"); + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&next)!=ESP_OK&&template_action.type==TS_AUTO_ACT_LOG); + boot();assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)==ESP_OK); + } + assert(snapshot_calls==snapshots&&service_pins==pins&&action_calls==actions&&connection_calls==0&&queue_calls==0&&variable_writes==0);free(raw); + puts(webhook?"PASS C3: direct/template/disabled Webhook refused, protected template unchanged, ordinary compatibility, no snapshot/pin/execute side effects":"PASS C1-C2: direct SSH preview/formal, real host source priority, proposed view/fallback fields and readiness, effective template, active/saved source protection; no snapshot/pin/execute"); +} int main(int argc,char **argv){ - assert(argc==2);const char *dir=argv[1];pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE); + assert(argc==2||argc==3);const char *dir=argv[1];pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE); pthread_mutex_init(&binding,&attr);pthread_mutex_init(&transaction,&attr);pthread_mutex_init(&lock,NULL);pthread_mutexattr_destroy(&attr); ca=read_file(dir,"root.pem");device_key=read_file(dir,"recipient.key");device_cert=read_file(dir,"recipient.pem");assert(ts_config_pack_init()==ESP_OK); reset_disk();ts_auto_rule_t a=ordinary("a","Old A"),b=ordinary("b","B");ts_rule_commit_t stored; + a.allow_manual_trigger=b.allow_manual_trigger=true; + a.presentation_fields=b.presentation_fields=3; + if(argc==3&&!strcmp(argv[2],"r1")){engine_capacity=2;a.conditions.conditions=&ready_condition;a.conditions.count=1;} + if(argc==3&&!strcmp(argv[2],"r2")){ + static ts_auto_action_t templated={.type=TS_AUTO_ACT_LOG};strcpy(templated.template_id,"template");a.actions=&templated;a.enabled=false;template_enabled=false; + } assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); assert(ts_rule_store_set_commit(&a,"a",0,0,NULL,0,NULL,&stored)==ESP_OK); assert(ts_rule_store_set_commit(&b,"b",0,1,NULL,0,NULL,&stored)==ESP_OK);boot(); + if(argc==3){ + if(!strcmp(argv[2],"r1")){ + r1_cases(dir); + }else if(!strcmp(argv[2],"r2")){ + r2_cases(dir); + }else{ + bool webhook=!strcmp(argv[2],"r3_webhook");assert(webhook||!strcmp(argv[2],"r3_ssh"));r3_cases(dir,webhook); + } + assert(ts_rule_engine_deinit()==ESP_OK);ts_rule_store_set_reset();reset_disk();free(device_key);free(device_cert);free(ca);return 0; + } char *raw=make_pack(dir,"a","Imported A",true);cJSON *pre=preview(raw),*data=NULL;const char *reason; templates_ready=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_loading"));cJSON_Delete(data);data=NULL;templates_ready=true; template_enabled=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_disabled"));cJSON_Delete(data);data=NULL;template_enabled=true; diff --git a/tests/runtime/rule_pack/test_store_set.c b/tests/runtime/rule_pack/test_store_set.c index 9fe65e6..071b2c6 100644 --- a/tests/runtime/rule_pack/test_store_set.c +++ b/tests/runtime/rule_pack/test_store_set.c @@ -12,26 +12,33 @@ #include "ts_ssh_hosts_config.h" #include "nvs.h" static bool templates_ready=true, template_exists=true, template_enabled=true; +static bool config_valid=true,internal_valid=true; +static esp_err_t config_read_error=ESP_OK; static bool command_exists,command_enabled=true,host_exists,internal_host; static esp_err_t command_state=ESP_OK,host_state=ESP_OK; +static esp_err_t template_state=ESP_OK; static ts_auto_action_t template_action={.type=TS_AUTO_ACT_LOG}; esp_err_t ts_action_template_get(const char *id,ts_action_template_t *t){ if(!template_exists||strcmp(id,"template"))return ESP_ERR_NOT_FOUND; memset(t,0,sizeof *t);strcpy(t->id,id);t->enabled=template_enabled;t->action=template_action;return ESP_OK; } bool ts_action_templates_ready(void){return templates_ready;} +esp_err_t ts_action_templates_load_state(void){return templates_ready?ESP_OK:template_state==ESP_OK?ESP_ERR_INVALID_STATE:template_state;} bool ts_ssh_commands_config_is_initialized(void){return true;} bool ts_ssh_hosts_config_is_initialized(void){return true;} esp_err_t ts_ssh_commands_config_load_state(void){return command_state;} esp_err_t ts_ssh_hosts_config_load_state(void){return host_state;} esp_err_t ts_ssh_commands_config_get(const char *id,ts_ssh_command_config_t *c){if(!command_exists||strcmp(id,"command"))return ESP_ERR_NOT_FOUND;memset(c,0,sizeof *c);strcpy(c->id,id);strcpy(c->host_id,"host");c->enabled=command_enabled;return ESP_OK;} -esp_err_t ts_ssh_hosts_config_get(const char *id,ts_ssh_host_config_t *h){if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;h->enabled=false;return ESP_OK;} +esp_err_t ts_ssh_hosts_config_get(const char *id,ts_ssh_host_config_t *h){if(config_read_error!=ESP_OK)return config_read_error;if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=config_valid?22:0;h->enabled=false;return ESP_OK;} +#ifndef RULE_ENGINE_INTEGRATION esp_err_t ts_action_get_ssh_host_ex(const char *id,ts_action_ssh_host_t *h,bool *internal){*internal=internal_host;if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;return ESP_OK;} esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *h){bool internal;return ts_action_get_ssh_host_ex(id,h,&internal);} +#endif typedef struct {char key[24];void *data;size_t size;} value_t; static value_t values[160]; static unsigned operation, fault, mode; static bool fail_selector_read; +static bool fail_selector_write; static jmp_buf crash; static bool before(void){ ++operation; @@ -59,6 +66,7 @@ esp_err_t nvs_get_blob(nvs_handle_t h,const char *key,void *out,size_t *n){ return after()?ESP_FAIL:ESP_OK; } esp_err_t nvs_set_blob(nvs_handle_t h,const char *key,const void *bytes,size_t n){ + if(fail_selector_write&&!strcmp(key,"set_select"))return ESP_FAIL; if(before())return ESP_FAIL;value_t *v=value(key,true);if(!v)return ESP_ERR_NO_MEM; free(v->data);v->data=malloc(n);assert(v->data);memcpy(v->data,bytes,n);v->size=n; return after()?ESP_FAIL:ESP_OK; @@ -102,7 +110,7 @@ static int unlink_fault(const char *p){if(before()){errno=EIO;return -1;}int r=u #undef fclose #undef unlink static void reset_disk(void){ - fail_selector_read=false; + fail_selector_read=fail_selector_write=false; fault=mode=operation=0;ts_rule_store_set_reset(); for(unsigned i=0;i<160;++i){free(values[i].data);memset(&values[i],0,sizeof values[i]);} DIR *d=opendir(OBJECT_DIR);struct dirent *e; diff --git a/tests/runtime/rule_pack_contract.txt b/tests/runtime/rule_pack_contract.txt index 77df358..268f53e 100644 --- a/tests/runtime/rule_pack_contract.txt +++ b/tests/runtime/rule_pack_contract.txt @@ -40,3 +40,26 @@ and never exempt healthy-storage failures because recover returned an error. Run: IDF_PATH=/path/to/esp-idf bash tests/runtime/run_rule_pack.sh (also included by run_all.sh). The fixture builds the SDK's native Mbed TLS and generates disposable OpenSSL PKI; no installed device credential is read. Browser tests: node --test tests/prompts/rule-pack.browser.test.cjs with the existing Playwright dependency. + +R1-R3 review repair (base af6fcf262c38b7fc466dfb3e2180791d94f2f173): +R1: classify union IDs(A,S), including pending_delete and unknown; recovery publishes deletion admission +flags before reopening mutations. Confirmed pending deletion preserves existing runtime objects/leases and +admitted snapshots but denies new automatic/manual admission and same-ID mutations/imports. New IDs consume +union capacity; known successful ordinary deletion still removes runtime immediately. API pending_change, +saved_exists and null saved fields reflect deletion; no digest from an old running version confirms import. +R2: strict static dependencies apply only to package sources. Ordinary NVS/SD JSON keeps prior loading +compatibility. Published metadata stores its own source; a pending replacement does not relabel runtime. +Package guards cover active retained packages (excluding confirmed deletion) plus saved packages. Genuine +first dependency initialization is allowed; destructive reload of retained packages remains protected. +Deferred loading uses actual dependencies; templates distinguish loading from failure, without global waiting. +R3: one read-only effective-action/proposed-object resolver serves preflight and dependency changes. +One-level template priority, direct SSH and command-reference SSH follow actual internal-first host getter. +Fallback fields/readiness and newly shadowing internal hosts are validated before mutation. Direct SSH does +not depend on command readiness. Webhook remains unimplemented: new packages/protected template changes reject +it even for disabled packages; ordinary legacy rules retain loading behavior. No snapshots/pins/connections, +queue admission, execution or variable writes occur in this checker; dynamic execution is not prevalidated. +Directed A1-A3/B1-B3/C1-C3 tests extract actual production engine/API/snapshot/host-getter/initialization guards +and use real crypto, codec and complete-set store. Hardware/RTOS, local dependency storage and NVS are adapters; +there is no claim of physical reboot, real SSH/network execution, or validation of installed device credentials. +Browser tests use the real bilingual page with simulated API replies; runtime tests separately prove payloads. +No v3 storage layout, selector protocol, encrypted trust or executor semantics are changed by this repair. diff --git a/tests/runtime/run_rule_pack.sh b/tests/runtime/run_rule_pack.sh index bd2e222..db35e57 100755 --- a/tests/runtime/run_rule_pack.sh +++ b/tests/runtime/run_rule_pack.sh @@ -7,3 +7,7 @@ python3 tests/runtime/test_rule_pack_crypto.py python3 tests/runtime/test_rule_pack_crypto.py --unconfigured python3 tests/runtime/test_rule_pack_crypto.py --store python3 tests/runtime/test_rule_pack_crypto.py --engine +# Directed review counterexamples, using the same production fixture and PKI. +for scenario in r1 r2 r3_ssh r3_webhook; do + python3 tests/runtime/test_rule_pack_crypto.py --engine --case="$scenario" +done diff --git a/tests/runtime/test_action_pack.py b/tests/runtime/test_action_pack.py index 722c6ca..405a905 100644 --- a/tests/runtime/test_action_pack.py +++ b/tests/runtime/test_action_pack.py @@ -9,7 +9,7 @@ def extract(path,name): source=Path(path).read_text();m=re.search(r'^(?:static )?(?:\w+[ \t]+)+[ \t*]*'+name+r'\([^;]+?\)\s*\{',source,re.M);assert m,name return source[m.start():source.index('\n}',m.start())+2]+'\n' -code=code.replace('int template_count;', 'int template_count; bool templates_ready;') +code=code.replace('int template_count;', 'int template_count; bool templates_ready; int templates_load_result;') code+=r''' #include "ts_rule_engine.h" esp_err_t ts_rule_dependency_change(ts_rule_dependency_t k,const char *id,const void *next){return ESP_OK;} diff --git a/tests/runtime/test_rule_pack_crypto.py b/tests/runtime/test_rule_pack_crypto.py index a771167..d9a5d67 100644 --- a/tests/runtime/test_rule_pack_crypto.py +++ b/tests/runtime/test_rule_pack_crypto.py @@ -68,16 +68,17 @@ def quiet(args): src=(root/'components/ts_automation/src/ts_rule_engine.c').read_text() a=src.index('typedef struct {\n ts_auto_rule_t *rules;');b=src.index('static void payload_free',a) names=['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation', - 'ts_rule_acquire','ts_rule_release','same_config','protected_bindings','pending_id', + 'ts_rule_acquire','ts_rule_release','same_config','protected_bindings','classify_rule_state','state_pending','pending_id','rule_union_count_locked','rebuild_rule_states', 'rule_commit_impl','ts_rule_commit','ts_rule_get_by_index','ts_rule_restart_pending', 'ts_rule_saved_status','ts_rule_pending_list','pack_reason','ts_rule_import_pack', 'ts_rule_edit_begin','ts_rule_edit_end','ts_rule_count','ts_rule_config_status','ts_rule_load_error', 'dependency_change_rule','ts_rule_dependency_change','load_rules','ts_rules_load', - 'ts_rules_load_from_file','ts_rule_engine_deinit','ts_rule_refresh_saved'] + 'ts_rules_load_from_file','ts_rule_engine_deinit','ts_rule_refresh_saved','ts_rule_deferred_load_task', + 'compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_trigger'] parts=[src[a:b]] masked=re.sub(r'/\*[\s\S]*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"',lambda m:' '*len(m.group()),src) for name in names: - match=re.search(r'^(?:static )?(?:esp_err_t|void|bool|int|cJSON|const char|rule_payload_t)[ \t]+\**'+name+r'\([^;]*?\)\s*\{',src,re.M) + match=re.search(r'^(?:static )?(?:esp_err_t|void|bool|int|cJSON|const char|rule_payload_t|rule_state_t)[ \t]+\**'+name+r'\([^;]*?\)\s*\{',src,re.M) assert match,name opening=src.index('{',match.start());depth=0 for end in range(opening,len(masked)): @@ -85,12 +86,21 @@ def quiet(args): if not depth:break parts.append(src[match.start():end+1]) api=(root/'components/ts_api/src/ts_api_automation.c').read_text() - a=api.index('static esp_err_t api_automation_rules_list(') - parts.append(api[a:api.index('\n}',a)+2]) + for name in ['api_automation_rules_list','rule_commit_reply','rule_mutate_locked','rule_mutate','api_automation_rules_enable']: + match=re.search(r'^static (?:esp_err_t|void) '+name+r'\([^;]*?\)\s*\{',api,re.M);assert match,name + parts.append(api[match.start():api.index('\n}',match.start())+2]) (build/'pack_engine.inc').write_text('\n'.join(parts)) + manager=(root/'components/ts_automation/src/ts_action_manager.c').read_text() + a=manager.index('typedef struct {\n atomic_uint refs;');b=manager.index('} action_binding_t;',a)+len('} action_binding_t;') + snapshots=[manager[a:b]] + for name in ['ts_action_register_ssh_host','ts_action_unregister_ssh_host','ts_action_get_ssh_host_ex','ts_action_templates_load','ts_action_snapshot_owner','ts_action_snapshot_retain','ts_action_snapshot_release','snapshot_command','ts_action_snapshot']: + m=re.search(r'^(?:static )?(?:void|esp_err_t) '+name+r'\([^;]*?\)\s*\{',manager,re.M);assert m,name + snapshots.append(manager[m.start():manager.index('\n}',m.start())+2]) + (build/'pack_snapshot.inc').write_text('\n'.join(snapshots)) sources[0]=root/'tests/runtime/rule_pack/test_engine_pack.c' cmd += ['-I'+str(build),'-DCONFIG_TS_AUTOMATION_MAX_RULES=4'] executable=build/('crypto-baseline' if '--baseline' in sys.argv else 'unconfigured' if '--unconfigured' in sys.argv else 'engine' if '--engine' in sys.argv else 'store' if '--store' in sys.argv else 'crypto') cmd += sources+[native/'library/libmbedx509.a',native/'library/libmbedcrypto.a','-lpthread','-o',executable] run(cmd,cwd=root) -run([executable,pki],cwd=root,env={**env,'ASAN_OPTIONS':'detect_leaks=0'} if '--store' in sys.argv else env) +case=next((a.split('=',1)[1] for a in sys.argv if a.startswith('--case=')),None) +run([executable,pki,*([case] if case else [])],cwd=root,env={**env,'ASAN_OPTIONS':'detect_leaks=0'} if '--store' in sys.argv else env) From bdc987d93d6050de2602528f2750ede89225c276 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 11:28:09 +0800 Subject: [PATCH 20/29] test: link rule-pack math and declare POSIX gate APIs on Linux --- docs/repair/delete-protection/INTEGRATION.md | 8 ++++++++ tests/runtime/test_configuration_protocol.py | 2 +- tests/runtime/test_rule_pack_crypto.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/repair/delete-protection/INTEGRATION.md b/docs/repair/delete-protection/INTEGRATION.md index 46d19b5..a1f5ed1 100644 --- a/docs/repair/delete-protection/INTEGRATION.md +++ b/docs/repair/delete-protection/INTEGRATION.md @@ -58,3 +58,11 @@ 真实签名根、Developer 证书用途、可信时间、NVS/SD 空间及设备验收仍未完成。 启动即可发生旧集合到 v3 的迁移;不承诺旧固件原地降级。完整迁移/信任边界见 `tests/runtime/rule_pack_contract.txt`;R1–R3 历史红/绿证据见 `tests/runtime/rule_pack/R1-R3-review.md`。 + +## GitHub Linux CI 跟进 + +整合提交后的首轮 runtime job(run `38020495204`,job `114120217161`)在实际 Linux 链接时 +报告 `round@@GLIBC_2.2.5` / `DSO missing from command line`。规则包原生夹具编译命令漏了 `-lm`, +macOS 自动带入系统数学符号,因而本地未暴露。新增显式 `-lm`;不改固件或断言。 +同时给同样编译真实递归锁夹具的 configuration_protocol 命令补 POSIX 接口声明,与其余调用者一致。 +这是测试构建适配,不修改生产源码;实际固件和 WebUI 产物因此不需重建。 diff --git a/tests/runtime/test_configuration_protocol.py b/tests/runtime/test_configuration_protocol.py index ee97f7f..9e22883 100644 --- a/tests/runtime/test_configuration_protocol.py +++ b/tests/runtime/test_configuration_protocol.py @@ -53,5 +53,5 @@ def extract(path,name): ''' (build/'configuration_protocol.c').write_text(s) env={**os.environ,'DEVELOPER_DIR':'/Library/Developer/CommandLineTools'} -subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined',*(['-isysroot','/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk'] if sys.platform == 'darwin' else []),*[f'-I{x}' for x in ['tests/runtime/state_stubs','tests/runtime/ssh_stubs','tests/runtime/stubs','tests/certificate/stubs','components/ts_security/include','components/ts_automation/include']],str(build/'configuration_protocol.c'),'components/ts_security/src/ts_ssh_service.c','components/ts_security/src/ts_ssh_log_watch.c','components/ts_security/src/ts_ssh_probe.c','-lpthread','-o',str(build/'configuration_protocol')],check=True,env=env) +subprocess.run(['cc','-std=c11','-D_POSIX_C_SOURCE=200809L','-g','-fsanitize=address,undefined',*(['-isysroot','/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk'] if sys.platform == 'darwin' else []),*[f'-I{x}' for x in ['tests/runtime/state_stubs','tests/runtime/ssh_stubs','tests/runtime/stubs','tests/certificate/stubs','components/ts_security/include','components/ts_automation/include']],str(build/'configuration_protocol.c'),'components/ts_security/src/ts_ssh_service.c','components/ts_security/src/ts_ssh_log_watch.c','components/ts_security/src/ts_ssh_probe.c','-lpthread','-o',str(build/'configuration_protocol')],check=True,env=env) subprocess.run([str(build/'configuration_protocol')],check=True,env=env) diff --git a/tests/runtime/test_rule_pack_crypto.py b/tests/runtime/test_rule_pack_crypto.py index d8693ca..f6f0c96 100644 --- a/tests/runtime/test_rule_pack_crypto.py +++ b/tests/runtime/test_rule_pack_crypto.py @@ -100,7 +100,7 @@ def quiet(args): sources[0]=root/'tests/runtime/rule_pack/test_engine_pack.c' cmd += ['-I'+str(build),'-DCONFIG_TS_AUTOMATION_MAX_RULES=4'] executable=build/('crypto-baseline' if '--baseline' in sys.argv else 'unconfigured' if '--unconfigured' in sys.argv else 'engine' if '--engine' in sys.argv else 'store' if '--store' in sys.argv else 'crypto') -cmd += sources+[native/'library/libmbedx509.a',native/'library/libmbedcrypto.a','-lpthread','-o',executable] +cmd += sources+[native/'library/libmbedx509.a',native/'library/libmbedcrypto.a','-lpthread','-lm','-o',executable] run(cmd,cwd=root) case=next((a.split('=',1)[1] for a in sys.argv if a.startswith('--case=')),None) run([executable,pki,*([case] if case else [])],cwd=root,env={**env,'ASAN_OPTIONS':'detect_leaks=0'} if '--store' in sys.argv else env) From 3a63ee4a97cc6efc47057e75903fc391aaf5d40e Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 11:41:30 +0800 Subject: [PATCH 21/29] =?UTF-8?q?feat(webui):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=90=8E=E8=A1=A5=E9=BD=90=E4=B8=8A=E6=B8=B8=E6=96=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=9A=84=E6=8F=90=E7=A4=BA=E8=AF=AD=EF=BC=88=E8=A7=84?= =?UTF-8?q?=E5=88=99=E9=85=8D=E7=BD=AE=E5=8C=85=E5=AF=BC=E5=85=A5=E3=80=81?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=BE=85=E9=87=8D=E5=90=AF/=E5=BE=85?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E3=80=81=E6=A8=A1=E6=9D=BF=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=EF=BC=89=E8=B5=B0=E7=BB=9F=E4=B8=80=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规则配置包验证/导入失败、结果未确认改由 failure 目录出句子(原因 22 条新增;旧的 rulePack.errors 整张表删除) - 快捷操作:待删除/未生效的规则被拒时说明原因 - 固件:config pack 新结果码(时间未确认、未配置信任、签名者无权限、凭证变化)映射为原因代号;引擎拒绝待删除规则时给出 rule_pending_delete - describeFailure 读取错误自带的 data(上游让 ApiOperationError 带上 data) Co-Authored-By: Claude Sonnet 5.5 --- components/ts_config_pack/src/ts_config_pack.c | 4 ++++ components/ts_webui/web/js/app.js | 3 ++- components/ts_webui/web/js/lang/en-US.js | 2 +- components/ts_webui/web/js/lang/zh-CN.js | 2 +- tests/prompts/rule-pack.browser.test.cjs | 13 ++++++++----- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/components/ts_config_pack/src/ts_config_pack.c b/components/ts_config_pack/src/ts_config_pack.c index 43efb5f..0047ca8 100644 --- a/components/ts_config_pack/src/ts_config_pack.c +++ b/components/ts_config_pack/src/ts_config_pack.c @@ -205,6 +205,10 @@ const char *ts_config_pack_reason(ts_config_pack_result_t result) case TS_CONFIG_PACK_ERR_PERMISSION: return "pack_not_allowed"; case TS_CONFIG_PACK_ERR_NOT_INIT: return "pack_not_ready"; case TS_CONFIG_PACK_ERR_INVALID_ARG: return "invalid_argument"; + case TS_CONFIG_PACK_ERR_TIME_UNVERIFIED: return "time_unverified"; + case TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED: return "signing_trust_unconfigured"; + case TS_CONFIG_PACK_ERR_SIGNER_ROLE: return "signer_unauthorized"; + case TS_CONFIG_PACK_ERR_CREDENTIAL_CHANGED: return "credential_changed"; default: return "pack_verify_failed"; } } diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index c6c796e..4512fed 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -18232,7 +18232,8 @@ async function previewRuleImport() { !data.rule || data.rule.id !== data.id || !Array.isArray(data.rule.actions) || !data.rule.actions.length || !Number.isInteger(data.expected_revision) || !Number.isInteger(data.expected_generation) || !Number.isInteger(data.credential_generation) || !/^[0-9a-f]{64}$/.test(data.package_digest || '')) { - packBox(box, { op: 'pack.verify', params: { kind: t('failure.kinds.rule'), file: file.name }, result, token: data?.error_code, endpoint: 'automation.rules.import', write: false }); return; + packBox(box, { op: 'pack.verify', params: { kind: t('failure.kinds.rule'), file: file.name }, result, endpoint: 'automation.rules.import', write: false, + token: data?.error_code || (result.code === 0 ? (data?.valid !== true ? 'invalid_pack' : data?.trusted !== true ? 'signer_untrusted' : data?.target_matches !== true ? 'wrong_device' : 'invalid_response') : undefined) }); return; } rulePackPreview = data; window._importRuleTscfg = bytes; window._importRuleFilename = file.name; diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index f5e3ffe..f34c727 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -4467,7 +4467,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { sio_event_timeout: { why: 'it connected, but no event arrived after waiting', hint: 'Check the event name, or leave it empty so the device discovers it.' }, // -- Automation: enable / disable / save a rule -- execution_busy: { why: 'it is running right now', hint: 'Wait for it to finish, then try again.' }, - revision_conflict: { why: 'it was just changed by another operation', hint: 'Refresh, check the latest content, then try again.' }, + revision_conflict: { why: 'the configuration on the device was just changed by another operation', hint: 'Refresh, check the latest content, then try again.' }, service_busy: { why: 'its bound service is running or its state is unconfirmed', hint: 'Verify the service state and stop it first.' }, source_read_only: { why: 'it comes from a read-only configuration source and cannot be changed', hint: 'The original file was not changed; edit its configuration source if you need a change.' }, recovery_required: { why: 'the automation configuration needs a recovery check, and automatic runs are paused', hint: 'Reload and check the configuration before continuing.' }, diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 9fc9e68..fea8e75 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -4430,7 +4430,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { sio_event_timeout: { why: '已经连上,但等了一会儿没有收到事件', hint: '请确认事件名是否正确,或者把事件名留空,让设备自动发现。' }, // —— 自动化:规则启用 / 禁用 / 保存 —— execution_busy: { why: '它正在执行中', hint: '等执行完成后再试。' }, - revision_conflict: { why: '它刚被其他操作修改过', hint: '请刷新后确认最新内容,再重新操作。' }, + revision_conflict: { why: '设备上的这份配置刚被其他操作修改过', hint: '请刷新后确认最新内容,再重新操作。' }, service_busy: { why: '它绑定的服务正在运行,或状态还没有确认', hint: '请先核实并停止该服务。' }, source_read_only: { why: '它来自只读的配置来源,不能修改', hint: '原文件没有被改动;如需修改,请编辑它的配置来源。' }, recovery_required: { why: '自动化配置需要先做恢复核对,自动执行已暂停', hint: '请重新读取配置并核对后再继续。' }, diff --git a/tests/prompts/rule-pack.browser.test.cjs b/tests/prompts/rule-pack.browser.test.cjs index 35dac3d..290ca8d 100644 --- a/tests/prompts/rule-pack.browser.test.cjs +++ b/tests/prompts/rule-pack.browser.test.cjs @@ -97,6 +97,9 @@ async function pageFor(language,mode,run){ assert.deepEqual(errors,[]); }finally{await context.close();await new Promise(resolve=>server.close(resolve));} } +// The sentence the page must show for a failed verification / import comes from the failure catalogue (one source of truth). +const said=(page,op,file,extra)=>page.evaluate(([op,file,extra])=>describeFailure({op,params:{kind:t('failure.kinds.rule'),file:file??window._importRuleFilename??''},...extra}).message,[op,file,extra]); +const shown=(page,text)=>page.waitForFunction(text=>document.getElementById('import-rule-result').textContent.includes(text),text); for(const language of ['zh-CN','en-US']){ test(language+': preview -> one save -> pending row and escaped ID',async()=>{ await pageFor(language,'ok',async({page,calls,writes,saved})=>{ @@ -121,7 +124,7 @@ for(const language of ['zh-CN','en-US']){ test(language+': saved generation conflict requires a fresh preview',async()=>{ await pageFor(language,'conflict',async({page,writes,saved})=>{ await page.locator('#import-rule-btn').click(); - await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.errors.revision_conflict')); + await shown(page,await said(page,'pack.import',null,{token:'revision_conflict',write:true})); assert.equal(writes(),1);assert.equal(saved(),false); assert(await page.locator('#import-rule-btn').isDisabled()); }); @@ -140,10 +143,10 @@ for(const language of ['zh-CN','en-US']){ test(language+': recovery failure cannot confirm an uncertain save',async()=>{ await pageFor(language,'unknown',async({page,calls,writes})=>{ await page.locator('#import-rule-btn').click(); - await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.unknown')); + await shown(page,await said(page,'pack.import',null,{token:'commit_unknown',write:true})); await page.waitForTimeout(100); assert(calls.some(c=>c.endpoint==='automation/rules/list'));assert.equal(writes(),1); - assert.equal(await page.locator('#import-rule-result').innerText(),await page.evaluate(()=>t('rulePack.unknown'))); + assert((await page.locator('#import-rule-result').innerText()).includes(await said(page,'pack.import',null,{token:'commit_unknown',write:true}))); assert(await page.locator('#import-rule-btn').isDisabled()); }); }); @@ -170,7 +173,7 @@ for(const language of ['zh-CN','en-US']){ test(language+': confirmed deletion row and quick action cannot trigger or confirm an uncertain pack save',async()=>{ await pageFor(language,'deleted',async({page,calls,writes})=>{ await page.locator('#import-rule-btn').click(); - await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.unknown')); + await shown(page,await said(page,'pack.import',null,{token:'commit_unknown',write:true})); assert.equal(writes(),1); await page.evaluate(async()=>{await refreshRules();document.body.insertAdjacentHTML('beforeend','
');await refreshQuickActions();}); assert.equal(await page.locator('#rules-list .tr:not(.th)').count(),1); @@ -187,7 +190,7 @@ for(const language of ['zh-CN','en-US']){ test(language+': unsupported action preview has a translated error and cannot save',async()=>{ await pageFor(language,'ok',async({page,writes})=>{ await page.locator('#import-rule-file').setInputFiles({name:'unsupported.tscfg',buffer:Buffer.from('unsupported')}); - await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.errors.action_unsupported')); + await shown(page,await said(page,'pack.verify','unsupported.tscfg',{token:'action_unsupported'})); assert(await page.locator('#import-rule-btn').isDisabled());assert.equal(writes(),0); assert.doesNotMatch(await page.locator('#import-rule-result').innerText(),/rulePack\.errors/); }); From 474bec118fd64e66ce12877e75e3a82036b52b91 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 13 Oct 2026 01:03:14 +0800 Subject: [PATCH 22/29] =?UTF-8?q?fix(webui):=20=E9=AA=8C=E8=AF=81=E4=B8=AD?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 44 个自动转换留下的“保存失败/设置失败”式提示改成“动作+对象”,并补上“结果未确认”的说法 - 设备原因不再被页面自己抛的 Error 吞掉:OTA 上传/服务器、证书、配置包都带原因代号(pageFailure) - 登录失败只在表单里显示一次;目录读取失败不再套用“上传结果不受影响”;更新检查不再重复“检查更新失败” - 密钥生成弹窗保留失败阶段与清理结果;规则保存缺少动作模板时说出模板名 - 折叠详情里的“接口”统一写成 storage.mkdir 这种形式;错误自带 data 时也显示阶段与原始信息 - 删除 198 条已无人使用的语言包旧键和 1 条已不存在的原因(规则导入已实现) Co-Authored-By: Claude Sonnet 5.5 --- components/ts_webui/web/js/api.js | 3 +- components/ts_webui/web/js/app.js | 176 ++++++++-------- components/ts_webui/web/js/lang/en-US.js | 242 ++-------------------- components/ts_webui/web/js/lang/zh-CN.js | 243 ++--------------------- 4 files changed, 115 insertions(+), 549 deletions(-) diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index b4cd0b2..40a78d1 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -174,7 +174,8 @@ function describeFailure({op, phase = 'failed', params = {}, result = null, erro const device = raw && raw !== tok ? raw : ''; const detail = device || (typeof body?.detail === 'string' ? body.detail : ''); const stage = typeof body?.stage === 'string' ? body.stage : ''; - const api = endpoint || error?.operation || ''; + // the name of the call, the same way everywhere: storage.mkdir (not /storage/mkdir, not storageMkdir) + const api = String(endpoint || error?.operation || '').replace(/^\/+/, '').replace(/\//g, '.').replace(/([a-z0-9])([A-Z])/g, (_, a, b) => a + '.' + b.toLowerCase()); const page = pageError && !error.failureToken && typeof error.message === 'string' ? error.message : ''; // Details are for whoever has to look into it: a reason the page worked out by itself (nothing was sent) has none. const lines = !ready || !(api || detail || stage || page) ? [] : [ diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 4512fed..8f9abf0 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -301,14 +301,12 @@ document.getElementById('login-form')?.addEventListener('submit', async (e) => { errorEl.textContent = failureText({ op: 'account.login', result, endpoint: 'auth.login', write: false }); errorEl.classList.remove('hidden'); } - reportFailure({ op: 'account.login', result, endpoint: 'auth.login', write: false }); } } catch (error) { if (errorEl) { errorEl.textContent = failureText({ op: 'account.login', error, endpoint: 'auth.login', write: false }); errorEl.classList.remove('hidden'); } - reportFailure({ op: 'account.login', error, endpoint: 'auth.login', write: false }); } finally { submitBtn.disabled = false; submitBtn.textContent = t('login.loginButton'); @@ -1037,12 +1035,12 @@ async function toggleProtection() { } else { // 恢复原状态 updateProtectionUI(currentRunning); - reportFailure({ op: 'system.switchfailed', result, write: true }); + reportFailure({ op: 'system.protection', result, write: true }); } } catch (e) { // 恢复原状态 updateProtectionUI(currentRunning); - reportFailure({ op: 'system.switchfailed', error: e, write: true }); + reportFailure({ op: 'system.protection', error: e, write: true }); } finally { if (icon) icon.style.pointerEvents = 'auto'; } @@ -1069,10 +1067,10 @@ async function toggleUsbMux() { updateUsbMuxButton(); showToast(t('system.usbSwitchedTo', { name: displayName }), 'success'); } else { - reportFailure({ op: 'system.switchfailed', result, write: true }); + reportFailure({ op: 'system.usbmux', result, write: true }); } } catch (e) { - reportFailure({ op: 'system.switchfailed', error: e, write: true }); + reportFailure({ op: 'system.usbmux', error: e, write: true }); } } @@ -1117,10 +1115,10 @@ async function toggleAgxPower() { updateAgxPowerButton(); showToast(toastSuccess, 'success'); } else { - reportFailure({ op: 'system.agxpowerfail', result, write: true }); + reportFailure({ op: 'system.agxpower', result, write: true }); } } catch (e) { - reportFailure({ op: 'system.agxpowerfail', error: e, write: true }); + reportFailure({ op: 'system.agxpower', error: e, write: true }); } } @@ -1150,10 +1148,10 @@ async function toggleLpmuPower() { // 启动状态检测(传入之前的状态) startLpmuStatePolling(wasOnline); } else { - reportFailure({ op: 'system.lpmutriggerfail', result, write: true }); + reportFailure({ op: 'system.lpmupower', result, write: true }); } } catch (e) { - reportFailure({ op: 'system.lpmutriggerfail', error: e, write: true }); + reportFailure({ op: 'system.lpmupower', error: e, write: true }); } } @@ -1598,11 +1596,11 @@ async function applyTestTemp() { // 刷新风扇状态 await refreshFans(); } else { - reportFailure({ op: 'fan.setfailed', result, write: true }); + reportFailure({ op: 'fan.settest', result, write: true }); } } catch (e) { console.error('设置测试温度失败:', e); - reportFailure({ op: 'fan.setfailed', error: e, write: true }); + reportFailure({ op: 'fan.settest', error: e, write: true }); } } @@ -1620,11 +1618,11 @@ async function clearTestTemp() { // 刷新风扇状态 await refreshFans(); } else { - reportFailure({ op: 'fan.clearfailed', result, write: true }); + reportFailure({ op: 'fan.cleartest', result, write: true }); } } catch (e) { console.error('清除测试温度失败:', e); - reportFailure({ op: 'fan.clearfailed', error: e, write: true }); + reportFailure({ op: 'fan.cleartest', error: e, write: true }); } } @@ -1666,7 +1664,7 @@ async function setFanMode(id, mode) { requireApiSuccess(await api.call('fan.mode', { id: id, mode: mode }), 'call'); showToast(typeof t === 'function' ? t('fan.modeSwitch', { id, mode: t({off:'common.disabled', manual:'common.manual', auto:'fanPage.modeSmart', curve:'fan.curve'}[mode] || 'common.unknown') }) : `风扇 ${id} 模式已切换为 ${mode}`, 'success'); await refreshFans(); - } catch (e) { reportFailure({ op: 'fan.setfanmodefailed', error: e, write: true }); } + } catch (e) { reportFailure({ op: 'fan.setmode', error: e, write: true }); } } function markFanOutputsUnknown(id = null) { @@ -2285,11 +2283,11 @@ async function bindTempVariable() { await loadTempSourceStatus(); } else { - reportFailure({ op: 'fanpage.bindfailed', result, write: true }); + reportFailure({ op: 'fanpage.bind', result, write: true }); } } catch (e) { console.error('绑定温度变量失败:', e); - reportFailure({ op: 'fanpage.bindfailed', error: e, write: true }); + reportFailure({ op: 'fanpage.bind', error: e, write: true }); } } @@ -2306,11 +2304,11 @@ async function unbindTempVariable() { await loadTempSourceStatus(); } else { - reportFailure({ op: 'fanpage.unbindfailed', result, write: true }); + reportFailure({ op: 'fanpage.unbind', result, write: true }); } } catch (e) { console.error('解绑温度变量失败:', e); - reportFailure({ op: 'fanpage.unbindfailed', error: e, write: true }); + reportFailure({ op: 'fanpage.unbind', error: e, write: true }); } } @@ -2385,7 +2383,7 @@ async function applyFanCurve() { } catch (e) { console.error('应用曲线失败:', e); - reportFailure({ op: 'fanpage.applycurvefailed', error: e, write: true }); + reportFailure({ op: 'fanpage.applycurve', error: e, write: true }); } } @@ -2496,7 +2494,7 @@ function importFanCurveConfig() { showToast(typeof t === 'function' ? t('fanPage.configImported', { name: file.name }) : `已导入配置文件: ${file.name}`, 'success'); } catch (err) { console.error('导入配置失败:', err); - reportFailure({ op: 'fanpage.importconfigfailed', error: err, write: true }); + reportFailure({ op: 'fanpage.importcurve', error: err, write: true }); } }; @@ -4462,10 +4460,10 @@ async function syncTimeFromBrowser(silent = false) { console.error('Failed to refresh time info:', e); } } else { - if (!silent) reportFailure({ op: 'toast.syncfailed', result, endpoint: 'time.sync', write: true }); + if (!silent) reportFailure({ op: 'system.timesync', result, endpoint: 'time.sync', write: true }); } } catch (e) { - if (!silent) reportFailure({ op: 'toast.syncfailed', error: e, write: true }); + if (!silent) reportFailure({ op: 'system.timesync', error: e, write: true }); } } @@ -4514,7 +4512,7 @@ async function applyTimezone() { await refreshSystemPage(); } } catch (e) { - reportFailure({ op: 'toast.setfailed', error: e, write: true }); + reportFailure({ op: 'system.timezone', error: e, write: true }); } } @@ -4604,7 +4602,7 @@ async function fillColorFromPicker(device, color) { updateLedCardState(device, true); showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} 已填充 ${color}`, 'success'); } catch (e) { - reportFailure({ op: 'toast.ledfillfailed', error: e, write: true }); + reportFailure({ op: 'led.fill', error: e, write: true }); } } @@ -4617,7 +4615,7 @@ async function quickFillColor(device, color) { updateLedCardState(device, true, null); showToast(typeof t === 'function' ? t('toast.ledFilled', { device: getDeviceDescription(device), color }) : `${device} → ${color}`, 'success'); } catch (e) { - reportFailure({ op: 'toast.ledfillfailed', error: e, write: true }); + reportFailure({ op: 'led.fill', error: e, write: true }); } } @@ -4872,7 +4870,7 @@ async function toggleLedFromModal(device, enabled) { // 更新卡片状态 updateLedCardState(device, ledStates[device], selectedEffects[device]); } catch (e) { - reportFailure({ op: 'toast.operationfailedmsg', error: e, write: true }); + reportFailure({ op: 'led.toggle', error: e, write: true }); // 恢复 UI 状态 const cb = document.getElementById(`modal-device-enabled-${device}`); if (cb) cb.checked = ledStates[device]; @@ -4960,7 +4958,7 @@ async function applyEffectFromModal(device) { showToast(typeof t === 'function' ? t('toast.ledEffectStarted', { device: getDeviceDescription(device), effect }) : `${device}: ${effect} 已启动`, 'success'); } catch (e) { - reportFailure({ op: 'toast.ledeffectstartfailed', error: e, write: true }); + reportFailure({ op: 'led.starteffect', error: e, write: true }); } } @@ -4972,7 +4970,7 @@ async function stopEffectFromModal(device) { updateLedCardState(device, ledStates[device], null); showToast(typeof t === 'function' ? t('toast.ledEffectStopped', { device: getDeviceDescription(device) }) : `${device} 动画已停止`, 'success'); } catch (e) { - reportFailure({ op: 'toast.ledeffectstopfailed', error: e, write: true }); + reportFailure({ op: 'led.stopeffect', error: e, write: true }); } } @@ -4990,7 +4988,7 @@ async function displayImageFromModal() { requireApiSuccess(await api.call('led.image', { device: 'matrix', path, center }), 'call'); showToast((typeof t === 'function' ? t('toast.imageDisplayed') : '图像已显示'), 'success'); } catch (e) { - reportFailure({ op: 'toast.ledimagefailed', error: e, write: true }); + reportFailure({ op: 'led.image', error: e, write: true }); } } @@ -5010,7 +5008,7 @@ async function generateQrCodeFromModal() { requireApiSuccess(await api.call('led.qrcode', { device: 'matrix', text, ecc, fg_color: fg, bg_image: bgImage || undefined }), 'call'); showToast((typeof t === 'function' ? t('toast.qrGenerated') : 'QR 码已生成'), 'success'); } catch (e) { - reportFailure({ op: 'toast.ledqrfailed', error: e, write: true }); + reportFailure({ op: 'led.qr', error: e, write: true }); } } @@ -5097,7 +5095,7 @@ async function displayTextFromModal() { requireApiSuccess(await api.call('led.text', params), 'call'); showToast((typeof t === 'function' ? t('toast.textDisplayed') : '文本已显示'), 'success'); } catch (e) { - reportFailure({ op: 'toast.ledtextfailed', error: e, write: true }); + reportFailure({ op: 'led.text', error: e, write: true }); } } @@ -5107,7 +5105,7 @@ async function stopTextFromModal() { requireApiSuccess(await api.call('led.text.stop', { device: 'matrix' }), 'call'); showToast((typeof t === 'function' ? t('toast.textScrollStopped') : '文本滚动已停止'), 'success'); } catch (e) { - reportFailure({ op: 'toast.ledtextstopfailed', error: e, write: true }); + reportFailure({ op: 'led.stoptext', error: e, write: true }); } } @@ -5171,7 +5169,7 @@ async function applyFilterFromModal() { requireApiSuccess(await api.call('led.filter.start', params), 'call'); showToast(typeof t === 'function' ? t('toast.filterApplied', { filter: filterDisplayName(selectedModalFilter) }) : `滤镜 ${selectedModalFilter} 已应用`, 'success'); } catch (e) { - reportFailure({ op: 'toast.filterapplyfailed', error: e, write: true }); + reportFailure({ op: 'led.filterapply', error: e, write: true }); } } @@ -5181,7 +5179,7 @@ async function stopFilterFromModal() { requireApiSuccess(await api.call('led.filter.stop', { device: 'matrix' }), 'call'); showToast((typeof t === 'function' ? t('toast.filterStopped') : '滤镜已停止'), 'success'); } catch (e) { - reportFailure({ op: 'toast.filterstopfailed', error: e, write: true }); + reportFailure({ op: 'led.filterstop', error: e, write: true }); } } @@ -5315,10 +5313,10 @@ async function applyColorCorrection() { ccInitialConfig = JSON.parse(JSON.stringify(config)); showToast(t('ledPage.ccApplySuccess'), 'success'); } else { - reportFailure({ op: 'ledpage.ccapplyfailed', result, write: true }); + reportFailure({ op: 'led.ccapply', result, write: true }); } } catch (e) { - reportFailure({ op: 'ledpage.ccapplyfailed', error: e, write: true }); + reportFailure({ op: 'led.ccapply', error: e, write: true }); } } @@ -5360,10 +5358,10 @@ async function resetColorCorrection() { if (result.code === 0) { showToast(t('ledPage.ccResetSuccess'), 'success'); } else { - reportFailure({ op: 'ledpage.ccresetfailed', result, write: true }); + reportFailure({ op: 'led.ccreset', result, write: true }); } } catch (e) { - reportFailure({ op: 'ledpage.ccresetfailed', error: e, write: true }); + reportFailure({ op: 'led.ccreset', error: e, write: true }); } } @@ -5390,10 +5388,10 @@ async function ccImport() { await loadColorCorrectionConfig(); showToast(t('ledPage.ccImportSuccess'), 'success'); } else { - reportFailure({ op: 'ledpage.ccimportfailed', result, write: true }); + reportFailure({ op: 'led.ccimport', result, write: true }); } } catch (e) { - reportFailure({ op: 'ledpage.ccimportfailed', error: e, write: true }); + reportFailure({ op: 'led.ccimport', error: e, write: true }); } } @@ -5424,7 +5422,7 @@ async function setBrightness(device, value) { requireApiSuccess(await api.ledBrightness(device, parseInt(value)), 'ledBrightness'); showToast(typeof t === 'function' ? t('toast.ledBrightnessSet', { device: getDeviceDescription(device), value }) : `${device} 亮度: ${value}`, 'success'); } catch (e) { - reportFailure({ op: 'toast.ledbrightnessfailed', params: { device: getDeviceDescription(device) }, error: e, write: true }); + reportFailure({ op: 'led.brightness', params: { device: getDeviceDescription(device) }, error: e, write: true }); } } @@ -5449,7 +5447,7 @@ async function toggleLed(device) { showToast(typeof t === 'function' ? t('toast.ledTurnedOn', { device: getDeviceDescription(device) }) : `${device} 已开启`, 'success'); } } catch (e) { - reportFailure({ op: 'toast.operationfailedmsg', error: e, write: true }); + reportFailure({ op: 'led.toggle', error: e, write: true }); } } @@ -5465,7 +5463,7 @@ async function stopEffect(device) { delete selectedEffects[device]; showToast(typeof t === 'function' ? t('toast.ledEffectStopped', { device: getDeviceDescription(device) }) : `${device} 动画已停止`, 'success'); } catch (e) { - reportFailure({ op: 'toast.ledeffectstopfailed', error: e, write: true }); + reportFailure({ op: 'led.stopeffect', error: e, write: true }); } } @@ -5479,7 +5477,7 @@ async function saveLedConfig(device) { showToast(typeof t === 'function' ? t('toast.ledConfigSaved', { device: getDeviceDescription(device) }) : `${device} 配置已保存`, 'success'); } } catch (e) { - reportFailure({ op: 'toast.ledconfigsavefailed', error: e, write: true }); + reportFailure({ op: 'led.saveconfig', error: e, write: true }); } } @@ -5588,7 +5586,7 @@ async function loadFilePickerDirectory(path) { return `
${escapeHtml(f.name)}
${isDir ? '-' : formatFileSize(f.size)}
`; }).join(''); } catch (e) { - listContainer.innerHTML = `
${failureNote({ op: 'common.loadfailed', error: e, write: false })}
`; + listContainer.innerHTML = `
${failureNote({ op: 'file.list', error: e, write: false })}
`; } } @@ -5598,7 +5596,7 @@ async function createAndOpenDir(path) { requireApiSuccess(await api.storageMkdir(path), 'storageMkdir'); await loadFilePickerDirectory(path); } catch (e) { - reportFailure({ op: 'toast.createdirfailed', error: e, write: true }); + reportFailure({ op: 'file.createdir', error: e, write: true }); } } @@ -6168,7 +6166,7 @@ function showNetworkLpmuAccessResult(info) { } } catch (error) { finished = true; // Reopening must not automatically retry an invalid transcript. - if (modal.isConnected) output.textContent = failureText({ op: 'networkpage.lpmuaccesslogfailed', error: error, write: false }); + if (modal.isConnected) output.textContent = failureText({ op: 'network.lpmulog', error, endpoint: 'lpmu.access.log', write: false }); } finally { loading = false; } }; modal.querySelector('button').onclick = close; @@ -6468,7 +6466,7 @@ async function setWifiMode() { showToast(typeof t === 'function' ? t('toast.wifiModeChanged', { mode: getWifiModeDisplay(mode) }) : `WiFi 模式已切换为 ${getWifiModeDisplay(mode)}`, 'success'); await refreshNetworkPage(); } catch (e) { - reportFailure({ op: 'toast.switchfailedmsg', error: e, write: true }); + reportFailure({ op: 'wifi.mode', error: e, write: true }); } } @@ -6485,7 +6483,7 @@ async function setHostname() { document.getElementById('hostname-input').value = ''; await refreshNetworkPage(); } catch (e) { - reportFailure({ op: 'toast.setfailedmsg', error: e, write: true }); + reportFailure({ op: 'system.hostname', error: e, write: true }); } } @@ -6591,7 +6589,7 @@ function submitWifiConnect(ssid) { showToast((typeof t === 'function' ? t('toast.connecting') : '正在连接...'), 'info'); setTimeout(refreshNetworkPage, 3000); }) - .catch(e => reportFailure({ op: 'toast.connectfailedmsg', error: e, write: true })); + .catch(e => reportFailure({ op: 'wifi.connect', error: e, write: true })); } async function disconnectWifi() { @@ -6600,7 +6598,7 @@ async function disconnectWifi() { showToast((typeof t === 'function' ? t('toast.wifiDisconnected') : '已断开 WiFi 连接'), 'success'); await refreshNetworkPage(); } catch (e) { - reportFailure({ op: 'toast.disconnectfailedmsg', error: e, write: true }); + reportFailure({ op: 'wifi.disconnect', error: e, write: true }); } } @@ -6624,7 +6622,7 @@ async function showApStations() { `).join(''); } } catch (e) { - container.innerHTML = `
${failureNote({ op: 'toast.fetchfailed', error: e, write: false })}
`; + container.innerHTML = `
${failureNote({ op: 'wifi.stations', error: e, write: false })}
`; } } @@ -6663,7 +6661,7 @@ async function applyApConfig() { hideApConfig(); await refreshNetworkPage(); } catch (e) { - reportFailure({ op: 'toast.configfailedmsg', error: e, write: true }); + reportFailure({ op: 'wifi.applyap', error: e, write: true }); } } @@ -6698,7 +6696,7 @@ async function loadDhcpClients() { } } catch (e) { if (!pageCurrent()) return; - container.innerHTML = `
${failureNote({ op: 'toast.fetchfailed', error: e, write: false })}
`; + container.innerHTML = `
${failureNote({ op: 'network.dhcpclients', error: e, write: false })}
`; } } @@ -6714,7 +6712,7 @@ async function toggleNat() { } await refreshNetworkPage(); } catch (e) { - reportFailure({ op: 'toast.operationfailedmsg', error: e, write: true }); + reportFailure({ op: 'network.nattoggle', error: e, write: true }); } } @@ -6723,7 +6721,7 @@ async function saveNatConfig() { requireApiSuccess(await api.natSave(), 'natSave'); showToast(typeof t === 'function' ? t('toast.natConfigSaved') : 'NAT 配置已保存', 'success'); } catch (e) { - reportFailure({ op: 'toast.savefailed', error: e, write: true }); + reportFailure({ op: 'network.savenat', error: e, write: true }); } } @@ -6944,7 +6942,7 @@ async function mountSdCard() { showToast(typeof t === 'function' ? t('filePage.mountSdSuccess') : 'SD 卡挂载成功', 'success'); await refreshFilesPage(); } catch (e) { - reportFailure({ op: 'filepage.mountsdfailed', error: e, write: true }); + reportFailure({ op: 'file.mountsd', error: e, write: true }); } } @@ -6962,7 +6960,7 @@ async function unmountSdCard() { } await refreshFilesPage(); } catch (e) { - reportFailure({ op: 'filepage.unmountsdfailed', error: e, write: true }); + reportFailure({ op: 'file.unmountsd', error: e, write: true }); } } @@ -7084,7 +7082,7 @@ async function loadDirectory(path) { `; } else { - listContainer.textContent = failureText({ op: 'promptrepair.listrefreshfailed', error: e, write: false }); + listContainer.textContent = failureText({ op: 'file.list', error: e, endpoint: 'storage.list', write: false }); } return {error: e}; } @@ -7310,7 +7308,8 @@ async function uploadFiles() { const summary = showOperationSummary('promptRepair.uploadSummary', success, failures, {invalid}); // Refresh is a separate read: its failure must never change confirmed upload results. if (success && pageCurrent() && document.getElementById('page-content') === page && currentFilePath === destination && document.getElementById('file-list')) { - await refreshFilesPage(); + const refreshed = await refreshFilesPage(); + if (refreshed?.error) document.getElementById('operation-summary')?.append('\n' + t('promptRepair.listRefreshFailed')); } return summary; } @@ -7339,7 +7338,7 @@ async function createNewFolder() { closeNewFolderDialog(); refreshFilesPage(); } catch (e) { - reportFailure({ op: 'toast.createfailedmsg', error: e, write: true }); + reportFailure({ op: 'file.createdir', error: e, write: true }); } } @@ -7374,7 +7373,7 @@ async function doRename() { closeRenameDialog(); refreshFilesPage(); } catch (e) { - reportFailure({ op: 'toast.renamefailedmsg', error: e, write: true }); + reportFailure({ op: 'file.rename', error: e, write: true }); } } @@ -7393,7 +7392,7 @@ async function downloadFile(path, silent = false) { if (!silent) showToast(t('promptRepair.downloadStarted'), 'success'); return {started: true}; } catch (error) { - if (!silent) reportFailure({ op: 'toast.downloadfailedmsg', error: error, write: false }); + if (!silent) reportFailure({ op: 'file.download', error: error, write: false }); return {started: false, error}; } finally { if (anchor) anchor.remove(); @@ -7413,7 +7412,7 @@ async function deleteFile(path) { showToast((typeof t === 'function' ? t('toast.deleteSuccess') : '删除成功'), 'success'); refreshFilesPage(); } catch (e) { - reportFailure({ op: 'toast.deletefailedmsg', error: e, write: true }); + reportFailure({ op: 'file.delete', error: e, write: true }); } } @@ -7648,7 +7647,7 @@ async function showDeleteProtection(result, context, pageCurrent) { const state = context.service; const key = state ? deleteServiceMessage(state) : null; bodyHtml = context.type === 'action' && (context.commandName || context.commandId) ? escapeHtml(t('deleteProtection.boundCommand', {command: context.commandName || context.commandId})) + '\n\n' : ''; - bodyHtml += escapeHtml(key ? t('deleteProtection.' + key, {command: context.commandName || context.commandId}) : t('promptRepair.serviceDeleteProtected')); + bodyHtml += escapeHtml(t('deleteProtection.' + (key || 'unconfirmed'), {command: context.commandName || context.commandId || context.name})); if (context.type === 'command') primary = t('common.close'); else { primary = t('deleteProtection.viewCommand'); target = '/commands'; } } else return false; @@ -11475,7 +11474,7 @@ async function packExportBrowseRefresh() { packExportUpdateSelectedDisplay(); } catch (e) { - fileList.innerHTML = `
${failureNote({ op: 'common.loadfailed', error: e, write: false })}
`; + fileList.innerHTML = `
${failureNote({ op: 'file.list', error: e, write: false })}
`; } } @@ -11815,7 +11814,7 @@ async function refreshConfigPackList() { } catch (e) { if (!pageCurrent()) return; - loading.textContent = failureText({ op: 'securitypage.packlistloadfailed', error: e, write: false }); + loading.textContent = failureText({ op: 'pack.list', error: e, write: false }); } } @@ -11830,7 +11829,7 @@ async function importPackFromList(filePath) { showToast(typeof t === 'function' ? t('securityPage.importPackSuccess') : '配置包导入成功', 'success'); hideConfigPackListModal(); } catch (e) { - reportFailure({ op: 'securitypage.importpackfailed', error: e, write: true }); + reportFailure({ op: 'pack.import', params: { file: String(filePath).split('/').pop() }, error: e, endpoint: 'config.pack.import', write: true }); } } @@ -12256,7 +12255,10 @@ async function generateKey() { const spec = { op: 'key.generate', params: { id }, endpoint: 'key.generate', write: true, result: {...result, data: {...detail, stage: detail?.failed_stage ? detail.failed_stage + (detail.esp_error ? ' · ' + detail.esp_error : '') : undefined, detail: detail?.cleanup_complete === false ? t('keyGeneration.cleanupFailed', {error: detail.cleanup_error || ''}) : undefined}} }; - if (ownsModal()) { setKeyGenerationStatus(failureText(spec)); reportFailure(spec); } + const lines = [failureText(spec), + detail?.failed_stage ? t('keyGeneration.stage', {stage: detail.failed_stage, error: detail.esp_error || ''}) : '', + detail?.cleanup_complete === false ? t('keyGeneration.cleanupFailed', {error: detail.cleanup_error || ''}) : ''].filter(Boolean); + if (ownsModal()) { setKeyGenerationStatus(lines.join('\n')); reportFailure(spec); } return; } if (result?.code !== 0 || (result.httpStatus && result.httpStatus >= 400) || result.data?.generated !== true || result.data?.id !== id) { @@ -13643,10 +13645,10 @@ async function validateOta() { showToast(typeof t === 'function' ? t('toast.firmwareValidated') : '固件已标记为有效', 'success'); await refreshOtaInfo(); } else { - reportFailure({ op: 'toast.operationfailedmsg', result, write: true }); + reportFailure({ op: 'ota.validate', result, write: true }); } } catch (error) { - reportFailure({ op: 'toast.operationfailedmsg', error: error, write: true }); + reportFailure({ op: 'ota.validate', error: error, write: true }); } } @@ -13667,10 +13669,10 @@ async function rollbackOta() { showToast(typeof t === 'function' ? t('toast.rollbackSuccess') : '回滚成功!系统将在 3 秒后重启...', 'success'); // 3秒后页面会因为重启而断开连接 } else { - reportFailure({ op: 'toast.rollbackfailedmsg', result, write: true }); + reportFailure({ op: 'ota.rollback', result, write: true }); } } catch (error) { - reportFailure({ op: 'toast.rollbackfailedmsg', error: error, write: true }); + reportFailure({ op: 'ota.rollback', error: error, write: true }); } } @@ -13695,10 +13697,10 @@ async function abortOta() { clearInterval(refreshInterval); refreshInterval = null; } else { - reportFailure({ op: 'toast.abortfailedmsg', result, write: true }); + reportFailure({ op: 'ota.abort', result, write: true }); } } catch (error) { - reportFailure({ op: 'toast.abortfailedmsg', error: error, write: true }); + reportFailure({ op: 'ota.abort', error: error, write: true }); } } @@ -13799,10 +13801,10 @@ async function saveOtaServer() { showToast(typeof t === 'function' ? t('toast.otaServerCleared') : 'OTA 服务器地址已清除', 'info'); } } else { - reportFailure({ op: 'toast.savefailedmsg', result, write: true }); + reportFailure({ op: 'ota.saveserver', result, write: true }); } } catch (error) { - reportFailure({ op: 'toast.savefailedmsg', error: error, write: true }); + reportFailure({ op: 'ota.saveserver', error: error, write: true }); } } @@ -13929,8 +13931,7 @@ async function checkForUpdates() { statusDiv.className = 'ota-update-status error'; statusDiv.innerHTML = `
-
${t('otaPage.checkUpdateFailed')}
-
${escapeHtml(failureText({ op: 'ota.check', error, write: false }))}
+
${failureNote({ op: 'ota.check', error, write: false })}
`; } @@ -16300,11 +16301,11 @@ async function saveShutdownSettings() { showToast(typeof t === 'function' ? t('toast.shutdownSettingsSaved') : '关机设置已保存', 'success'); closeShutdownSettingsModal(); } else { - errorDiv.textContent = failureText({ op: 'errors.savefailed', result, write: false }); + errorDiv.textContent = failureText({ op: 'system.saveshutdown', result, write: false }); errorDiv.classList.remove('hidden'); } } catch (e) { - errorDiv.textContent = failureText({ op: 'toast.savefailedmsg', error: e, write: false }); + errorDiv.textContent = failureText({ op: 'system.saveshutdown', error: e, endpoint: 'system.shutdown.set', write: true }); errorDiv.classList.remove('hidden'); } } @@ -16335,10 +16336,10 @@ async function resetShutdownSettings() { document.getElementById('ss-fan-stop-delay').value = 60; showToast(typeof t === 'function' ? t('toast.defaultsRestored') : '已恢复默认设置', 'success'); } else { - reportFailure({ op: 'toast.restorefailedmsg', result, write: true }); + reportFailure({ op: 'system.resetshutdown', result, write: true }); } } catch (e) { - reportFailure({ op: 'toast.restorefailedmsg', error: e, write: true }); + reportFailure({ op: 'system.resetshutdown', error: e, write: true }); } } @@ -17843,9 +17844,10 @@ async function submitAddRule(originalId = null) { closeModal('add-rule-modal'); await Promise.all([refreshRules(), refreshAutomationStatus()]); } else { - const missingTemplate = result.data?.error_code === 'action_missing' - ? (selectedTemplateNames.get(result.data.missing_template_id) || result.data.missing_template_id) : undefined; - reportFailure({ op: 'rule.save', params: { ...messageParams, template: missingTemplate }, result, endpoint }); + const missingId = result.data?.error_code === 'action_missing' ? result.data.missing_template_id : undefined; + const missingTemplate = missingId ? (selectedTemplateNames.get(missingId) || missingId) : undefined; + reportFailure({ op: 'rule.save', params: { ...messageParams, template: missingTemplate }, result, endpoint, + token: result.data?.error_code === 'action_missing' && !missingTemplate ? 'action_missing_unnamed' : undefined }); } } catch (error) { reportFailure({ op: 'rule.save', params: messageParams, error, endpoint, write: true }); diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index f34c727..eb38be2 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -379,7 +379,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { run: 'Run', stop: 'Stop', start: 'Start', - restart: 'Restart', pause: 'Pause', reload: 'Reload', trigger: 'Trigger', @@ -474,13 +473,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { valid: 'Valid', invalid: 'Invalid', // GPIO - high: 'High', - low: 'Low', host: 'Host', device: 'Device', logs: 'Logs', - loadFailed: 'Load Failed', - loadFailedMsg: 'Load failed: {msg}', refreshOnce: 'Refresh once', uploadFailedMsg: 'Failed: {msg}', copyToClipboard: 'Copy to Clipboard', @@ -515,7 +510,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { commands: 'SSH Commands', home: 'Home', system: 'System', - led: 'LED Control', network: 'Network', files: 'Files', terminal: 'Terminal', @@ -559,7 +553,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { stage: 'Stage', health: 'Health', toggleProtectionTitle: 'Click to toggle protection', - switchFailed: 'Switch failed', usbMuxNotConfigured: 'USB MUX not configured', usbSwitchTo: 'Switching USB to {name}...', usbSwitchedTo: 'USB switched to {name}', @@ -652,7 +645,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { rebootConfirm: 'Reboot system?', rebootSending: 'Sending reboot command...', rebootingPleaseWait: 'System is rebooting, please wait...', - rebootFailed: 'Reboot failed', agxPowerOnTitle: 'Click to turn on AGX power', agxPowerOffTitle: 'Click to turn off AGX power', agxPowerOn: 'Power on', @@ -661,7 +653,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { agxPoweringOff: 'AGX powering off...', agxPowerOnSuccess: 'AGX powered on', agxPowerOffSuccess: 'AGX powered off', - agxPowerFail: 'AGX operation failed', lpmuRunning: "Network reachable", lpmuStopped: "Network unreachable", lpmuOnlineTitle: "LPMU is reachable over the network. This check does not confirm its power state.", @@ -672,7 +663,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { lpmuTriggerConfirm: 'Trigger LPMU power button?\n\nThis sends a pulse, like pressing the physical power button.', lpmuTriggering: 'LPMU power trigger...', lpmuTriggerSuccess: 'LPMU trigger sent, detecting status...', - lpmuTriggerFail: 'LPMU trigger failed', lpmuOnlineSuccess: "LPMU is reachable over the network. This check does not confirm its power state.", lpmuOfflineSuccess: "LPMU is unreachable over the network. This does not confirm that it is powered off.", lpmuStartupTimeout: "LPMU is unreachable over the network. This does not confirm that it is powered off.", @@ -691,8 +681,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { requestedSetting: 'Requested setting: {speed}%', outputUnknown: 'Current output unconfirmed', adjustmentFailed: 'Fan adjustment failed', - settingFailed: 'Could not set fan {id} to {speed}%.', - settingUnknown: 'Unable to confirm whether the setting succeeded. Check the current output before making another change.', settingDisabled: 'Fan {id} setting updated. The fan is currently disabled.', actualSpeed: 'Actual Speed', temperature: 'Temperature', @@ -722,9 +710,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { speedSet: 'Fan {id} set to {speed}%.', modeSwitch: 'Fan {id} mode switched to {mode}', setFanFailed: 'Failed to set fan: {msg}', - setFanModeFailed: 'Failed to set fan mode: {msg}', - setFailed: 'Setting failed: {msg}', - clearFailed: 'Clear failed: {msg}' }, // LED Control @@ -765,7 +750,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { touch: 'Touch Buttons', board: 'Board LED', matrix: 'LED Matrix', - selectedFilter: 'Selected: {filter}' }, // Network @@ -844,7 +828,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { dropFilesHere: 'Drop files here or click to select', uploading: 'Uploading...', uploadSuccess: 'Upload Successful', - uploadFailed: 'Upload Failed', folder: 'Folder', file: 'File', emptyFolder: 'Empty Folder', @@ -931,7 +914,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { outputParser: 'Output Parser', testConnection: 'Test Connection', connectionSuccess: 'Connection Successful', - connectionFailed: 'Connection Failed', exportConfig: 'Export Config', importConfig: 'Import Config', selectHost: 'Select Host', @@ -997,7 +979,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { signer: 'Signer', official: 'Official', restartToLoad: 'Load after restart', - cannotVerifyPack: 'Cannot verify config pack', signatureVerified: 'Signature verified', savingConfig: 'Saving config...', sshCommand: 'SSH Command', @@ -1412,9 +1393,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { rememberMe: 'Remember Me', loginButton: 'Login', loggingIn: 'Logging in...', - loginFailed: 'Login Failed', welcomeName: 'Welcome, {name}!', - networkError: 'Network error', sessionExpired: 'Session expired. Please log in again.', passwordChanged: 'Password changed successfully!', invalidCredentials: 'Invalid username or password', @@ -1477,17 +1456,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { fileNotFound: 'File not found', uploadFailed: 'Upload failed', downloadFailed: 'Download failed', - saveFailed: 'Save failed', loadFailed: 'Load failed', - deleteFailed: 'Delete failed', connectionLost: 'Connection lost', reconnecting: 'Reconnecting...', - unknownError: 'Unknown error' }, // Toast Messages toast: { - saved: 'Saved', deleted: 'Deleted', updated: 'Updated', created: 'Created', @@ -1516,27 +1491,20 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { connectionFailed: 'Connection failed', configFailed: 'Config failed', operationFailed: 'Operation failed', - fetchFailed: 'Fetch failed', - scanFailed: 'Scan failed', loadFailed: 'Load failed', - saveFailed: 'Save failed', deleteFailed: 'Delete failed', uploadFailed: 'Upload failed', rootRequired: 'This page requires root access', downloadFailed: 'Download failed', renameFailed: 'Rename failed', createFailed: 'Create failed', - generateFailed: 'Generate failed', executeFailed: 'Execute failed', testFailed: 'Test failed', deployFailed: 'Deploy failed', - importFailed: 'Import failed', verifyFailed: 'Verify failed', installFailed: 'Install failed', - getCertFailed: 'Failed to get certificate', invalidResponseData: 'Invalid response data', fileContentEmpty: 'File content is empty', - exportFailed: 'Export failed', configSaved: 'Config saved', configRestartRequired: 'Takes effect after restart', verifySuccess: 'Verification passed', @@ -1572,51 +1540,22 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { voltageRecovering: 'Voltage recovering: {voltage}V', voltageProtectionDisabled: 'Voltage protection disabled', switchFailed: 'Switch failed', - switchFailedMsg: 'Switch failed: {msg}', - setFailedMsg: 'Setting failed: {msg}', enterHostname: 'Please enter hostname', hostnameSet: 'Hostname set', - connectFailedMsg: 'Connection failed: {msg}', - disconnectFailedMsg: 'Disconnect failed: {msg}', - configFailedMsg: 'Config failed: {msg}', - operationFailedMsg: 'Operation failed: {msg}', - applyFailedMsg: 'Apply failed: {msg}', - saveFailedMsg: 'Save failed: {msg}', - deleteFailedMsg: 'Delete failed: {msg}', - removeFailedMsg: 'Remove failed: {msg}', - exportFailedMsg: 'Export failed: {msg}', - updateFailedMsg: 'Update failed: {msg}', - clearFailedMsg: 'Clear failed: {msg}', - copyFailedMsg: 'Copy failed: {msg}', - upgradeFailedMsg: 'Upgrade failed: {msg}', - webuiUpgradeStartFailed: 'WebUI upgrade start failed', - upgradeStartFailedMsg: 'Upgrade start failed: {msg}', - rollbackFailedMsg: 'Rollback failed: {msg}', - abortFailedMsg: 'Abort failed: {msg}', otaServerNotSet: 'OTA server address not set', firmwareWriteComplete: 'Firmware write complete!', webuiWriteComplete: 'WebUI write complete!', webuiUpgradeSkipped: 'WebUI upgrade skipped: {msg}', nohupStripped: "Duplicate background execution parameters were removed. The system will run this command in the background.", - saveCommandFailedMsg: 'Failed to save command: {msg}', - deleteCommandFailedMsg: 'Failed to delete command: {msg}', - fillServerInfo: 'Please fill in complete server information', fillServerHost: 'Please enter the server address', fillServerUser: 'Please enter the user name', fillServerPassword: 'Please enter the password', publicKeyNotFound: 'Public key not found', - oldHostKeyRemoved: 'Old host key removed, reconnect to trust new key', - allHostsCleared: 'All known hosts cleared', generateFailedMsg: 'Generate failed: {msg}', configPackDownloaded: 'Config pack downloaded: {filename}', selectAtLeastOneAction: 'Please select at least one action template', cmdNotExist: 'Command does not exist, please reselect', - hostFingerprintDeleted: 'Host fingerprint deleted', - keyDeleted: 'Key deleted', - cannotGetPublicKey: 'Cannot get public key', - cannotGetPrivateKey: 'Cannot get private key', keyRevoked: 'Key revoked successfully', - hostRemoved: 'Host removed', configPackCopied: 'Config pack copied to clipboard', // USB switch usbSwitching: 'Switching USB to {target}...', @@ -1629,10 +1568,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { widgetDeleted: 'Deleted {name}', widgetBound: 'Bound {widget} → {var}', // Quick actions / data - agxMovedToBinding: 'AGX config moved to variable binding', - waitBeforeTrigger: 'Please wait a few seconds before triggering another', operationExecuted: 'Operation executed', - execFailed: 'Execution failed', execFailedMsg: 'Execution failed: {msg}', errorMsg: 'Error: {msg}', processRunning: 'Process is running, please stop it first', @@ -1654,23 +1590,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { // Time sync timeSynced: 'Time synced: {datetime}', timeSyncSyncing: 'Syncing time from browser...', - timeSyncFailed: 'Time sync failed', - syncFailed: 'Sync failed', - setFailed: 'Setting failed', - ntpSyncing: 'Forcing NTP sync...', - ntpStarted: 'NTP sync started, refresh to see results', - ntpFailed: 'NTP sync failed', // LED ledFilled: '{device} filled with {color}', - ledFillFailed: 'Fill failed', ledEffectStarted: '{device}: {effect} started', - ledEffectStartFailed: 'Failed to start effect', ledEffectStopped: '{device} effect stopped', - ledEffectStopFailed: 'Failed to stop effect', - ledImageFailed: 'Failed to display image', - ledQrFailed: 'Failed to generate QR code', - ledTextFailed: 'Failed to display text', - ledTextStopFailed: 'Failed to stop text', allLedsOff: 'All LEDs turned off', selectAnimation: 'Please select an animation', enterImagePath: 'Please enter the image path', @@ -1683,17 +1606,11 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { filterStopped: 'Filter stopped', selectFilter: 'Please select a filter', ledBrightnessSet: '{device} brightness: {value}', - ledBrightnessFailed: 'Failed to set {device} brightness', ledTurnedOff: '{device} turned off', ledTurnedOn: '{device} turned on', - ledOnFailed: 'Failed to turn on', - ledOffFailed: 'Failed to turn off', filterApplied: 'Filter {filter} applied', - filterApplyFailed: 'Failed to apply filter', - filterStopFailed: 'Failed to stop filter', ledConfigSaved: '{device} config saved', ledConfigSavedWithAnim: '{device} config saved: {anim}', - ledConfigSaveFailed: 'Failed to save config', enterQrContent: 'Please enter the QR code content', // PKI/Certificates configPackImported: 'Config pack imported successfully', @@ -1711,15 +1628,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { generatingKey: 'Generating key...', terminalDisconnected: 'Terminal disconnected', // Export/Import - exportFailed: 'Export failed', exportSuccess: 'Export successful', - invalidResponse: 'Invalid response data', - exportedSourceConfig: 'Exported data source config: {filename}', exportedCmdWithHost: 'Exported command config (with host {hostId}): {filename}', exportedCmdConfig: 'Exported command config: {filename}', - exportedActionTemplate: 'Exported action template: {filename}', selectFileFirst: 'Please choose the config pack file (.tscfg) to import first', - importedRestartRequired: 'Imported config, restart required', // OTA enterFirmwareUrl: 'Please enter firmware URL', urlMustHttp: 'URL must start with http:// or https://', @@ -1739,43 +1651,17 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { enterOtaServer: 'Please enter OTA server URL first', upgradeFailed: 'Upgrade failed', // Automation - ruleToggled: 'Rule {id} {state}', - ruleTriggered: 'Rule {id} triggered', - toggleRuleFailed: 'Failed to toggle rule', - triggerRuleFailed: 'Failed to trigger rule', - toggleSourceFailed: 'Failed to toggle source status', - sourceToggled: 'Source {id} {state}', - actionExecuting: 'Executing action: {id}...', - actionSuccess: 'Execution successful', - actionFailed: 'Action {id} failed', - actionExecuteFailed: 'Action execution failed', - getActionFailed: 'Failed to get action details', - editActionFailed: 'Failed to edit action', updateFailed: 'Update failed', deleteOldTemplateFailed: 'Failed to delete old template', - deleteActionResult: 'Delete action {id}', - deleteSourceResult: 'Delete source {id}', - deleteSourceFailed: 'Failed to delete source', shutdownSettingsSaved: 'Shutdown settings saved', defaultsRestored: 'Defaults restored', restoreFailed: 'Restore failed', - restoreFailedMsg: 'Restore failed: {msg}', - deleteRuleResult: 'Delete rule {id}', - deleteRuleFailed: 'Failed to delete rule', - getRuleDetailFailed: 'Failed to get rule details', - sourceCreated: 'Source {id} created successfully', - sourceCreateFailed: 'Failed to create source', - createActionFirst: 'Please create action templates first', ruleCreated: 'Rule {id} {action} successfully', ruleCreateFailed: 'Failed to {action} rule', - actionResult: '{action}: {msg}', - actionFailedMsg: '{action} failed: {msg}', ruleUpdate: 'Update', ruleCreate: 'Create', configExported: 'Exported {type} config: {filename}', - ruleConfigExported: 'Exported rule config: {filename}', selectFile: 'Please select a file first', - configImported: 'Config imported, takes effect after restart', // Form validation fillActionId: 'Please enter an action ID', selectActionType: 'Please select action type', @@ -1788,12 +1674,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { fillVarName: 'Enter a variable name', fillVarValue: 'Enter a value for the variable', fillWebhookUrl: 'Please enter the webhook URL', - actionCreated: 'Action template {id} created successfully', - actionCreateFailed: 'Creation failed', // LED config save ledConfigSaved: '{device} config saved', ledConfigSavedWithAnim: '{device} config saved: {animation}', - ledConfigSaveFailed: 'Failed to save config', qrCodeSuccess: 'QR code generated successfully', qrCodeFailed: 'Failed to generate QR code', fontNotFound: 'Font files not found, please upload to /sdcard/fonts', @@ -1801,15 +1684,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { textDisplayFailed: 'Failed to display text', stopFailed: 'Failed to stop', filterApplied: 'Filter applied: {filter}', - filterApplyFailed: 'Failed to apply filter', - filterStopFailed: 'Failed to stop filter', // Network wifiModeChanged: 'WiFi mode changed to {mode}', // File operations - createDirFailed: 'Failed to create directory: {msg}', enterQrContent: 'Please enter QR code content', - enterDisplayText: 'Please enter display text', - textStopped: 'Text stopped', selectFileToDelete: 'Please select file(s) to delete first', selectFileToDownload: 'Please select file(s) to download first', noDownloadableFiles: 'No downloadable files in selection (folders not supported)', @@ -1824,27 +1702,19 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { unmountFailed: 'Unmount failed', enterFolderName: 'Please enter folder name', folderCreated: 'Folder created successfully', - createFailedMsg: 'Create failed: {msg}', enterNewName: 'Please enter new name', renameSuccess: 'Rename successful', - renameFailedMsg: 'Rename failed: {msg}', downloadStart: 'Download started', - downloadFailedMsg: 'Download failed: {msg}', deleteSuccess: 'Deleted successfully', - deleteFailedMsg: 'Delete failed: {msg}', // SSH page selectHostFirst: 'Please select a host first', - fillCommandNameAndCmd: 'Please fill in command name and command', fillCommandName: 'Please enter a name for the command', fillCommandText: 'Please enter the command to run', fillCommandId: 'Please enter a command ID', commandIdInvalid: 'The command ID may only contain letters, numbers, underscores and hyphens, cannot start or end with an underscore or hyphen, and can be at most 31 characters long', serviceModeRequiresPattern: 'Ready pattern is required when service mode is enabled', serviceModeRequiresVar: 'Variable name is required when service mode is enabled', - commandUpdated: 'Command updated', - commandCreated: 'Command created', saveCommandFailed: 'Failed to save command', - commandDeleted: 'Command deleted', deleteCommandFailed: 'Failed to delete command', noLogInfo: 'No log information available', noProcessInfo: 'No process information available', @@ -1854,43 +1724,28 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { serviceNotRunning: 'Service not running', stopServiceFailed: 'Failed to stop service', stopServiceFailedMsg: 'Failed to stop service: {msg}', - commandRunning: 'A command is running, please cancel or wait for completion', startExecFailed: 'Failed to start execution', - startExecFailedMsg: 'Failed to start execution: {msg}', noRunningCommand: 'No running command', cancelSent: 'Cancel request sent', cancelFailed: 'Cancel failed', - cancelFailedMsg: 'Cancel failed: {msg}', patternMatchSuccess: 'Pattern matched successfully', commandSuccess: 'Command executed successfully', commandMatchFailed: 'Command completed, pattern match failed', commandTimeout: 'Command execution timeout', commandCompletedCode: "The command did not complete successfully (exit code: {code}). Check its output.", execError: 'Execution error', - execErrorMsg: 'Execution error: {msg}', commandCancelled: 'Cancellation handled; remote outcome unconfirmed', - hostFingerprintDeleted: 'Host fingerprint deleted', - hostInfoEmpty: "No connection settings are available for this host. Check its settings.", testingConnection: 'Testing connection to {host}...', - cannotGetHostInfo: 'Cannot get host information', connectionSuccess: 'Connection to {host} successful!', - testConnectionFailed: 'Test failed', hostConfigExported: 'Host config exported: {filename}', - sshHostRemoved: 'SSH host {id} removed from list', removeFailed: 'Remove failed', enterPassword: 'Please enter password', publicKeyRevoked: 'Public key revoked and host record removed', localHostRemoved: 'Local host record removed', - keyDeleted: 'Key deleted', - cannotGetPublicKey: 'Cannot get public key', - cannotGetPrivateKey: 'Cannot get private key', downloadedPublicKey: 'Downloaded {id}.pub', downloadedPrivateKey: 'Downloaded {id}', - fillServerInfo: 'Please fill in complete server information', keyDeploySuccess: 'Key deployed successfully', keyRevokeSuccess: 'Key revoked successfully', - oldHostKeyRemoved: 'Old host key removed, reconnect to trust new key', - hostRemoved: 'Host removed', allKnownHostsCleared: 'All known hosts cleared', clearFailed: 'Clear failed', certCopiedToClipboard: 'Certificate copied to clipboard', @@ -1921,8 +1776,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { lpmuOfflineHint: 'LPMU offline (ping 10.10.99.99 unreachable)\nClick to trigger power button', lpmuDetectingHint: 'Detecting LPMU status...\nMax wait 80 seconds', lpmuUnknownHint: 'LPMU status unknown\nClick to trigger power button', - powerOn: 'Power On', - powerOff: 'Power Off', clickToOff: 'Click to power off', clickToOn: 'Click to power on', confirmLpmuTrigger: 'Confirm LPMU power button trigger?\n\nThis will send a pulse signal, similar to pressing the physical power button.', @@ -1947,7 +1800,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { noNetwork: 'No networks found', hiddenNetwork: '(Hidden Network)', connect: 'Connect', - needStaMode: 'Switch to STA or APSTA mode required', enterPassword: 'Enter password for "{ssid}" (leave empty for open network):', loading: 'Loading...', noDevice: 'No connected devices', @@ -2016,7 +1868,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { lpmuAccessResultDone: 'Done', lpmuAccessLog: 'Execution log', lpmuAccessLogEmpty: 'No output was recorded for this attempt.', - lpmuAccessLogFailed: 'Could not load the execution log', lpmuAccessLogInvalidPage: 'Invalid log page or no progress while reading', lpmuAccessLastOutput: 'Last output: {output}', lpmuAccessNoInternet: 'No usable internet connection was found.', @@ -2034,8 +1885,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { lpmuAccessFailed: 'Failed', lpmuAccessError: 'Error', lpmuAccessOutput: 'Output', - lpmuAccessStartFailed: 'Failed to start access', - lpmuAccessStatusFailed: 'Failed to fetch status', lpmuAccessStatusUnknown: 'Status unavailable; execution outcome is not yet confirmed', lpmuAccessRefreshStatus: 'Refresh status', clients: 'Clients', @@ -2086,12 +1935,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { sdCardNotMounted: 'SD Card Not Mounted', mountingSd: 'Mounting SD card...', mountSdSuccess: 'SD card mounted', - mountSdFailed: 'Mount failed', confirmUnmountSd: 'Unmount SD card?\n\nYou will not be able to access files on the SD card after unmounting.', unmountingSd: 'Unmounting SD card...', unmountSdSuccess: 'SD card unmounted', - unmountSdFailed: 'Unmount failed', - loadFailed: 'Load failed', // Batch operations batchDownload: 'Batch Download', batchDelete: 'Batch Delete', @@ -2160,17 +2006,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { // SSH/Commands Page sshPage: { // Service status labels - statusReady: 'Ready', - statusChecking: 'Checking', - statusTimeout: 'Timeout', - statusFailed: 'Failed', - statusIdle: 'Idle', - statusStopped: 'Stopped', statusUnknown: 'Unknown', // Error messages - hostNotFound: 'Host not found', hostNotExistShort: 'Host does not exist', - cmdNotFound: 'Command not found', // Match result labels matchResultTitle: 'Match Results', wsMessageHint: 'WebSocket message fields can be used in handleSshExecMessage(msg) callback', @@ -2195,7 +2033,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { loadingHosts: 'Loading hosts...', cancelEsc: 'Cancel (Esc)', selectKey: '-- Select Key --', - selectHost: '-- Select Host --', selectCommand: '-- Select Command --', selectDevice: '-- Select Device --', noKeysHint: 'No keys yet, click button above to generate', @@ -2203,9 +2040,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { noDeployedHostsMsg: 'No deployed hosts', deployKeyAtSecurityHint: 'Please go to Security page to deploy SSH public key first', noKnownHosts: 'No known host fingerprints', - pleaseConfigSshHost: '-- Please configure SSH host first --', noVariableData: 'No variable data for this command, please execute it first', - getVarFailed: 'Failed to get variables', // Variable table headers varTableName: 'Variable Name', varTableType: 'Type', @@ -2257,12 +2092,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { stopServiceFailed: 'Failed to stop service', executionFailed: 'Execution failed', getLogFailed: 'Failed to get log', - getLogFailedMsg: 'Failed to fetch log: {msg}', startRealTimeTail: 'Starting real-time tail: {logFile}\n(Click "Stop Tracking" to exit)', viewServiceLogFile: 'View service log: {name}\nFile: {file}', logErrorMsg: '[Error] {msg}', stopServiceName: 'Stop service: {name}', - startExecFailedDetail: 'Failed to start execution\n\n{msg}', patternMatchSuccess: 'Pattern Match Success!', expectPatternMatch: 'Expect pattern matched: Yes', failPatternMatch: 'Fail pattern matched: Yes', @@ -2467,7 +2300,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { csrGenerateTitle: 'Generate Certificate Signing Request', genHttpsKeyDescFull: 'Generate ECDSA P-256 key pair for mTLS authentication.', mismatchTitle: 'Security Warning: Host Key Mismatch!', - mismatchAdvice: 'Suggestion: If you confirm the server was reinstalled or key was updated, click "Update Host Key" to remove old record, then reconnect to trust new key.', packExportPick: 'Select files to export', dirShort: 'Directory', recipientCert: 'Recipient device certificate', @@ -2685,10 +2517,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { packListTitle: 'Config Pack List', packListDirPath: 'Directory Path', noTscfgInDir: 'No .tscfg files in directory', - packListLoadFailed: 'Load failed', confirmImportPack: 'Import config pack: {path}?', importPackSuccess: 'Config pack imported', - importPackFailed: 'Import failed', deviceType: 'Device Type', certCN: 'Certificate CN', formatVersion: 'Format Version', @@ -2823,7 +2653,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { revokedAndRemoved: 'Revoked public key and removed host record', keyNotFoundOnServer: 'No matching public key found on server (may have been removed)\nRemove local record anyway?', removedLocalRecord: 'Removed local host record', - revokeFailed: 'Revoke failed', // Known hosts viewFullFingerprint: 'View full fingerprint', deleteFingerprint: 'Delete fingerprint record' @@ -2837,7 +2666,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { newVersionFound: 'New Version Found', serverVersionOlder: 'Server Version is Older', alreadyLatest: 'Already Latest Version', - checkUpdateFailed: 'Check Update Failed', upgradeNow: 'Upgrade Now', downgrade: 'Downgrade', uploadFailed: 'Upload failed', @@ -2897,7 +2725,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { patchUpdate: 'Patch Update', // Upgrade process serverNotSet: 'OTA server address not set', - uploadFirmwareFailed: 'Failed to upload firmware', firmwareWriteComplete: 'Firmware write complete!', uploadingFirmware: 'Uploading firmware to device...', uploadingWebUI: 'Uploading WebUI to device...', @@ -2984,8 +2811,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { requestBodyHint: 'JSON format, supports variables', getMemoryDetailFailed: 'Failed to get memory details', // Logs (quick action log tail) - logFetchFailed: '[Fetch failed]', - logError: '[Error]', deviceBusyRetry: 'Device may be busy, please retry later.', logEmptyBracket: '[Empty]', logFileEmptyBracket: '[Log file does not exist or is empty]', @@ -2993,22 +2818,16 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { logEmpty: 'No logs', noRules: 'No rules yet, click "Add" to create', noSources: 'No data sources yet, click "Add" to create', - noVariables: 'No variable data', noActions: 'No action templates, click "Add" to create', saveRuleChanges: 'Save changes', - getRulesFailed: 'Failed to get rules', - getSourcesFailed: 'Failed to get data sources', getVariablesFailed: 'Failed to get variables', - getStatusFailed: 'Failed to get status', selectActionType: 'Please select action type', - networkFailed: 'Network connection failed', loadingVariables: 'Loading...', loadingOptions: '-- Loading --', sourceNoData: 'No variable data for this source', configSourceFirst: 'Please configure and enable data source first', testRequesting: 'Requesting...', testSuccess: 'Connection successful', - testFailed: 'Request failed', testError: 'Error', testConnecting: 'Connecting...', testConnected: 'Connected, data received', @@ -3057,10 +2876,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { method: 'Method', requestBody: 'Request Body', indexPlaceholder: 'Index', - pulseMsPlaceholder: 'Pulse ms', - varNamePlaceholder: 'Var name', - jsonValuePlaceholder: 'Value (JSON)', - logMessagePlaceholder: 'Log message', browseFilesTitle: 'Browse Files', insertVariableTitle: 'Insert Variable', imagePathHint: 'Supports PNG, JPG, BMP, GIF. Path supports variables', @@ -3098,7 +2913,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { // Quick actions noQuickActions: 'No quick actions', quickActionsHint: "No rule has “Show on dashboard” enabled. Create a rule on the Automation page, or edit an existing rule and enable “Show on dashboard”.", - loadQuickActionsFailed: 'Failed to load quick actions', processRunning: 'Process running', processNotRunning: 'Process not running', viewLog: 'View Log', @@ -3276,7 +3090,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { noLogVariable: 'No log variable configured', invalidExpression: 'Invalid variable expression', variableNoData: 'Variable does not exist or has no data', - readFailed: 'Read failed', noLogs: 'No logs', cleared: 'Cleared', noWidgets: 'No widgets', @@ -3333,21 +3146,16 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { minCurvePoints: 'At least 2 curve points required', selectVarToBind: 'Please select a variable to bind', tempBoundToVar: 'Temperature bound to variable: {var}', - bindFailed: 'Bind failed', unbindSuccess: 'Temperature variable unbound', - unbindFailed: 'Unbind failed', dutyOrderError: 'Min duty cannot be greater than max duty', hysteresisRangeError: "The temperature change required to adjust fan speed (hysteresis) must be 0–20°C.", intervalRangeError: "The minimum time between fan speed adjustments must be 0.5–30 seconds.", curveApplied: 'Fan {id} curve applied and saved', - applyCurveFailed: 'Apply curve failed', curveExported: 'Fan {id} curve exported (local + SD {path})', - curveExportSdFailed: 'Fan {id} curve saved locally, SD save failed: {msg}', invalidConfigFormat: 'Invalid config file format', invalidCurvePoints: 'Invalid curve points in config', curvePointFormatError: 'Curve point format error', configImported: 'Config imported: {name}', - importConfigFailed: 'Import config failed', // Weighted temp binding addVariable: 'Add Variable', weight: 'Weight', @@ -3749,7 +3557,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { statusActivated: 'Activated', statusExpired: 'Expired', statusError: 'Error', - statusLoadFailed: 'Load Failed', deviceType: '{type} Device', certValid: 'Valid', certInvalid: 'Invalid', @@ -3761,11 +3568,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { ecdsaKeyPairSuccess: 'ECDSA P-256 key pair generated successfully!', installingCert: 'Installing certificate...', certInstalledSuccess: 'Certificate installed successfully!', - installFailed: 'Install failed', - installFailedMsg: 'Install failed: {msg}', - deployFailedMsg: 'Deploy failed: {msg}', - revokeFailedMsg: 'Revoke failed: {msg}', - invalidJsonConfigMsg: 'Config file is not valid JSON: {msg}', // Config Pack enterConfigName: 'Please enter config name', selectConfigFile: 'Please select config file', @@ -3776,12 +3578,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { savedToDevice: 'Saved to device', installingCaCert: 'Installing CA certificate chain...', caCertInstalledSuccess: 'CA certificate chain installed successfully!', - loadFailedMsg: 'Load failed: {msg}', generationFailed: 'Generation failed', - generationFailedMsg: 'Generation failed: {msg}', verifying: 'Verifying...', - verifyFailed: 'Verification failed', - signatureInvalid: 'Invalid signature', importing: 'Importing...', importFailed: 'Import failed', uploadOrPasteContent: 'Please upload file or paste config pack content' @@ -3826,10 +3624,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { lightOff: 'Off', turnOff: 'Off', turnOn: 'On', - colorSettings: 'Color Settings', - turnOffLight: 'Turn Off', - turnOnLight: 'Turn On', - noFilterSelected: 'No filter selected', // OTA firmwareUpgradeComplete: 'Firmware upgrade complete, preparing WebUI upgrade...', allUpgradeComplete: 'All upgrades complete', @@ -3943,11 +3737,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { confirmStopService: 'Are you sure you want to stop service "{name}"?', confirmDeleteFingerprint: 'Are you sure you want to delete the fingerprint for {host}:{port}?\n\nThe server fingerprint will be re-verified on next connection.', confirmRemoveHostLocal: 'Are you sure you want to remove host "{id}" from the list?\n\nNote: This only removes the local record, not the public key deployed on the server. To revoke the key, use the "Revoke" button.', - confirmRemoveHostLocal2: 'Are you sure you want to remove host "{id}" from the list?\n\nNote: This only removes the local record, not the public key deployed on the server. To revoke the key, use the "Revoke" function in Key Management.', confirmDeleteKey: 'Are you sure you want to delete key "{id}"? This action cannot be undone!', confirmExportPrivateKey: 'Security Warning\n\nYou are about to export private key "{id}".\n\nPrivate keys are highly sensitive credentials. Please ensure:\n• Do not transmit over public networks\n• Do not share with others\n• Store securely locally\n\nAre you sure you want to continue?', - confirmRemoveKnownHost: 'Are you sure you want to remove the record for "{host}:{port}"?', - confirmClearKnownHosts: 'Are you sure you want to clear all known host records? This action cannot be undone!', confirmDeletePKI: 'Are you sure you want to delete all PKI credentials?\n\nThis will delete:\n• Private key\n• Device certificate\n• CA certificate chain\n\nThis action cannot be undone!', confirmMarkFirmwareValid: 'Confirm marking current firmware as valid?\nThis will disable automatic rollback protection.', confirmRollback: 'Confirm rollback to previous firmware version?\n\nThe system will immediately reboot and load the firmware from the previous partition.\nPlease ensure the previous firmware is available!', @@ -4120,7 +3911,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { runtimeStats: 'Runtime Statistics', updateTime: 'Updated: ', loadFailed: 'Failed to load memory detail', - getDataFailed: 'Failed to get data', staticDataBss: '.data + .bss', iramTitle: 'IRAM (Instruction Memory)', rtcUsed: 'Used', @@ -4194,30 +3984,21 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, ops: { generic: { failed: 'The operation failed', unknown: 'The result of the operation is unconfirmed' }, - network: { lpmuaccess: { failed: 'Failed to start the LPMU access setup', unknown: 'The result of starting the LPMU access setup is unconfirmed' }, lpmuaccessstatus: { failed: 'Failed to read the LPMU access status' } }, - wifi: { scan: { failed: 'Failed to scan for WiFi networks' } }, + system: { agxpower: { failed: 'Failed to switch the AGX power', unknown: 'The result of switching the AGX power is unconfirmed' }, lpmupower: { failed: 'Failed to trigger the LPMU power action', unknown: 'The result of triggering the LPMU power action is unconfirmed' }, protection: { failed: 'Failed to switch voltage protection', unknown: 'The result of switching voltage protection is unconfirmed' }, usbmux: { failed: 'Failed to switch the USB channel', unknown: 'The result of switching the USB channel is unconfirmed' }, resetshutdown: { failed: 'Failed to restore the default shutdown settings', unknown: 'The result of restoring the default shutdown settings is unconfirmed' }, saveshutdown: { failed: 'Failed to save the shutdown settings', unknown: 'The result of saving the shutdown settings is unconfirmed' }, timezone: { failed: 'Failed to set the time zone', unknown: 'The result of setting the time zone is unconfirmed' }, hostname: { failed: 'Failed to set the host name', unknown: 'The result of setting the host name is unconfirmed' }, timesync: { failed: 'Failed to sync the device time', unknown: 'The result of syncing the device time is unconfirmed' } }, + network: { lpmuaccess: { failed: 'Failed to start the LPMU access setup', unknown: 'The result of starting the LPMU access setup is unconfirmed' }, lpmuaccessstatus: { failed: 'Failed to read the LPMU access status' }, nattoggle: { failed: 'Failed to switch NAT', unknown: 'The result of switching NAT is unconfirmed' }, savenat: { failed: 'Failed to save the NAT config', unknown: 'The result of saving the NAT config is unconfirmed' }, dhcpclients: { failed: 'Failed to read the DHCP client list' }, lpmulog: { failed: 'Failed to read the LPMU access log' } }, + wifi: { scan: { failed: 'Failed to scan for WiFi networks' }, applyap: { failed: 'Failed to apply the hotspot settings', unknown: 'The result of applying the hotspot settings is unconfirmed' }, connect: { failed: 'Failed to connect to WiFi', unknown: 'The result of connecting to WiFi is unconfirmed' }, disconnect: { failed: 'Failed to disconnect from WiFi', unknown: 'The result of disconnecting from WiFi is unconfirmed' }, mode: { failed: 'Failed to change the WiFi mode', unknown: 'The result of changing the WiFi mode is unconfirmed' }, stations: { failed: 'Failed to read the list of connected devices' } }, cert: { status: { failed: 'Failed to refresh the certificate status' }, timesync: { failed: 'Failed to sync the device time', unknown: 'The result of syncing the device time is unconfirmed' }, keypair: { failed: 'Failed to generate the key pair', unknown: 'The result of generating the key pair is unconfirmed' }, csr: { failed: 'Failed to generate the certificate request (CSR)' }, install: { failed: 'Failed to install the certificate', unknown: 'The result of installing the certificate is unconfirmed' }, installca: { failed: 'Failed to install the CA chain', unknown: 'The result of installing the CA chain is unconfirmed' }, view: { failed: 'Failed to read the device certificate' }, delete: { failed: 'Failed to delete the PKI credentials', unknown: 'The result of deleting the PKI credentials is unconfirmed' } }, account: { login: { failed: 'Login failed' }, changepassword: { failed: 'Failed to change the password', unknown: 'The result of changing the password is unconfirmed' }, setroot: { failed: 'Failed to set the root password', unknown: 'The result of setting the root password is unconfirmed' }, setadmin: { failed: 'Failed to set the admin password', unknown: 'The result of setting the admin password is unconfirmed' }, resetadmin: { failed: 'Failed to reset the admin password', unknown: 'The result of resetting the admin password is unconfirmed' } }, - ota: { upgrade: { failed: 'The upgrade failed', unknown: 'The result of the upgrade is unconfirmed' }, startwww: { failed: 'The WebUI upgrade could not be started', unknown: 'The result of starting the WebUI upgrade is unconfirmed' }, check: { failed: 'Failed to check for updates' }, start: { failed: 'Failed to start the firmware upgrade', unknown: 'The result of starting the firmware upgrade is unconfirmed' }, upgradewww: { failed: 'The WebUI upgrade failed (the firmware was already written)', unknown: 'The result of the WebUI upgrade is unconfirmed (the firmware was already written)' }, path: { failed: 'Could not find the file to upgrade from' } }, + ota: { upgrade: { failed: 'The upgrade failed', unknown: 'The result of the upgrade is unconfirmed' }, startwww: { failed: 'The WebUI upgrade could not be started', unknown: 'The result of starting the WebUI upgrade is unconfirmed' }, check: { failed: 'Failed to check for updates' }, start: { failed: 'Failed to start the firmware upgrade', unknown: 'The result of starting the firmware upgrade is unconfirmed' }, upgradewww: { failed: 'The WebUI upgrade failed (the firmware was already written)', unknown: 'The result of the WebUI upgrade is unconfirmed (the firmware was already written)' }, path: { failed: 'Could not start the upgrade from the SD card' }, abort: { failed: 'Failed to abort the upgrade', unknown: 'The result of aborting the upgrade is unconfirmed' }, validate: { failed: 'Failed to mark the firmware as valid', unknown: 'The result of marking the firmware as valid is unconfirmed' }, rollback: { failed: 'Failed to roll back the firmware', unknown: 'The result of rolling back the firmware is unconfirmed' }, saveserver: { failed: 'Failed to save the OTA server address', unknown: 'The result of saving the OTA server address is unconfirmed' } }, device: { reboot: { failed: 'Failed to restart the device', unknown: 'The result of restarting the device is unconfirmed' } }, - led: { list: { failed: 'Failed to read the LED device list' }, clear: { failed: 'Failed to turn off the LED', unknown: 'The result of turning off the LED is unconfirmed' } }, + led: { list: { failed: 'Failed to read the LED device list' }, clear: { failed: 'Failed to turn off the LED', unknown: 'The result of turning off the LED is unconfirmed' }, ccapply: { failed: 'Failed to apply the color correction', unknown: 'The result of applying the color correction is unconfirmed' }, ccreset: { failed: 'Failed to reset the color correction', unknown: 'The result of resetting the color correction is unconfirmed' }, ccimport: { failed: 'Failed to import the color correction config', unknown: 'The result of importing the color correction config is unconfirmed' }, filterapply: { failed: 'Failed to apply the filter', unknown: 'The result of applying the filter is unconfirmed' }, filterstop: { failed: 'Failed to stop the filter', unknown: 'The result of stopping the filter is unconfirmed' }, brightness: { failed: 'Failed to set the brightness of {device}', unknown: 'The result of setting the brightness of {device} is unconfirmed' }, saveconfig: { failed: 'Failed to save the LED config', unknown: 'The result of saving the LED config is unconfirmed' }, starteffect: { failed: 'Failed to start the LED animation', unknown: 'The result of starting the LED animation is unconfirmed' }, stopeffect: { failed: 'Failed to stop the LED animation', unknown: 'The result of stopping the LED animation is unconfirmed' }, fill: { failed: 'Failed to fill the LED with a color', unknown: 'The result of filling the LED with a color is unconfirmed' }, image: { failed: 'Failed to display the image on the LED', unknown: 'The result of displaying the image on the LED is unconfirmed' }, qr: { failed: 'Failed to show the QR code on the LED', unknown: 'The result of showing the QR code on the LED is unconfirmed' }, text: { failed: 'Failed to show the text on the LED', unknown: 'The result of showing the text on the LED is unconfirmed' }, stoptext: { failed: 'Failed to stop the text on the LED', unknown: 'The result of stopping the text on the LED is unconfirmed' }, toggle: { failed: 'Failed to switch the LED on or off', unknown: 'The result of switching the LED on or off is unconfirmed' } }, home: { quicklist: { failed: 'Failed to read the quick actions' }, quicklog: { failed: 'Failed to read the run log' } }, - file: { upload: { failed: 'Failed to upload the file', unknown: 'The result of uploading the file is unconfirmed' }, delete: { failed: 'Failed to delete', unknown: 'The result of the deletion is unconfirmed' }, download: { failed: 'Failed to download' } }, + file: { upload: { failed: 'Failed to upload the file', unknown: 'The result of uploading the file is unconfirmed' }, mountsd: { failed: 'Failed to mount the SD card', unknown: 'The result of mounting the SD card is unconfirmed' }, unmountsd: { failed: 'Failed to unmount the SD card', unknown: 'The result of unmounting the SD card is unconfirmed' }, createdir: { failed: 'Failed to create the folder', unknown: 'The result of creating the folder is unconfirmed' }, rename: { failed: 'Failed to rename the file or folder', unknown: 'The result of renaming the file or folder is unconfirmed' }, delete: { failed: 'Failed to delete the file or folder', unknown: 'The result of deleting the file or folder is unconfirmed' }, download: { failed: 'Failed to download the file' }, list: { failed: 'Failed to read the folder contents' } }, memory: { load: { failed: 'Failed to read the memory details' } }, automationpage: { logfetchfailed: { failed: '[Fetch failed]' } }, - common: { loadfailed: { failed: 'Load Failed' }, loadfailedmsg: { failed: 'Load failed' } }, - errors: { savefailed: { failed: 'Save failed' } }, - fan: { clearfailed: { failed: 'Clear failed' }, setfailed: { failed: 'Setting failed' }, setfanmodefailed: { failed: 'Failed to set fan mode' }, setspeed: { failed: 'Failed to set the speed of fan {id}', unknown: 'The result of setting the speed of fan {id} is unconfirmed' } }, - fanpage: { applycurvefailed: { failed: 'Apply curve failed' }, bindfailed: { failed: 'Bind failed' }, curveexportsdfailed: { failed: 'Fan {id} curve saved locally, SD save failed' }, importconfigfailed: { failed: 'Import config failed' }, unbindfailed: { failed: 'Unbind failed' }, readlog: { failed: 'Failed to read the log variable' } }, - filepage: { mountsdfailed: { failed: 'Mount failed' }, unmountsdfailed: { failed: 'Unmount failed' } }, - ledpage: { ccapplyfailed: { failed: 'Failed to apply color correction' }, ccexportfailed: { failed: 'Failed to export config' }, ccimportfailed: { failed: 'Failed to import config' }, ccresetfailed: { failed: 'Failed to reset color correction' } }, - login: { networkerror: { failed: 'Network error' } }, - networkpage: { lpmuaccesslogfailed: { failed: 'Could not load the execution log' } }, - pkipage: { generationfailedmsg: { failed: 'Generation failed' }, loadfailedmsg: { failed: 'Load failed' }, verifyfailed: { failed: 'Verification failed' } }, - promptrepair: { listrefreshfailed: { failed: 'The file list could not be refreshed. Refresh it manually. Confirmed upload results are unchanged' }, otapathfailed: { failed: 'The SD card upgrade file could not be read. Check its path' }, startfailed: { failed: 'The operation could not be started' } }, - securitypage: { importpackfailed: { failed: 'Import failed' }, packlistloadfailed: { failed: 'Load failed' } }, - system: { agxpowerfail: { failed: 'AGX operation failed' }, lpmutriggerfail: { failed: 'LPMU trigger failed' }, switchfailed: { failed: 'Switch failed' } }, - toast: { abortfailedmsg: { failed: 'Abort failed' }, configfailedmsg: { failed: 'Config failed' }, connectfailedmsg: { failed: 'Connection failed' }, createdirfailed: { failed: 'Failed to create directory' }, createfailedmsg: { failed: 'Create failed' }, deletefailedmsg: { failed: 'Delete failed' }, disconnectfailedmsg: { failed: 'Disconnect failed' }, downloadfailedmsg: { failed: 'Download failed' }, fetchfailed: { failed: 'Fetch failed' }, filterapplyfailed: { failed: 'Failed to apply filter' }, filterstopfailed: { failed: 'Failed to stop filter' }, importfailed: { failed: 'Import failed' }, ledbrightnessfailed: { failed: 'Failed to set {device} brightness' }, ledconfigsavefailed: { failed: 'Failed to save config' }, ledeffectstartfailed: { failed: 'Failed to start effect' }, ledeffectstopfailed: { failed: 'Failed to stop effect' }, ledfillfailed: { failed: 'Fill failed' }, ledimagefailed: { failed: 'Failed to display image' }, ledqrfailed: { failed: 'Failed to generate QR code' }, ledtextfailed: { failed: 'Failed to display text' }, ledtextstopfailed: { failed: 'Failed to stop text' }, operationfailedmsg: { failed: 'Operation failed' }, renamefailedmsg: { failed: 'Rename failed' }, restorefailedmsg: { failed: 'Restore failed' }, rollbackfailedmsg: { failed: 'Rollback failed' }, savefailed: { failed: 'Save failed' }, savefailedmsg: { failed: 'Save failed' }, setfailed: { failed: 'Setting failed' }, setfailedmsg: { failed: 'Setting failed' }, switchfailedmsg: { failed: 'Switch failed' }, syncfailed: { failed: 'Sync failed' }, upgradestartfailedmsg: { failed: 'Upgrade start failed' } }, + fan: { setspeed: { failed: 'Failed to set the speed of fan {id}', unknown: 'The result of setting the speed of fan {id} is unconfirmed' }, cleartest: { failed: 'Failed to clear the test temperature', unknown: 'The result of clearing the test temperature is unconfirmed' }, settest: { failed: 'Failed to set the test temperature', unknown: 'The result of setting the test temperature is unconfirmed' }, setmode: { failed: 'Failed to set the fan mode', unknown: 'The result of setting the fan mode is unconfirmed' } }, + fanpage: { curveexportsdfailed: { failed: 'Fan {id} curve saved locally, SD save failed' }, readlog: { failed: 'Failed to read the log variable' }, applycurve: { failed: 'Failed to apply the fan curve', unknown: 'The result of applying the fan curve is unconfirmed' }, bind: { failed: 'Failed to bind the temperature variables', unknown: 'The result of binding the temperature variables is unconfirmed' }, unbind: { failed: 'Failed to unbind the temperature variables', unknown: 'The result of unbinding the temperature variables is unconfirmed' }, importcurve: { failed: 'Failed to import the fan curve config', unknown: 'The result of importing the fan curve config is unconfirmed' } }, + ledpage: { ccexportfailed: { failed: 'Failed to export config' } }, // -- Lists read by several pages -- image: { list: { failed: 'Failed to read the image list' } }, variable: { list: { failed: 'Failed to read the variable list' } }, @@ -4258,6 +4039,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { generate: { failed: 'Failed to generate the config pack', unknown: 'The result of generating the config pack is unconfirmed' }, importtext: { failed: 'Failed to import the config pack', unknown: 'The result of importing the config pack is unconfirmed' }, certload: { failed: 'Failed to read the device certificate' }, + list: { failed: 'Failed to read the config pack list' }, import: { failed: 'Failed to import the config pack “{file}”', unknown: 'The result of importing the config pack “{file}” is unconfirmed', done: 'Imported the config pack “{file}”. It takes effect after a restart.' } }, // -- Automation -- @@ -4359,7 +4141,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { password_length: { why: 'the new password must be 4 to 64 characters long', hint: 'Choose another password.' }, old_password_wrong: { why: 'the current password is wrong', hint: 'Check it and try again.' }, root_required: { why: 'this action needs the root account', hint: 'Log in as root and try again.' }, - ota_failed: { why: 'the upgrade did not succeed', hint: 'Click Details to see the reason the device gave.' }, + ota_failed: { why: 'the device reported an error during the upgrade', hint: 'Click Details to see the reason the device gave.' }, ota_no_rollback: { why: 'there is no older version to roll back to', hint: 'An older version only exists after an upgrade.' }, ota_server_not_set: { why: 'no OTA server address has been set', hint: 'Save a server address on the OTA page first, or enter a full address.' }, url_invalid: { why: 'the address is not valid, or it is too long', hint: 'Enter a complete, valid address.' }, @@ -4410,6 +4192,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { rule_pending_delete: { why: 'this rule has been deleted and is removed after a restart', hint: 'It cannot run in the meantime. It disappears once the device restarts.' }, rule_not_active: { why: 'this rule has been saved but only runs after a restart', hint: 'Restart the device, then run it.' }, action_missing: { why: 'the action template “{template}” no longer exists; it may have been deleted', hint: 'Choose the action template again, then save.' }, + action_missing_unnamed: { why: 'the chosen action template no longer exists; it may have been deleted', hint: 'Choose the action template again, then save.' }, template_lookup_failed: { why: 'the device could not confirm that the chosen action template still exists', hint: 'Try again later. If it keeps failing, open Terminal → System log for the cause.' }, source_unavailable: { why: 'the SD card needed to save the rule is not available', hint: 'Make sure the SD card is inserted and mounted.' }, action_unsupported: { why: 'the pack uses an action this firmware does not support', hint: 'Upgrade the device firmware to a newer version.' }, @@ -4442,7 +4225,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { pack_not_ready: { why: 'the config pack feature is not initialized yet', hint: 'Wait a few seconds and try again.' }, pack_verify_failed: { why: 'the config pack could not be verified', hint: 'Make sure the file is complete and was exported from TianshanOS.' }, device_cert_unavailable: { why: 'the device certificate is not available', hint: 'Check the device certificate on the Security page first.' }, - rule_import_unavailable: { why: 'a rule config pack can only be verified for now, not imported', hint: 'Create the rule by hand on the Automation page.' }, // -- Automation: engine, action templates, data sources -- engine_unavailable: { why: 'the automation engine is stopping, still busy with actions, or not initialized yet', hint: 'Wait a few seconds and try again.' }, engine_paused: { why: 'the automation engine is paused', hint: 'To resume it, click Stop first, then Start.' }, diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index fea8e75..bf48389 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -379,7 +379,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { run: '运行', stop: '停止', start: '启动', - restart: '重启', pause: '暂停', reload: '重载', trigger: '触发', @@ -474,13 +473,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { valid: '有效', invalid: '无效', // GPIO - high: '高电平', - low: '低电平', host: '主机', device: '设备', logs: '日志', - loadFailed: '加载失败', - loadFailedMsg: '加载失败: {msg}', refreshOnce: '刷新一次', uploadFailedMsg: '失败: {msg}', copyToClipboard: '复制到剪贴板', @@ -515,7 +510,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { commands: '指令', home: '首页', system: '系统', - led: 'LED 控制', network: '网络', files: '文件', terminal: '终端', @@ -559,7 +553,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { stage: '阶段', health: '健康', toggleProtectionTitle: '点击切换保护状态', - switchFailed: '切换失败', usbMuxNotConfigured: 'USB MUX 未配置', usbSwitchTo: '切换 USB 到 {name}...', usbSwitchedTo: 'USB 已切换到 {name}', @@ -652,7 +645,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { rebootConfirm: '确定要重启系统吗?', rebootSending: '正在发送重启命令...', rebootingPleaseWait: '系统正在重启,请稍候...', - rebootFailed: '重启失败', agxPowerOnTitle: '点击开启 AGX 电源', agxPowerOffTitle: '点击关闭 AGX 电源', agxPowerOn: '上电', @@ -661,7 +653,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { agxPoweringOff: 'AGX 断电中...', agxPowerOnSuccess: 'AGX 已上电', agxPowerOffSuccess: 'AGX 已断电', - agxPowerFail: 'AGX 操作失败', lpmuRunning: "网络可达", lpmuStopped: "网络不可达", lpmuOnlineTitle: "LPMU 网络可达;此检测不能确认电源状态。", @@ -672,7 +663,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { lpmuTriggerConfirm: '确定要触发 LPMU 电源按钮吗?\n\n这将发送一个脉冲信号,效果类似按物理电源按钮。', lpmuTriggering: 'LPMU 电源触发中...', lpmuTriggerSuccess: 'LPMU 电源已触发,开始检测状态...', - lpmuTriggerFail: 'LPMU 触发失败', lpmuOnlineSuccess: "LPMU 网络可达;此检测不能确认电源状态。", lpmuOfflineSuccess: "LPMU 网络不可达,无法据此确认是否已关机。", lpmuStartupTimeout: "LPMU 网络不可达,无法据此确认是否已关机。", @@ -691,8 +681,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { requestedSetting: '请求设置:{speed}%', outputUnknown: '当前输出尚未确认', adjustmentFailed: '未能完成风扇调节', - settingFailed: '风扇{id}未能改为{speed}%。', - settingUnknown: '暂时无法确认是否设置成功。请先查看当前输出。', settingDisabled: '风扇{id}设置已更新,风扇当前处于停用状态。', actualSpeed: '实际转速', temperature: '温度', @@ -722,9 +710,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { speedSet: '风扇{id}已设为{speed}%。', modeSwitch: '风扇 {id} 模式已切换为 {mode}', setFanFailed: '设置风扇失败: {msg}', - setFanModeFailed: '设置风扇模式失败: {msg}', - setFailed: '设置失败: {msg}', - clearFailed: '清除失败: {msg}' }, // LED 控制 @@ -765,7 +750,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { touch: '触摸按钮', board: '板载 LED', matrix: 'LED 矩阵', - selectedFilter: '已选择: {filter}' }, // 网络 @@ -844,7 +828,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { dropFilesHere: '拖放文件到此处或点击选择', uploading: '上传中...', uploadSuccess: '上传成功', - uploadFailed: '上传失败', folder: '文件夹', file: '文件', emptyFolder: '空文件夹', @@ -923,7 +906,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { outputParser: '输出解析器', testConnection: '测试连接', connectionSuccess: '连接成功', - connectionFailed: '连接失败', exportConfig: '导出配置', importConfig: '导入配置', selectHost: '选择主机', @@ -961,7 +943,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { restartToLoad: '重启后自动加载', configExistsOverwrite: '该配置已存在,导入将覆盖现有文件', signatureVerified: '签名验证通过', - cannotVerifyPack: '无法验证配置包', savingConfig: '正在保存配置...', configExistsCheckOverwrite: '配置 {id} 已存在,请勾选「覆盖」选项', savedConfig: '已保存配置', @@ -1370,9 +1351,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { rememberMe: '记住我', loginButton: '登录', loggingIn: '登录中...', - loginFailed: '登录失败', welcomeName: '欢迎, {name}!', - networkError: '网络错误', sessionExpired: '登录已过期,请重新登录', passwordChanged: '密码修改成功!', invalidCredentials: '用户名或密码错误', @@ -1435,17 +1414,13 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { fileNotFound: '文件不存在', uploadFailed: '上传失败', downloadFailed: '下载失败', - saveFailed: '保存失败', loadFailed: '加载失败', - deleteFailed: '删除失败', connectionLost: '连接已断开', reconnecting: '正在重连...', - unknownError: '未知错误' }, // Toast 消息 toast: { - saved: '已保存', deleted: '已删除', updated: '已更新', created: '已创建', @@ -1474,10 +1449,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { connectionFailed: '连接失败', configFailed: '配置失败', operationFailed: '操作失败', - fetchFailed: '获取失败', - scanFailed: '扫描失败', loadFailed: '加载失败', - saveFailed: '保存失败', deleteFailed: '删除失败', uploadFailed: '上传失败', rootRequired: '此页面需要 root 权限', @@ -1494,51 +1466,22 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { voltageRecovering: '电压恢复中: {voltage}V', voltageProtectionDisabled: '电压保护已禁用', switchFailed: '切换失败', - switchFailedMsg: '切换失败: {msg}', - setFailedMsg: '设置失败: {msg}', enterHostname: '请输入主机名', hostnameSet: '主机名已设置', - connectFailedMsg: '连接失败: {msg}', - disconnectFailedMsg: '断开失败: {msg}', - configFailedMsg: '配置失败: {msg}', - operationFailedMsg: '操作失败: {msg}', - applyFailedMsg: '应用失败: {msg}', - saveFailedMsg: '保存失败: {msg}', - deleteFailedMsg: '删除失败: {msg}', - removeFailedMsg: '移除失败: {msg}', - exportFailedMsg: '导出失败: {msg}', - updateFailedMsg: '更新失败: {msg}', - clearFailedMsg: '清除失败: {msg}', - copyFailedMsg: '复制失败: {msg}', - upgradeFailedMsg: '升级失败: {msg}', - webuiUpgradeStartFailed: 'WebUI 升级启动失败', - upgradeStartFailedMsg: '启动升级失败: {msg}', - rollbackFailedMsg: '回滚失败: {msg}', - abortFailedMsg: '中止失败: {msg}', otaServerNotSet: 'OTA 服务器地址未设置', firmwareWriteComplete: '固件写入完成!', webuiWriteComplete: 'WebUI 写入完成!', webuiUpgradeSkipped: 'WebUI 升级跳过: {msg}', nohupStripped: "已移除重复的后台运行参数,系统会自动在后台执行此命令。", - saveCommandFailedMsg: '保存指令失败: {msg}', - deleteCommandFailedMsg: '删除指令失败: {msg}', - fillServerInfo: '请填写完整的服务器信息', fillServerHost: '请填写服务器地址', fillServerUser: '请填写用户名', fillServerPassword: '请填写密码', publicKeyNotFound: '公钥未找到', - oldHostKeyRemoved: '旧主机密钥已移除,请重新连接以信任新密钥', - allHostsCleared: '已清除所有已知主机', generateFailedMsg: '生成失败: {msg}', configPackDownloaded: '配置包已下载: {filename}', selectAtLeastOneAction: '请至少选择一个动作模板', cmdNotExist: '指令不存在,请重新选择', - hostFingerprintDeleted: '已删除主机指纹', - keyDeleted: '密钥已删除', - cannotGetPublicKey: '无法获取公钥', - cannotGetPrivateKey: '无法获取私钥', keyRevoked: '密钥撤销成功', - hostRemoved: '主机已移除', configPackCopied: '配置包已复制到剪贴板', // USB 切换 usbSwitching: '切换 USB 到 {target}...', @@ -1551,10 +1494,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { widgetDeleted: '已删除 {name}', widgetBound: '已绑定 {widget} → {var}', // 快捷操作 / 数据 - agxMovedToBinding: 'AGX 配置已移至变量绑定', - waitBeforeTrigger: '请等待几秒后再触发其他模型', operationExecuted: '操作已执行', - execFailed: '执行失败', execFailedMsg: '执行失败: {msg}', errorMsg: '错误: {msg}', processRunning: '进程正在运行中,请先停止', @@ -1576,23 +1516,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { // 时间同步 timeSynced: '时间已同步: {datetime}', timeSyncSyncing: '正在从浏览器同步时间...', - timeSyncFailed: '时间同步失败', - syncFailed: '同步失败', - setFailed: '设置失败', - ntpSyncing: '正在强制NTP同步...', - ntpStarted: 'NTP同步已启动,请稍候刷新查看结果', - ntpFailed: 'NTP同步失败', // LED ledFilled: '{device} 已填充 {color}', - ledFillFailed: '填充失败', ledEffectStarted: '{device}: {effect} 已启动', - ledEffectStartFailed: '启动动画失败', ledEffectStopped: '{device} 动画已停止', - ledEffectStopFailed: '停止动画失败', - ledImageFailed: '显示图像失败', - ledQrFailed: '生成 QR 码失败', - ledTextFailed: '显示文本失败', - ledTextStopFailed: '停止文本失败', allLedsOff: '全部 LED 已关闭', selectAnimation: '请先选择一个动画', enterImagePath: '请输入图像路径', @@ -1605,21 +1532,14 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { filterStopped: '滤镜已停止', selectFilter: '请先选择滤镜', ledBrightnessSet: '{device} 亮度: {value}', - ledBrightnessFailed: '设置 {device} 亮度失败', ledTurnedOff: '{device} 已关闭', ledTurnedOn: '{device} 已开启', - ledOnFailed: '开启失败', - ledOffFailed: '关闭失败', filterApplied: '滤镜 {filter} 已应用', - filterApplyFailed: '应用滤镜失败', - filterStopFailed: '停止滤镜失败', ledConfigSaved: '{device} 配置已保存', ledConfigSavedWithAnim: '{device} 配置已保存: {anim}', - ledConfigSaveFailed: '保存配置失败', enterQrContent: '请输入QR码内容', // PKI/证书 configPackImported: '配置包导入成功', - importFailed: '导入失败', keypairGenerated: '密钥对生成成功', csrGenerated: 'CSR 生成成功', csrCopied: 'CSR 已复制到剪贴板', @@ -1633,19 +1553,12 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { enterKeyId: '请输入密钥 ID', generatingKey: '正在生成密钥...', keyGenerated: '密钥 "{name}" 生成成功', - generateFailed: '生成失败', terminalDisconnected: '终端已断开', // Export/Import - exportFailed: '导出失败', exportSuccess: '导出成功', - invalidResponse: '无效的响应数据', - exportedSourceConfig: '已导出数据源配置: {filename}', exportedCmdWithHost: '已导出指令配置(包含主机 {hostId}): {filename}', exportedCmdConfig: '已导出指令配置: {filename}', - exportedActionTemplate: '已导出动作模板: {filename}', selectFileFirst: '请先选择要导入的配置包文件(.tscfg)', - importedRestartRequired: '已导入配置,重启后生效', - importFailed: '导入失败', // OTA enterFirmwareUrl: '请输入固件 URL', urlMustHttp: 'URL 必须以 http:// 或 https:// 开头', @@ -1665,43 +1578,17 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { enterOtaServer: '请先输入 OTA 服务器地址', upgradeFailed: '升级失败', // 自动化 - ruleToggled: '规则 {id} {state}', - ruleTriggered: '触发规则 {id}', - toggleRuleFailed: '切换规则状态失败', - triggerRuleFailed: '触发规则失败', - toggleSourceFailed: '切换数据源状态失败', - sourceToggled: '数据源 {id} {state}', - actionExecuting: '正在执行动作: {id}...', - actionSuccess: '执行成功', - actionFailed: '动作 {id} 失败', - actionExecuteFailed: '动作执行失败', - getActionFailed: '获取动作详情失败', - editActionFailed: '编辑动作失败', updateFailed: '更新失败', deleteOldTemplateFailed: '无法删除旧模板', - deleteActionResult: '删除动作 {id}', - deleteSourceResult: '删除数据源 {id}', - deleteSourceFailed: '删除数据源失败', shutdownSettingsSaved: '关机设置已保存', defaultsRestored: '已恢复默认设置', restoreFailed: '恢复失败', - restoreFailedMsg: '恢复失败: {msg}', - deleteRuleResult: '删除规则 {id}', - deleteRuleFailed: '删除规则失败', - getRuleDetailFailed: '获取规则详情失败', - sourceCreated: '数据源 {id} 创建成功', - sourceCreateFailed: '创建数据源失败', - createActionFirst: '请先创建动作模板', ruleCreated: '规则 {id} {action}成功', ruleCreateFailed: '{action}规则失败', - actionResult: '{action}: {msg}', - actionFailedMsg: '{action} 失败: {msg}', ruleUpdate: '更新', ruleCreate: '创建', configExported: '已导出{type}配置: {filename}', - ruleConfigExported: '已导出规则配置: {filename}', selectFile: '请先选择文件', - configImported: '已导入配置,重启后生效', // 表单验证 fillActionId: '请填写动作 ID', selectActionType: '请选择动作类型', @@ -1714,8 +1601,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { fillVarName: '请填写变量名', fillVarValue: '请填写变量的值', fillWebhookUrl: '请填写 Webhook URL', - actionCreated: '动作模板 {id} 创建成功', - actionCreateFailed: '创建失败', downloadFailed: '下载失败', renameFailed: '重命名失败', createFailed: '创建失败', @@ -1724,10 +1609,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { deployFailed: '部署失败', verifyFailed: '验证失败', installFailed: '安装失败', - getCertFailed: '获取证书失败', invalidResponseData: '无效的响应数据', fileContentEmpty: '文件内容为空', - exportFailed: '导出失败', configSaved: '配置已保存', configRestartRequired: '重启系统后生效', verifySuccess: '验证通过', @@ -1752,7 +1635,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { // LED 配置保存 ledConfigSaved: '{device} 配置已保存', ledConfigSavedWithAnim: '{device} 配置已保存: {animation}', - ledConfigSaveFailed: '保存配置失败', qrCodeSuccess: 'QR 码生成成功', qrCodeFailed: '生成 QR 码失败', fontNotFound: '未找到字体文件,请上传到 /sdcard/fonts', @@ -1760,15 +1642,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { textDisplayFailed: '显示文本失败', stopFailed: '停止失败', filterApplied: '已应用滤镜: {filter}', - filterApplyFailed: '应用滤镜失败', - filterStopFailed: '停止滤镜失败', // 网络 wifiModeChanged: 'WiFi 模式已切换为 {mode}', // 文件操作 - createDirFailed: '创建目录失败: {msg}', enterQrContent: '请输入 QR 码内容', - enterDisplayText: '请输入显示文本', - textStopped: '文本已停止', selectFileToDelete: '请先选择要删除的文件', selectFileToDownload: '请先选择要下载的文件', noDownloadableFiles: '选中的项目中没有可下载的文件(文件夹不支持下载)', @@ -1783,27 +1660,19 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { unmountFailed: '卸载失败', enterFolderName: '请输入文件夹名称', folderCreated: '文件夹创建成功', - createFailedMsg: '创建失败: {msg}', enterNewName: '请输入新名称', renameSuccess: '重命名成功', - renameFailedMsg: '重命名失败: {msg}', downloadStart: '下载开始', - downloadFailedMsg: '下载失败: {msg}', deleteSuccess: '删除成功', - deleteFailedMsg: '删除失败: {msg}', // SSH 页面 selectHostFirst: '请先选择一个主机', - fillCommandNameAndCmd: '请填写指令名称和命令', fillCommandName: '请填写指令名称', fillCommandText: '请填写要执行的命令', fillCommandId: '请填写指令 ID', commandIdInvalid: '指令 ID 只能包含字母、数字、下划线和短横线,不能以下划线或短横线开头或结尾,最长 31 个字符', serviceModeRequiresPattern: '启用服务模式时必须设置就绪匹配模式', serviceModeRequiresVar: '启用服务模式时必须设置变量名', - commandUpdated: '指令已更新', - commandCreated: '指令已创建', saveCommandFailed: '保存指令失败', - commandDeleted: '指令已删除', deleteCommandFailed: '删除指令失败', noLogInfo: '没有可用的日志信息', noProcessInfo: '没有可用的进程信息', @@ -1813,43 +1682,28 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { serviceNotRunning: '服务未运行', stopServiceFailed: '停止服务失败', stopServiceFailedMsg: '停止服务失败: {msg}', - commandRunning: '有命令正在执行中,请先取消或等待完成', startExecFailed: '启动执行失败', - startExecFailedMsg: '启动执行失败: {msg}', noRunningCommand: '没有正在执行的命令', cancelSent: '取消请求已发送', cancelFailed: '取消失败', - cancelFailedMsg: '取消失败: {msg}', patternMatchSuccess: '模式匹配成功', commandSuccess: '命令执行成功', commandMatchFailed: '命令执行完成,模式匹配失败', commandTimeout: '命令执行超时', commandCompletedCode: "命令未成功完成,退出码:{code}。请查看命令输出。", execError: '执行出错', - execErrorMsg: '执行出错: {msg}', commandCancelled: '取消处理已结束,远端结果未确认', - hostFingerprintDeleted: '已删除主机指纹', - hostInfoEmpty: "没有找到此主机的连接配置,请检查主机设置。", testingConnection: '正在测试连接 {host}...', - cannotGetHostInfo: '无法获取主机信息', connectionSuccess: '连接 {host} 成功!', - testConnectionFailed: '测试失败', hostConfigExported: '已导出主机配置: {filename}', - sshHostRemoved: 'SSH 主机 {id} 已从列表移除', removeFailed: '移除失败', enterPassword: '请输入密码', publicKeyRevoked: '已撤销公钥并移除主机记录', localHostRemoved: '已移除本地主机记录', - keyDeleted: '密钥已删除', - cannotGetPublicKey: '无法获取公钥', - cannotGetPrivateKey: '无法获取私钥', downloadedPublicKey: '已下载 {id}.pub', downloadedPrivateKey: '已下载 {id}', - fillServerInfo: '请填写完整的服务器信息', keyDeploySuccess: '密钥部署成功', keyRevokeSuccess: '密钥撤销成功', - oldHostKeyRemoved: '旧主机密钥已移除,请重新连接以信任新密钥', - hostRemoved: '主机已移除', allKnownHostsCleared: '已清除所有已知主机', clearFailed: '清除失败', certCopiedToClipboard: '证书已复制到剪贴板', @@ -1881,8 +1735,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { lpmuOfflineHint: 'LPMU 离线 (ping 10.10.99.99 不可达)\n点击触发电源按钮', lpmuDetectingHint: '正在检测 LPMU 状态...\n最多等待 80 秒', lpmuUnknownHint: 'LPMU 状态未知\n点击触发电源按钮', - powerOn: '上电', - powerOff: '断电', clickToOff: '点击关闭电源', clickToOn: '点击开启电源', confirmLpmuTrigger: '确定要触发 LPMU 电源按钮吗?\n\n这将发送一个脉冲信号,效果类似按物理电源按钮。', @@ -1907,7 +1759,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { noNetwork: '未发现网络', hiddenNetwork: '(隐藏网络)', connect: '连接', - needStaMode: '需要切换到 STA 或 APSTA 模式', enterPassword: '输入 "{ssid}" 的密码 (开放网络留空):', loading: '加载中...', noDevice: '无接入设备', @@ -1976,7 +1827,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { lpmuAccessResultDone: '完成', lpmuAccessLog: '执行日志', lpmuAccessLogEmpty: '本次执行没有输出日志。', - lpmuAccessLogFailed: '执行日志读取失败', lpmuAccessLogInvalidPage: '日志分页数据无效或读取没有进展', lpmuAccessLastOutput: '末尾输出:{output}', lpmuAccessNoInternet: '没有找到可用的互联网连接', @@ -1994,8 +1844,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { lpmuAccessFailed: '失败', lpmuAccessError: '错误', lpmuAccessOutput: '输出', - lpmuAccessStartFailed: '启动接入失败', - lpmuAccessStatusFailed: '状态获取失败', lpmuAccessStatusUnknown: '状态获取失败,执行结果尚未确认', lpmuAccessRefreshStatus: '刷新状态', clients: '客户端', @@ -2046,12 +1894,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { sdCardNotMounted: 'SD 卡未挂载', mountingSd: '正在挂载 SD 卡...', mountSdSuccess: 'SD 卡挂载成功', - mountSdFailed: '挂载失败', confirmUnmountSd: '确定要卸载 SD 卡吗?\n\n卸载后将无法访问 SD 卡上的文件。', unmountingSd: '正在卸载 SD 卡...', unmountSdSuccess: 'SD 卡已卸载', - unmountSdFailed: '卸载失败', - loadFailed: '加载失败', // 批量操作 batchDownload: '批量下载', batchDelete: '批量删除', @@ -2120,17 +1965,9 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { // SSH/Commands 页面 sshPage: { // 服务状态标签 - statusReady: '就绪', - statusChecking: '检测中', - statusTimeout: '超时', - statusFailed: '失败', - statusIdle: '未启动', - statusStopped: '已停止', statusUnknown: '未知', // 错误消息 - hostNotFound: '主机信息不存在', hostNotExistShort: '主机不存在', - cmdNotFound: '命令不存在', // 匹配结果标签 matchResultTitle: '匹配结果', wsMessageHint: 'WebSocket 消息字段可在 handleSshExecMessage(msg) 回调中使用', @@ -2155,7 +1992,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { loadingHosts: '加载主机列表...', cancelEsc: '取消 (Esc)', selectKey: '-- 选择密钥 --', - selectHost: '-- 选择主机 --', selectCommand: '-- 选择指令 --', selectDevice: '-- 选择设备 --', noKeysHint: '暂无密钥,点击上方按钮生成新密钥', @@ -2163,9 +1999,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { noDeployedHostsMsg: '暂无已部署主机', deployKeyAtSecurityHint: '请先到 安全 页面部署 SSH 公钥', noKnownHosts: '暂无已知主机指纹', - pleaseConfigSshHost: '-- 请先配置 SSH 主机 --', noVariableData: '该指令暂无变量数据,请先执行一次', - getVarFailed: '获取变量失败', // 变量表格表头 varTableName: '变量名', varTableType: '类型', @@ -2217,12 +2051,10 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { stopServiceFailed: '停止服务失败', executionFailed: '执行失败', getLogFailed: '获取日志失败', - getLogFailedMsg: '获取日志失败: {msg}', startRealTimeTail: '开始实时跟踪: {logFile}\n(点击"停止跟踪"按钮退出)', viewServiceLogFile: '查看服务日志: {name}\n文件: {file}', logErrorMsg: '[错误] {msg}', stopServiceName: '停止服务: {name}', - startExecFailedDetail: '启动执行失败\n\n{msg}', patternMatchSuccess: '模式匹配成功!', expectPatternMatch: '期望模式匹配: 是', failPatternMatch: '失败模式匹配: 是', @@ -2427,7 +2259,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { csrGenerateTitle: '生成证书签名请求', genHttpsKeyDescFull: '为设备生成 ECDSA P-256 密钥对,用于 mTLS 身份验证。', mismatchTitle: '安全警告:主机指纹不匹配!', - mismatchAdvice: '建议:如果您确认服务器已重装或密钥已更新,可以点击「更新主机密钥」移除旧记录,然后重新连接以信任新密钥。', packExportPick: '选择要导出的文件', dirShort: '目录', recipientCert: '接收方设备证书', @@ -2645,10 +2476,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { packListTitle: '配置包列表', packListDirPath: '目录路径', noTscfgInDir: '目录中没有 .tscfg 文件', - packListLoadFailed: '加载失败', confirmImportPack: '确定要导入配置包: {path} ?', importPackSuccess: '配置包导入成功', - importPackFailed: '导入失败', deviceType: '设备类型', certCN: '证书 CN', formatVersion: '格式版本', @@ -2785,7 +2614,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { revokedAndRemoved: '已撤销公钥并移除主机记录', keyNotFoundOnServer: '未在服务器上找到匹配的公钥(可能已被移除)\n是否仍要移除本地记录?', removedLocalRecord: '已移除本地主机记录', - revokeFailed: '撤销失败', // Known hosts viewFullFingerprint: '查看完整指纹', deleteFingerprint: '删除指纹记录' @@ -2799,7 +2627,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { newVersionFound: '发现新版本', serverVersionOlder: '服务器版本较旧', alreadyLatest: '已是最新版本', - checkUpdateFailed: '检查更新失败', upgradeNow: '立即升级', downgrade: '降级', uploadFailed: '上传失败', @@ -2859,7 +2686,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { patchUpdate: '补丁更新', // 升级过程 serverNotSet: 'OTA 服务器地址未设置', - uploadFirmwareFailed: '上传固件失败', firmwareWriteComplete: '固件写入完成!', uploadingFirmware: '上传固件到设备...', uploadingWebUI: '上传 WebUI 到设备...', @@ -2946,8 +2772,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { requestBodyHint: 'JSON 格式,支持变量', getMemoryDetailFailed: '获取内存详情失败', // 日志(快捷操作日志跟踪) - logFetchFailed: '[获取失败]', - logError: '[错误]', deviceBusyRetry: '若设备繁忙可稍后重试。', logEmptyBracket: '[空]', logFileEmptyBracket: '[日志文件不存在或为空]', @@ -2955,22 +2779,16 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { logEmpty: '暂无日志', noRules: '暂无规则,点击"添加"创建第一条', noSources: '暂无数据源,点击"添加"创建第一个', - noVariables: '暂无变量数据', noActions: '暂无动作模板,点击"添加"创建', saveRuleChanges: '保存修改', - getRulesFailed: '获取规则失败', - getSourcesFailed: '获取数据源失败', getVariablesFailed: '获取变量失败', - getStatusFailed: '获取状态失败', selectActionType: '请选择动作类型', - networkFailed: '网络连接失败', loadingVariables: '加载中...', loadingOptions: '-- 加载中 --', sourceNoData: '该数据源暂无变量数据', configSourceFirst: '请先配置数据源并启用', testRequesting: '正在请求...', testSuccess: '连接成功', - testFailed: '请求失败', testError: '错误', testConnecting: '正在连接...', testConnected: '连接成功,已收到数据', @@ -3019,10 +2837,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { method: '方法', requestBody: '请求体', indexPlaceholder: '索引', - pulseMsPlaceholder: '脉冲ms', - varNamePlaceholder: '变量名', - jsonValuePlaceholder: '值 (JSON)', - logMessagePlaceholder: '日志消息', browseFilesTitle: '浏览文件', insertVariableTitle: '插入变量', imagePathHint: '支持 PNG、JPG、BMP、GIF 格式,路径支持变量', @@ -3060,7 +2874,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { // 快捷操作 noQuickActions: '暂无快捷操作', quickActionsHint: "没有规则勾选「在首页显示」。你可以在「自动化」页新建规则,或编辑已有规则并勾选「在首页显示」。", - loadQuickActionsFailed: '无法加载快捷操作', processRunning: '进程运行中', processNotRunning: '进程未运行', viewLog: '查看日志', @@ -3238,7 +3051,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { noLogVariable: '未配置日志变量', invalidExpression: '无效的变量表达式', variableNoData: '变量不存在或无数据', - readFailed: '读取失败', noLogs: '暂无日志', cleared: '已清空', noWidgets: '暂无组件', @@ -3295,21 +3107,16 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { minCurvePoints: '至少需要 2 个曲线点', selectVarToBind: '请选择要绑定的变量', tempBoundToVar: '温度已绑定到变量: {var}', - bindFailed: '绑定失败', unbindSuccess: '温度变量绑定已解除', - unbindFailed: '解绑失败', dutyOrderError: '最小占空比不能大于最大占空比', hysteresisRangeError: "调速温差(温度迟滞)必须在 0–20°C 范围内。", intervalRangeError: "两次调速的最小间隔必须在 0.5–30 秒之间。", curveApplied: '风扇 {id} 曲线已应用并保存', - applyCurveFailed: '应用曲线失败', curveExported: '风扇 {id} 曲线配置已导出(本地 + SD 卡 {path})', - curveExportSdFailed: '风扇 {id} 曲线已保存到本地,SD 卡保存失败: {msg}', invalidConfigFormat: '无效的配置文件格式', invalidCurvePoints: '配置文件中曲线点无效', curvePointFormatError: '曲线点格式错误', configImported: '已导入配置文件: {name}', - importConfigFailed: '导入配置失败', // 加权温度绑定 addVariable: '添加变量', weight: '权重', @@ -3711,7 +3518,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { statusActivated: '已激活', statusExpired: '已过期', statusError: '错误', - statusLoadFailed: '加载失败', deviceType: '{type} 设备', certValid: '有效', certInvalid: '无效', @@ -3723,11 +3529,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { ecdsaKeyPairSuccess: 'ECDSA P-256 密钥对生成成功!', installingCert: '正在安装证书...', certInstalledSuccess: '证书安装成功!', - installFailed: '安装失败', - installFailedMsg: '安装失败: {msg}', - deployFailedMsg: '部署失败: {msg}', - revokeFailedMsg: '撤销失败: {msg}', - invalidJsonConfigMsg: '配置文件不是有效的 JSON: {msg}', // 配置包 enterConfigName: '请输入配置名称', selectConfigFile: '请选择配置文件', @@ -3738,12 +3539,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { savedToDevice: '已保存到设备', installingCaCert: '正在安装 CA 证书链...', caCertInstalledSuccess: 'CA 证书链安装成功!', - loadFailedMsg: '加载失败: {msg}', generationFailed: '生成失败', - generationFailedMsg: '生成失败: {msg}', verifying: '验证中...', - verifyFailed: '验证失败', - signatureInvalid: '签名无效', importing: '导入中...', importFailed: '导入失败', uploadOrPasteContent: '请上传文件或粘贴配置包内容' @@ -3784,10 +3581,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { lightOff: '关闭', turnOff: '关闭', turnOn: '开启', - colorSettings: '颜色设置', - turnOffLight: '关灯', - turnOnLight: '开灯', - noFilterSelected: '未选择滤镜', // OTA firmwareUpgradeComplete: '固件升级完成,准备升级 WebUI...', allUpgradeComplete: '全部升级完成', @@ -3901,11 +3694,8 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { confirmStopService: '确定要停止服务 "{name}" 吗?', confirmDeleteFingerprint: '确定要删除主机 {host}:{port} 的指纹记录吗?\n\n删除后下次连接将重新验证服务器指纹。', confirmRemoveHostLocal: '确定要从列表中移除主机 "{id}" 吗?\n\n注意:这只会移除本地记录,不会删除已部署到服务器上的公钥。如需撤销公钥,请点击「撤销」按钮。', - confirmRemoveHostLocal2: '确定要从列表中移除主机 "{id}" 吗?\n\n注意:这只会移除本地记录,不会删除已部署到服务器上的公钥。如需撤销公钥,请使用密钥管理中的「撤销」功能。', confirmDeleteKey: '确定要删除密钥 "{id}" 吗?此操作不可撤销!', confirmExportPrivateKey: '安全警告\n\n您正在导出私钥 "{id}"。\n\n私钥是高度敏感的安全凭证,请确保:\n• 不要在公共网络传输\n• 不要分享给他人\n• 安全存储在本地\n\n确定要继续吗?', - confirmRemoveKnownHost: '确定要移除主机 "{host}:{port}" 的记录吗?', - confirmClearKnownHosts: '确定要清除所有已知主机记录吗?此操作不可撤销!', confirmDeletePKI: '确定要删除所有 PKI 凭证吗?\n\n这将删除:\n• 私钥\n• 设备证书\n• CA 证书链\n\n此操作不可撤销!', confirmMarkFirmwareValid: '确认将当前固件标记为有效?\n这将取消自动回滚保护。', confirmRollback: '确认回滚到上一版本固件?\n\n系统将立即重启并加载上一个分区的固件。\n请确保上一版本固件可用!', @@ -4083,7 +3873,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { runtimeStats: '运行时统计', updateTime: '更新时间: ', loadFailed: '获取内存详情失败', - getDataFailed: '获取数据失败', staticDataBss: '.data + .bss', iramTitle: 'IRAM (指令内存)', rtcUsed: '已用', @@ -4157,30 +3946,21 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, ops: { generic: { failed: '操作失败', unknown: '操作结果未确认' }, - network: { lpmuaccess: { failed: '启动 LPMU 接入失败', unknown: '启动 LPMU 接入的结果未确认' }, lpmuaccessstatus: { failed: '读取 LPMU 接入状态失败' } }, - wifi: { scan: { failed: '扫描 WiFi 失败' } }, + system: { agxpower: { failed: '切换 AGX 电源失败', unknown: '切换 AGX 电源的结果未确认' }, lpmupower: { failed: '触发 LPMU 电源操作失败', unknown: '触发 LPMU 电源操作的结果未确认' }, protection: { failed: '切换电压保护失败', unknown: '切换电压保护的结果未确认' }, usbmux: { failed: '切换 USB 通道失败', unknown: '切换 USB 通道的结果未确认' }, resetshutdown: { failed: '恢复关机设置的默认值失败', unknown: '恢复关机设置默认值的结果未确认' }, saveshutdown: { failed: '保存关机设置失败', unknown: '保存关机设置的结果未确认' }, timezone: { failed: '设置时区失败', unknown: '设置时区的结果未确认' }, hostname: { failed: '设置主机名失败', unknown: '设置主机名的结果未确认' }, timesync: { failed: '同步设备时间失败', unknown: '同步设备时间的结果未确认' } }, + network: { lpmuaccess: { failed: '启动 LPMU 接入失败', unknown: '启动 LPMU 接入的结果未确认' }, lpmuaccessstatus: { failed: '读取 LPMU 接入状态失败' }, nattoggle: { failed: '切换 NAT 失败', unknown: '切换 NAT 的结果未确认' }, savenat: { failed: '保存 NAT 配置失败', unknown: '保存 NAT 配置的结果未确认' }, dhcpclients: { failed: '读取 DHCP 客户端列表失败' }, lpmulog: { failed: '读取 LPMU 接入的执行日志失败' } }, + wifi: { scan: { failed: '扫描 WiFi 失败' }, applyap: { failed: '应用热点配置失败', unknown: '应用热点配置的结果未确认' }, connect: { failed: '连接 WiFi 失败', unknown: '连接 WiFi 的结果未确认' }, disconnect: { failed: '断开 WiFi 连接失败', unknown: '断开 WiFi 连接的结果未确认' }, mode: { failed: '切换 WiFi 模式失败', unknown: '切换 WiFi 模式的结果未确认' }, stations: { failed: '读取已连接设备列表失败' } }, cert: { status: { failed: '刷新证书状态失败' }, timesync: { failed: '同步设备时间失败', unknown: '同步设备时间的结果未确认' }, keypair: { failed: '生成密钥对失败', unknown: '生成密钥对的结果未确认' }, csr: { failed: '生成证书请求(CSR)失败' }, install: { failed: '安装证书失败', unknown: '安装证书的结果未确认' }, installca: { failed: '安装 CA 证书链失败', unknown: '安装 CA 证书链的结果未确认' }, view: { failed: '读取设备证书失败' }, delete: { failed: '删除 PKI 凭证失败', unknown: '删除 PKI 凭证的结果未确认' } }, account: { login: { failed: '登录失败' }, changepassword: { failed: '修改密码失败', unknown: '修改密码的结果未确认' }, setroot: { failed: '设置 root 密码失败', unknown: '设置 root 密码的结果未确认' }, setadmin: { failed: '设置 admin 密码失败', unknown: '设置 admin 密码的结果未确认' }, resetadmin: { failed: '重置 admin 密码失败', unknown: '重置 admin 密码的结果未确认' } }, - ota: { upgrade: { failed: '升级失败', unknown: '升级的结果未确认' }, startwww: { failed: 'WebUI 升级没能启动', unknown: '启动 WebUI 升级的结果未确认' }, check: { failed: '检查更新失败' }, start: { failed: '启动固件升级失败', unknown: '启动固件升级的结果未确认' }, upgradewww: { failed: 'WebUI 升级失败(固件已经写入)', unknown: 'WebUI 升级的结果未确认(固件已经写入)' }, path: { failed: '找不到要升级的文件' } }, + ota: { upgrade: { failed: '升级失败', unknown: '升级的结果未确认' }, startwww: { failed: 'WebUI 升级没能启动', unknown: '启动 WebUI 升级的结果未确认' }, check: { failed: '检查更新失败' }, start: { failed: '启动固件升级失败', unknown: '启动固件升级的结果未确认' }, upgradewww: { failed: 'WebUI 升级失败(固件已经写入)', unknown: 'WebUI 升级的结果未确认(固件已经写入)' }, path: { failed: '无法开始从 SD 卡升级' }, abort: { failed: '中止升级失败', unknown: '中止升级的结果未确认' }, validate: { failed: '标记固件有效失败', unknown: '标记固件有效的结果未确认' }, rollback: { failed: '回滚固件失败', unknown: '回滚固件的结果未确认' }, saveserver: { failed: '保存 OTA 服务器地址失败', unknown: '保存 OTA 服务器地址的结果未确认' } }, device: { reboot: { failed: '重启设备失败', unknown: '重启设备的结果未确认' } }, - led: { list: { failed: '读取 LED 设备列表失败' }, clear: { failed: '关闭 LED 失败', unknown: '关闭 LED 的结果未确认' } }, + led: { list: { failed: '读取 LED 设备列表失败' }, clear: { failed: '关闭 LED 失败', unknown: '关闭 LED 的结果未确认' }, ccapply: { failed: '应用色彩校正失败', unknown: '应用色彩校正的结果未确认' }, ccreset: { failed: '重置色彩校正失败', unknown: '重置色彩校正的结果未确认' }, ccimport: { failed: '导入色彩校正配置失败', unknown: '导入色彩校正配置的结果未确认' }, filterapply: { failed: '应用滤镜失败', unknown: '应用滤镜的结果未确认' }, filterstop: { failed: '停止滤镜失败', unknown: '停止滤镜的结果未确认' }, brightness: { failed: '设置 {device} 的亮度失败', unknown: '设置 {device} 亮度的结果未确认' }, saveconfig: { failed: '保存 LED 配置失败', unknown: '保存 LED 配置的结果未确认' }, starteffect: { failed: '启动 LED 动画失败', unknown: '启动 LED 动画的结果未确认' }, stopeffect: { failed: '停止 LED 动画失败', unknown: '停止 LED 动画的结果未确认' }, fill: { failed: '填充 LED 颜色失败', unknown: '填充 LED 颜色的结果未确认' }, image: { failed: '在 LED 上显示图像失败', unknown: '在 LED 上显示图像的结果未确认' }, qr: { failed: '在 LED 上显示 QR 码失败', unknown: '在 LED 上显示 QR 码的结果未确认' }, text: { failed: '在 LED 上显示文本失败', unknown: '在 LED 上显示文本的结果未确认' }, stoptext: { failed: '停止 LED 上的文本失败', unknown: '停止 LED 上的文本的结果未确认' }, toggle: { failed: '切换 LED 开关失败', unknown: '切换 LED 开关的结果未确认' } }, home: { quicklist: { failed: '读取快捷操作失败' }, quicklog: { failed: '读取运行日志失败' } }, - file: { upload: { failed: '上传文件失败', unknown: '上传文件的结果未确认' }, delete: { failed: '删除失败', unknown: '删除的结果未确认' }, download: { failed: '下载失败' } }, + file: { upload: { failed: '上传文件失败', unknown: '上传文件的结果未确认' }, mountsd: { failed: '挂载 SD 卡失败', unknown: '挂载 SD 卡的结果未确认' }, unmountsd: { failed: '卸载 SD 卡失败', unknown: '卸载 SD 卡的结果未确认' }, createdir: { failed: '创建文件夹失败', unknown: '创建文件夹的结果未确认' }, rename: { failed: '重命名文件或文件夹失败', unknown: '重命名文件或文件夹的结果未确认' }, delete: { failed: '删除文件或文件夹失败', unknown: '删除文件或文件夹的结果未确认' }, download: { failed: '下载文件失败' }, list: { failed: '读取文件夹内容失败' } }, memory: { load: { failed: '读取内存详情失败' } }, automationpage: { logfetchfailed: { failed: '[获取失败]' } }, - common: { loadfailed: { failed: '加载失败' }, loadfailedmsg: { failed: '加载失败' } }, - errors: { savefailed: { failed: '保存失败' } }, - fan: { clearfailed: { failed: '清除失败' }, setfailed: { failed: '设置失败' }, setfanmodefailed: { failed: '设置风扇模式失败' }, setspeed: { failed: '设置风扇 {id} 的转速失败', unknown: '设置风扇 {id} 转速的结果未确认' } }, - fanpage: { applycurvefailed: { failed: '应用曲线失败' }, bindfailed: { failed: '绑定失败' }, curveexportsdfailed: { failed: '风扇 {id} 曲线已保存到本地,SD 卡保存失败' }, importconfigfailed: { failed: '导入配置失败' }, unbindfailed: { failed: '解绑失败' }, readlog: { failed: '读取日志变量失败' } }, - filepage: { mountsdfailed: { failed: '挂载失败' }, unmountsdfailed: { failed: '卸载失败' } }, - ledpage: { ccapplyfailed: { failed: '应用色彩校正失败' }, ccexportfailed: { failed: '导出配置失败' }, ccimportfailed: { failed: '导入配置失败' }, ccresetfailed: { failed: '重置色彩校正失败' } }, - login: { networkerror: { failed: '网络错误' } }, - networkpage: { lpmuaccesslogfailed: { failed: '执行日志读取失败' } }, - pkipage: { generationfailedmsg: { failed: '生成失败' }, loadfailedmsg: { failed: '加载失败' }, verifyfailed: { failed: '验证失败' } }, - promptrepair: { listrefreshfailed: { failed: '文件列表刷新失败,请手动刷新。已确认的上传结果不受影响' }, otapathfailed: { failed: '无法读取 SD 卡升级文件,请检查文件路径' }, startfailed: { failed: '未能启动操作' } }, - securitypage: { importpackfailed: { failed: '导入失败' }, packlistloadfailed: { failed: '加载失败' } }, - system: { agxpowerfail: { failed: 'AGX 操作失败' }, lpmutriggerfail: { failed: 'LPMU 触发失败' }, switchfailed: { failed: '切换失败' } }, - toast: { abortfailedmsg: { failed: '中止失败' }, configfailedmsg: { failed: '配置失败' }, connectfailedmsg: { failed: '连接失败' }, createdirfailed: { failed: '创建目录失败' }, createfailedmsg: { failed: '创建失败' }, deletefailedmsg: { failed: '删除失败' }, disconnectfailedmsg: { failed: '断开失败' }, downloadfailedmsg: { failed: '下载失败' }, fetchfailed: { failed: '获取失败' }, filterapplyfailed: { failed: '应用滤镜失败' }, filterstopfailed: { failed: '停止滤镜失败' }, importfailed: { failed: '导入失败' }, ledbrightnessfailed: { failed: '设置 {device} 亮度失败' }, ledconfigsavefailed: { failed: '保存配置失败' }, ledeffectstartfailed: { failed: '启动动画失败' }, ledeffectstopfailed: { failed: '停止动画失败' }, ledfillfailed: { failed: '填充失败' }, ledimagefailed: { failed: '显示图像失败' }, ledqrfailed: { failed: '生成 QR 码失败' }, ledtextfailed: { failed: '显示文本失败' }, ledtextstopfailed: { failed: '停止文本失败' }, operationfailedmsg: { failed: '操作失败' }, renamefailedmsg: { failed: '重命名失败' }, restorefailedmsg: { failed: '恢复失败' }, rollbackfailedmsg: { failed: '回滚失败' }, savefailed: { failed: '保存失败' }, savefailedmsg: { failed: '保存失败' }, setfailed: { failed: '设置失败' }, setfailedmsg: { failed: '设置失败' }, switchfailedmsg: { failed: '切换失败' }, syncfailed: { failed: '同步失败' }, upgradestartfailedmsg: { failed: '启动升级失败' } }, + fan: { setspeed: { failed: '设置风扇 {id} 的转速失败', unknown: '设置风扇 {id} 转速的结果未确认' }, cleartest: { failed: '清除测试温度失败', unknown: '清除测试温度的结果未确认' }, settest: { failed: '设置测试温度失败', unknown: '设置测试温度的结果未确认' }, setmode: { failed: '设置风扇模式失败', unknown: '设置风扇模式的结果未确认' } }, + fanpage: { curveexportsdfailed: { failed: '风扇 {id} 曲线已保存到本地,SD 卡保存失败' }, readlog: { failed: '读取日志变量失败' }, applycurve: { failed: '应用风扇曲线失败', unknown: '应用风扇曲线的结果未确认' }, bind: { failed: '绑定温度变量失败', unknown: '绑定温度变量的结果未确认' }, unbind: { failed: '解除温度变量绑定失败', unknown: '解除温度变量绑定的结果未确认' }, importcurve: { failed: '导入风扇曲线配置失败', unknown: '导入风扇曲线配置的结果未确认' } }, + ledpage: { ccexportfailed: { failed: '导出配置失败' } }, // —— 通用的列表读取(各页都会用到)—— image: { list: { failed: '读取图片列表失败' } }, variable: { list: { failed: '读取变量列表失败' } }, @@ -4221,6 +4001,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { generate: { failed: '生成配置包失败', unknown: '生成配置包的结果未确认' }, importtext: { failed: '导入配置包失败', unknown: '导入配置包的结果未确认' }, certload: { failed: '读取设备证书失败' }, + list: { failed: '读取配置包列表失败' }, import: { failed: '导入配置包“{file}”失败', unknown: '导入配置包“{file}”的结果未确认', done: '已导入配置包“{file}”,重启后生效' } }, // —— 自动化 —— @@ -4322,7 +4103,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { password_length: { why: '新密码的长度不对(需要 4 到 64 个字符)', hint: '请重新设置密码。' }, old_password_wrong: { why: '当前密码不对', hint: '请检查后重新输入。' }, root_required: { why: '这项操作需要 root 账户', hint: '请换用 root 账户登录后再操作。' }, - ota_failed: { why: '升级没有成功', hint: '点“详情”可以看到设备给出的原因。' }, + ota_failed: { why: '设备在升级过程中报告了错误', hint: '点“详情”可以看到设备给出的原因。' }, ota_no_rollback: { why: '没有可以回滚的旧版本', hint: '只有升级过之后,才会有旧版本可以回滚。' }, ota_server_not_set: { why: '还没有设置 OTA 服务器地址', hint: '请先在 OTA 页保存服务器地址,或直接填写完整的地址。' }, url_invalid: { why: '地址的格式不对,或者太长了', hint: '请填写完整、正确的地址。' }, @@ -4373,6 +4154,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { rule_pending_delete: { why: '这条规则已被删除,要重启设备后才会真正移除', hint: '在此之前它不能再执行;重启设备后它会消失。' }, rule_not_active: { why: '这条规则已保存,但要重启设备后才会运行', hint: '请重启设备后再执行。' }, action_missing: { why: '动作模板“{template}”已经不存在,可能已被删除', hint: '请重新选择动作模板后再保存。' }, + action_missing_unnamed: { why: '所选的动作模板已经不存在,可能已被删除', hint: '请重新选择动作模板后再保存。' }, template_lookup_failed: { why: '没能确认所选的动作模板是否还在', hint: '请稍后再试;仍然失败请在「终端」页打开「系统日志」查看原因。' }, source_unavailable: { why: '保存规则需要的 SD 卡不可用', hint: '请确认 SD 卡已插好并已挂载。' }, action_unsupported: { why: '配置包里用到了当前固件不支持的动作', hint: '请先把设备固件升级到较新的版本。' }, @@ -4405,7 +4187,6 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { pack_not_ready: { why: '配置包功能还没有初始化', hint: '请稍等几秒再试。' }, pack_verify_failed: { why: '没能验证这个配置包', hint: '请确认文件完整,并且是从天山 OS 导出的。' }, device_cert_unavailable: { why: '设备证书不可用', hint: '请先到「安全」页检查设备证书。' }, - rule_import_unavailable: { why: '规则配置包目前只能验证,还不能导入', hint: '请在自动化页手动创建这条规则。' }, // —— 自动化:引擎、动作模板、数据源 —— engine_unavailable: { why: '自动化引擎正在停止、仍有操作在执行,或还没有完成初始化', hint: '请稍等几秒再试。' }, engine_paused: { why: '自动化引擎处于暂停状态', hint: '如要恢复运行,请先点“停止”,再点“启动”。' }, From 9db1b3cead6f25142092e34c1ac5e046a2441a3a Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 13 Oct 2026 01:03:14 +0800 Subject: [PATCH 23/29] =?UTF-8?q?test:=20=E8=B7=9F=E4=B8=8A=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E4=B8=8E=E6=8F=90=E7=A4=BA=E8=AF=AD=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=E9=A1=B5=E9=9D=A2=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E6=B5=8B=E8=AF=95=E3=80=81=E8=B0=83=E7=94=A8=E7=82=B9?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=A3=80=E6=9F=A5=EF=BC=8C=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E9=92=89=E7=9D=80=E6=97=A7=E5=8F=A5=E5=AD=90=E7=9A=84=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - failure-catalog:新增“每个调用点都给足句子里的占位值”检查;一次列出全部问题 - flows:新增 system 组(登录、重启、服务、文件、网络、OTA、证书、配置包)和阻断删除弹窗、规则包导入场景;黄金句子重生成 - 上游测试适配:rule-pack/delete-reference 用 describeFailure 取句子;runtime 测试补桩与抽取清单 - ws 适配测试:补 ts_ssh_connect_reason 桩;帧类型按 type 字段判断,不再被原因代号里的 ssh_output 误判 Co-Authored-By: Claude Sonnet 5.5 --- tests/fixtures/failure-texts/automation.json | 270 ++++++++++++++++- .../fixtures/failure-texts/ssh-commands.json | 36 ++- tests/fixtures/failure-texts/ssh.json | 24 +- tests/fixtures/failure-texts/system.json | 278 ++++++++++++++++++ .../failure-wire/wire-trigger-generic.json | 2 +- .../prompts/delete-reference.browser.test.cjs | 4 +- tests/prompts/delete-reference.test.cjs | 11 +- tests/prompts/failure-catalog.test.cjs | 58 +++- .../prompts/failure-messages.browser.test.cjs | 4 +- tests/prompts/flows.browser.test.cjs | 110 ++++++- tests/prompts/key-generation.test.cjs | 4 +- tests/prompts/lpmu-lifecycle.browser.test.cjs | 4 +- tests/prompts/ownership.browser.test.cjs | 8 +- tests/prompts/pagekit.cjs | 2 +- tests/runtime/rule_pack/test_engine_pack.c | 4 +- tests/runtime/test_delete_reference.py | 4 +- tests/runtime/test_failure_wire.py | 2 + tests/runtime/test_rule_pack_crypto.py | 2 +- tests/runtime/test_ui.cjs | 2 +- .../ws_subscriptions/test_operation_adapter.c | 3 +- 20 files changed, 759 insertions(+), 73 deletions(-) create mode 100644 tests/fixtures/failure-texts/system.json diff --git a/tests/fixtures/failure-texts/automation.json b/tests/fixtures/failure-texts/automation.json index cb7fbb8..a222b06 100644 --- a/tests/fixtures/failure-texts/automation.json +++ b/tests/fixtures/failure-texts/automation.json @@ -1,4 +1,134 @@ { + "action.delete.check-busy": { + "zh-CN": { + "type": "sheet", + "title": "无法删除动作模板", + "text": "自动化配置正在更新,本次未删除动作模板。请等待更新完成后再删除。", + "items": [], + "buttons": [ + "关闭" + ] + }, + "en-US": { + "type": "sheet", + "title": "Can't delete action template", + "text": "The automation configuration is being updated. The action template was not deleted. Wait for the update to finish before deleting.", + "items": [], + "buttons": [ + "Close" + ] + } + }, + "action.delete.check-failed": { + "zh-CN": { + "type": "sheet", + "title": "无法删除动作模板", + "text": "没能确认是否还有规则在使用这个动作模板,所以没有删除。请在「终端」页打开「系统日志」查看原因。", + "items": [], + "buttons": [ + "关闭" + ] + }, + "en-US": { + "type": "sheet", + "title": "Can't delete action template", + "text": "Couldn't confirm whether any rule still uses this action template, so it was not deleted. Open “System Logs” on the Terminal page to see why.", + "items": [], + "buttons": [ + "Close" + ] + } + }, + "action.delete.check-loading": { + "zh-CN": { + "type": "sheet", + "title": "无法删除动作模板", + "text": "规则配置仍在加载,本次未删除动作模板。请等待加载完成后再删除。", + "items": [], + "buttons": [ + "关闭" + ] + }, + "en-US": { + "type": "sheet", + "title": "Can't delete action template", + "text": "The rule configuration is still loading. The action template was not deleted. Wait for loading to finish before deleting.", + "items": [], + "buttons": [ + "Close" + ] + } + }, + "action.delete.check-recovery": { + "zh-CN": { + "type": "sheet", + "title": "无法删除动作模板", + "text": "规则配置需要恢复核对,本次未删除动作模板。请在「终端」页打开「系统日志」查看详情。", + "items": [], + "buttons": [ + "关闭" + ] + }, + "en-US": { + "type": "sheet", + "title": "Can't delete action template", + "text": "The rule configuration needs a recovery check. The action template was not deleted. Open “System Logs” on the Terminal page for details.", + "items": [], + "buttons": [ + "Close" + ] + } + }, + "action.delete.in-use": { + "zh-CN": { + "type": "sheet", + "title": "无法删除动作模板", + "text": "动作模板“restart-agx”正被以下规则使用。请先把它从这些规则中移除,或删除不再需要的规则,再删除动作模板。", + "items": [ + "Night off · night-off", + "Morning · morning" + ], + "buttons": [ + "关闭", + "查看规则" + ] + }, + "en-US": { + "type": "sheet", + "title": "Can't delete action template", + "text": "Action template “restart-agx” is used by the rules below. Remove it from those rules, or delete rules you no longer need, before deleting the template.", + "items": [ + "Night off · night-off", + "Morning · morning" + ], + "buttons": [ + "Close", + "View rules" + ] + } + }, + "action.delete.in-use-no-details": { + "zh-CN": { + "type": "sheet", + "title": "无法删除动作模板", + "text": "动作模板“restart-agx”仍被规则使用,无法删除。请先把它从使用它的规则中移除。", + "items": [], + "buttons": [ + "关闭", + "查看规则" + ] + }, + "en-US": { + "type": "sheet", + "title": "Can't delete action template", + "text": "Action template “restart-agx” is still used by rules, so it can't be deleted. Remove it from those rules first.", + "items": [], + "buttons": [ + "Close", + "View rules" + ] + } + }, "action.delete.no-answer": { "zh-CN": { "type": "warning", @@ -25,14 +155,24 @@ }, "action.delete.protected": { "zh-CN": { - "type": "error", - "text": "删除动作模板“重启 AGX”失败:它绑定的服务还没有确认已停止,或有操作正在使用它。请先核验或停止服务,待操作完成后再试。", - "details": "原因代号:service_delete_protected\n接口:automation.actions.delete\n时间: