diff --git a/README.md b/README.md index 2614d6bf..b1376bca 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 11ae2793..355fa582 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 00000000..fdd7e120 Binary files /dev/null and b/assets/tianshanos-banner-en.png differ diff --git a/components/ts_api/src/ts_api.c b/components/ts_api/src/ts_api.c index 0ff1f1a8..9bf9b8bc 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 96f8a0a4..a8c42cb5 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; } @@ -542,6 +563,8 @@ static void rule_commit_reply(ts_api_result_t *result, esp_err_t ret, cJSON_AddNumberToObject(result->data, "revision", commit->revision); cJSON_AddStringToObject(result->data, "error_code", commit->error_code ? commit->error_code : "invalid_configuration"); + if (commit->missing_template_id[0]) + cJSON_AddStringToObject(result->data, "missing_template_id", commit->missing_template_id); } static esp_err_t rule_mutate_locked(const cJSON *params, ts_api_result_t *result, int operation) { const cJSON *id = cJSON_GetObjectItemCaseSensitive(params, "id"), @@ -695,7 +718,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; @@ -710,15 +737,21 @@ 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; } 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; + ts_rule_edit_end(); return ESP_OK; } @@ -2335,7 +2368,8 @@ static esp_err_t api_automation_actions_delete(const cJSON *params, ts_api_resul } const char *action_id = id->valuestring; - esp_err_t ret = ts_action_template_remove(action_id); + ts_action_template_delete_result_t detail = {0}; + esp_err_t ret = ts_action_template_remove_checked(action_id, &detail); if (ret == ESP_OK) { // 同时删除 SD 卡上的配置文件(.json 和 .tscfg) @@ -2351,17 +2385,38 @@ static esp_err_t api_automation_actions_delete(const cJSON *params, ts_api_resul result->code = TS_API_OK; result->message = strdup("Action template deleted"); + } else if (detail.reason) { + result->code = TS_API_ERR_BUSY; + result->message = strdup(detail.reason); + cJSON *data = cJSON_CreateObject(); + cJSON_AddStringToObject(data, "action_id", action_id); + if (detail.references_confirmed) { + cJSON_AddBoolToObject(data, "references_confirmed", true); + cJSON *rules = cJSON_CreateArray(); + bool complete = rules && detail.rules; + for (size_t i = 0; complete && i < detail.rule_count; ++i) { + cJSON *rule = cJSON_CreateObject(); + complete = rule && cJSON_AddStringToObject(rule, "id", detail.rules[i].id) && + cJSON_AddStringToObject(rule, "name", detail.rules[i].name); + if (complete) complete = cJSON_AddItemToArray(rules, rule); + if (!complete) cJSON_Delete(rule); + } + if (complete) { + complete = cJSON_AddItemToObject(data, "rules", rules); + if (!complete) cJSON_Delete(rules); + } else cJSON_Delete(rules); + cJSON_AddBoolToObject(data, "details_available", complete); + } + if (detail.check_reason) cJSON_AddStringToObject(data, "check_reason", detail.check_reason); + result->data = data; } else if (ret == ESP_ERR_NOT_FOUND) { result->code = TS_API_ERR_NOT_FOUND; result->message = strdup("Template not found"); - } else if (ret == ESP_ERR_INVALID_STATE) { - result->code = TS_API_ERR_BUSY; - result->message = strdup("service_delete_protected"); } else { result->code = TS_API_ERR_INTERNAL; result->message = strdup("Failed to delete template"); } - + free(detail.rules); return ESP_OK; } @@ -3996,93 +4051,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 efb6e72b..fdffd596 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 49bedcdb..2b10ba21 100644 --- a/components/ts_automation/include/ts_action_manager.h +++ b/components/ts_automation/include/ts_action_manager.h @@ -161,6 +161,8 @@ 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); +esp_err_t ts_action_templates_load_state(void); /*===========================================================================*/ /* SSH Host Management */ @@ -190,6 +192,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 @@ -465,6 +468,18 @@ esp_err_t ts_action_template_add(const ts_action_template_t *tpl); * @param id Template ID to remove * @return ESP_OK on success, ESP_ERR_NOT_FOUND if not exists */ +typedef struct { + char id[TS_AUTO_NAME_MAX_LEN]; + char name[TS_AUTO_LABEL_MAX_LEN]; +} ts_action_template_reference_t; +typedef struct { + const char *reason; + const char *check_reason; + bool references_confirmed; + size_t rule_count; + ts_action_template_reference_t *rules; /* Caller frees; NULL when details are unavailable. */ +} ts_action_template_delete_result_t; +esp_err_t ts_action_template_remove_checked(const char *id, ts_action_template_delete_result_t *result); esp_err_t ts_action_template_remove(const char *id); /** diff --git a/components/ts_automation/include/ts_rule_engine.h b/components/ts_automation/include/ts_rule_engine.h index 43f555cb..11942627 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 @@ -83,7 +84,12 @@ esp_err_t ts_rule_disable(const char *id); * @param id Rule ID * @return Rule pointer or NULL */ -typedef struct {int applied,durable,mirror_synced;uint32_t revision;const char *error_code;} ts_rule_commit_result_t; +typedef struct { + int applied, durable, mirror_synced; + uint32_t revision; + const char *error_code; + char missing_template_id[TS_AUTO_NAME_MAX_LEN]; /* Request-local diagnostic, never persisted. */ +} ts_rule_commit_result_t; esp_err_t ts_rule_acquire(const char *id,ts_auto_rule_t *out); void ts_rule_release(ts_auto_rule_t *rule); esp_err_t ts_rule_commit(const ts_auto_rule_t *candidate,const char *id,uint32_t expected_revision,ts_rule_commit_result_t *result); @@ -354,6 +360,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 00000000..13faf4e4 --- /dev/null +++ b/components/ts_automation/include/ts_rule_pack.h @@ -0,0 +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 127efed5..af2f8c5a 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,8 @@ 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; @@ -288,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 卡挂载,避免栈溢出)*/ @@ -303,7 +307,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 +341,26 @@ 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_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) { if (s_ctx == NULL) { @@ -391,7 +407,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 +442,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 +469,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 +482,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; } @@ -491,9 +509,28 @@ esp_err_t ts_action_get_ssh_host(const char *host_id, ts_action_ssh_host_t *host 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) { + 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 +3128,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 +3249,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 +3330,31 @@ 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_load_result=ret; + 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_load_result=ret; + 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,17 +3371,90 @@ 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; } -esp_err_t ts_action_template_remove(const char *id) { - if (!id) - return ESP_ERR_INVALID_ARG; +/* The edit gate holds binding before transaction: count/get/release cannot race a rule reload. */ +static esp_err_t template_references(const char *id, ts_action_template_delete_result_t *result) { + int total = ts_rule_count(); + size_t matches = 0; + for (int pass = 0; pass < 2; ++pass) { + size_t at = 0; + for (int i = 0; i < total; ++i) { + ts_auto_rule_t rule; + esp_err_t ret = ts_rule_get_by_index(i, &rule); + if (ret != ESP_OK) { + result->reason = "action_reference_check_unavailable"; + result->check_reason = "failed"; + result->references_confirmed = false; + ESP_LOGE(TAG, "Rule reference check failed: %s", esp_err_to_name(ret)); + return ret; + } + bool used = false; + for (unsigned n = 0; n < rule.action_count; ++n) + used |= !strcmp(rule.actions[n].template_id, id); + if (used) { + if (!pass) ++matches; + else { + memcpy(result->rules[at].id, rule.id, sizeof(rule.id)); + memcpy(result->rules[at].name, rule.name, sizeof(rule.name)); + ++at; + } + } + ts_rule_release(&rule); + } + if (!pass) { + if (!matches) return ESP_OK; + result->references_confirmed = true; + result->rule_count = matches; + result->reason = "action_in_use"; + result->rules = heap_caps_calloc(matches, sizeof(*result->rules), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!result->rules) return ESP_ERR_INVALID_STATE; /* Confirmed blocked, details optional. */ + } + } + return ESP_ERR_INVALID_STATE; +} +esp_err_t ts_action_template_remove_checked(const char *id, ts_action_template_delete_result_t *result) { + if (!id || !id[0] || !result) return ESP_ERR_INVALID_ARG; + *result = (ts_action_template_delete_result_t){0}; + bool loaded, recovery; + if (!ts_rule_edit_begin()) { + ts_rule_config_status(&loaded, &recovery); + result->reason = "action_reference_check_unavailable"; + result->check_reason = recovery ? "recovery" : loaded ? "busy" : "loading"; + return ESP_ERR_INVALID_STATE; + } ts_ssh_binding_lock(); - esp_err_t ret = - template_binding_protected(id, NULL) ? ESP_ERR_INVALID_STATE : template_remove_impl(id); + ts_rule_config_status(&loaded, &recovery); + esp_err_t ret; + if (!loaded || recovery) { + result->reason = "action_reference_check_unavailable"; + result->check_reason = recovery ? "recovery" : "loading"; + ret = ESP_ERR_INVALID_STATE; + } else { + ret = template_references(id, result); + if (ret == ESP_OK) { + ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,id,NULL); + if (ret != ESP_OK) { + result->reason = "action_reference_check_unavailable"; + result->check_reason = "failed"; + } else if (template_binding_protected(id, NULL)) { + result->reason = "service_delete_protected"; + ret = ESP_ERR_INVALID_STATE; + } else ret = template_remove_impl(id); + } + } ts_ssh_binding_unlock(); + ts_rule_edit_end(); + return ret; +} +esp_err_t ts_action_template_remove(const char *id) { + ts_action_template_delete_result_t result = {0}; + esp_err_t ret = ts_action_template_remove_checked(id, &result); + free(result.rules); return ret; } diff --git a/components/ts_automation/src/ts_automation.c b/components/ts_automation/src/ts_automation.c index c700e27e..1f6ccb27 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 e7f9060c..5b22813a 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,12 +85,15 @@ typedef struct { SemaphoreHandle_t mutex; // 访问互斥锁 bool initialized; bool loaded, recovery_error; + const char *load_error; int source; uint32_t next_instance; unsigned retired; 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; @@ -95,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; @@ -346,6 +362,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 +403,20 @@ 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. */ + 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 +425,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 +441,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 +451,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 +545,82 @@ 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, +/* Historical missing references may remain, but a write cannot introduce or multiply them. */ +static esp_err_t validate_new_template_refs(const ts_auto_rule_t *candidate, + const ts_auto_rule_t *old, + ts_rule_commit_result_t *result) { + ts_action_template_t *tpl = NULL; + esp_err_t ret = ESP_OK; + for (unsigned i = 0; i < candidate->action_count; ++i) { + const char *id = candidate->actions[i].template_id; + if (!id[0]) continue; + unsigned prior = 0, current = 0; + if (old) + for (unsigned k = 0; k < old->action_count; ++k) + prior += !strcmp(old->actions[k].template_id, id); + for (unsigned k = 0; k <= i; ++k) + current += !strcmp(candidate->actions[k].template_id, id); + if (current <= prior) continue; + if (!tpl) tpl = heap_caps_malloc(sizeof(*tpl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!tpl) { ret = ESP_ERR_NO_MEM; result->error_code = "no_memory"; break; } + ret = ts_action_template_get(id, tpl); + if (ret != ESP_OK) { + result->error_code = ret == ESP_ERR_NOT_FOUND ? "action_missing" : + ret == ESP_ERR_NO_MEM ? "no_memory" : "template_lookup_failed"; + if (ret == ESP_ERR_NOT_FOUND) + memcpy(result->missing_template_id, id, sizeof(result->missing_template_id)); + break; + } + } + free(tpl); + return ret; +} +/* 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 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 = "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 (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) { @@ -586,9 +708,13 @@ 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 (input && (ret = validate_new_template_refs(&candidate, i >= 0 ? &s_rule_ctx.rules[i] : NULL, result)) != ESP_OK) { + xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); + if (i >= 0) s_rule_ctx.meta[i].committing = false; + xSemaphoreGive(s_rule_ctx.mutex); + goto done; + } 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 +729,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 +742,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; @@ -641,6 +770,9 @@ esp_err_t ts_rule_commit(const ts_auto_rule_t *input, const char *id, uint32_t e } 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 { @@ -661,12 +793,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; } @@ -822,6 +960,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 && @@ -865,6 +1004,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; @@ -1793,11 +1933,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 +2093,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 +2108,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 +2116,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 +2156,23 @@ 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) { + 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; + } + 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) @@ -1996,9 +2199,13 @@ 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; xSemaphoreGive(s_rule_ctx.mutex); for (int i = 0; i < oldcount; ++i) @@ -2018,19 +2225,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; + if (!ts_ssh_binding_try_lock()) return false; + 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 +2255,225 @@ 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) return false; + xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); + xSemaphoreTake(s_rule_ctx.mutex,portMAX_DELAY); + bool confirmed=s_rule_ctx.loaded&&!s_rule_ctx.recovery_error, pending=false; + for(int i=0;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); +} +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;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; + 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; + 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; + } + 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) { + 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) { + /* 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;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; +} +/* 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; + } + /* 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;} + } + } + 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; + } + 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; + } + 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; +} +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_automation/src/ts_rule_store_set.c b/components/ts_automation/src/ts_rule_store_set.c new file mode 100644 index 00000000..4bb69bdc --- /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 00000000..2c184e10 --- /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 7d45a26e..16fc606c 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 577d7239..c5e4a9be 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 56d73042..db7c4cf2 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 4e9627fe..a1133f79 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 c90615ff..e50ef0d1 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 beb06199..10af8dad 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 00000000..e2138f30 --- /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 00000000..f1679854 --- /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 b121d4f7..9bab583b 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 fd54fe98..6078af86 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/include/ts_ssh_service.h b/components/ts_security/include/ts_ssh_service.h index d5e6eb5b..477480c0 100644 --- a/components/ts_security/include/ts_ssh_service.h +++ b/components/ts_security/include/ts_ssh_service.h @@ -45,6 +45,8 @@ bool ts_ssh_service_launch_command(const ts_ssh_command_config_t *cmd, const cha char *out, size_t capacity); void ts_ssh_binding_lock(void); +/* Nonblocking edit admission, same recursive gate as ordinary binding operations. */ +bool ts_ssh_binding_try_lock(void); void ts_ssh_binding_unlock(void); esp_err_t ts_ssh_service_pin(const ts_ssh_command_config_t *cmd, const char *host, uint16_t port, uint32_t *registration); void ts_ssh_service_unpin(const char *id, uint32_t registration); diff --git a/components/ts_security/src/ts_crypto.c b/components/ts_security/src/ts_crypto.c index 92767cfc..ecdfeaf8 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 60f530c6..66b8d348 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 26a223fd..30dff9c8 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_security/src/ts_ssh_service.c b/components/ts_security/src/ts_ssh_service.c index 7dd000dc..f3f05d02 100644 --- a/components/ts_security/src/ts_ssh_service.c +++ b/components/ts_security/src/ts_ssh_service.c @@ -42,16 +42,19 @@ static bool complete(service_t *s, uint32_t registration, uint32_t operation) { } void ts_ssh_binding_lock(void) { if (binding_gate) - xSemaphoreTake(binding_gate, portMAX_DELAY); + xSemaphoreTakeRecursive(binding_gate, portMAX_DELAY); +} +bool ts_ssh_binding_try_lock(void) { + return !binding_gate || xSemaphoreTakeRecursive(binding_gate, 0) == pdTRUE; } void ts_ssh_binding_unlock(void) { if (binding_gate) - xSemaphoreGive(binding_gate); + xSemaphoreGiveRecursive(binding_gate); } esp_err_t ts_ssh_service_init(void) { if (!binding_gate) - binding_gate = xSemaphoreCreateMutex(); + binding_gate = xSemaphoreCreateRecursiveMutex(); if (!mutex) mutex = xSemaphoreCreateMutex(); if (!services) diff --git a/components/ts_webui/CMakeLists.txt b/components/ts_webui/CMakeLists.txt index 0fd6e093..71cbbc80 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 2c7441a0..eb5afc6f 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/css/style.css b/components/ts_webui/web/css/style.css index c2683f3c..ea6f7f2a 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -202,6 +202,7 @@ input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer- .toast-error::before{background:var(--bad-dot)} .sheet{border-radius:32px;padding:24px} .sheet .sh{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px} +.confirm-sheet .st,.confirm-sheet .t-body{min-width:0;overflow-wrap:anywhere} .sheet .st{font-size:20px;line-height:26px;font-weight:600;letter-spacing:-.3px} .sheet .sf{display:flex;justify-content:flex-end;gap:8px;margin-top:20px} .modal-content{position:relative;width:calc(100% - 32px);max-width:600px;max-height:90vh;overflow-y:auto;padding:24px;border-radius:32px;background:rgba(250,251,253,.72);-webkit-backdrop-filter:blur(44px) saturate(1.9);backdrop-filter:blur(44px) saturate(1.9);border:1px solid rgba(255,255,255,.75);box-shadow:inset 0 1px 0 rgba(255,255,255,.85),0 0 0 .5px rgba(0,0,0,.10),0 24px 60px rgba(20,30,50,.22)} diff --git a/components/ts_webui/web/index.html b/components/ts_webui/web/index.html index d93c3208..a18d968e 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 b34d6b37..22dfef84 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -75,6 +75,7 @@ class ApiOperationError extends Error { this.name = 'ApiOperationError'; this.code = result?.code; this.rawMessage = apiErrorReason(result); + this.data = result?.data; this.operation = operation; this.httpStatus = result?.httpStatus; Object.assign(this, metadata); diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 79144989..054dfedc 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -5,7 +5,12 @@ 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']; + if (code === 'action_missing') { + const name = result.missingTemplateName || result.data?.missing_template_id; + return name ? t('deleteProtection.actionMissing', {name}) : runtimeText('saveFailed'); + } + if (code === 'template_lookup_failed') return runtimeText('saveFailed'); return known.includes(code) ? runtimeText(code) : (typeof code === 'string' ? code : runtimeText('saveFailed')); } async function ruleWriteWithRevision(method, id) { @@ -3833,7 +3838,7 @@ async function refreshQuickActions() { if (result.code === 0 && result.data && result.data.rules) { // 过滤出启用且标记为可手动触发的规则 if (!result.data.loaded || result.data.recovery_required) { - container.textContent = runtimeText(result.data.recovery_required ? 'recovery_required' : 'configLoading'); return; + renderQuickActionNotice(container, result.data.recovery_required ? 'recovery' : 'loading'); return; } const allRules = result.data.rules; const manualRules = allRules.filter(r => r.show_on_dashboard === true); @@ -3882,13 +3887,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}
`); } @@ -3900,12 +3907,7 @@ async function refreshQuickActions() { // 启动定时刷新服务状态(每 3 秒) startServiceStatusRefresh(); } else { - container.innerHTML = ` -
-

${typeof t === 'function' ? t('automationPage.noQuickActions') : '暂无快捷操作'}

-

${typeof t === 'function' ? t('automationPage.quickActionsHint') : '在自动化规则中启用"手动触发"选项'}

-
- `; + renderQuickActionNotice(container, 'empty'); } } else { container.innerHTML = '

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

'; @@ -4117,7 +4119,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'; @@ -8257,8 +8259,77 @@ async function saveSshCommandToBackend(hostId, cmdData, cmdId) { */ // Deletion needs fresh remote evidence: a missing registry entry is unknown, // including after a controller reboot. Reuse queued verification, never stop here. +// Only queued/executing operations are current work; terminal receipts are not busy evidence. +function deleteServiceMessage(data = {}) { + const pending = data.busy || ['queued', 'executing'].includes(data.operation_phase); + if (pending && ['start', 'stop', 'verify'].includes(data.operation_kind)) + return {start: 'starting', stop: 'stopping', verify: 'verifying'}[data.operation_kind]; + if (pending) return 'operationPending'; + if (data.state === 'stopped') return null; + if (['running', 'ready', 'checking'].includes(data.state)) return 'running'; + if (['starting', 'stopping'].includes(data.state)) return data.state; + if (['unknown', 'failed', 'timeout'].includes(data.state)) return 'unconfirmed'; + return 'unconfirmed'; +} +async function showDeleteProtection(result, context, pageCurrent) { + if (!pageCurrent()) return false; + const reason = apiErrorReason(result); + const data = result.data || {}; + let title, bodyHtml, target = null, primary; + if (reason === 'action_in_use') { + title = t('deleteProtection.actionTitle'); + const rules = data.details_available && Array.isArray(data.rules) ? data.rules : []; + bodyHtml = escapeHtml(t('deleteProtection.' + (rules.length ? 'actionInUse' : 'actionInUseWithoutDetails'), {name: context.name})); + if (rules.length) bodyHtml += '
' + rules.map(rule => + '
' + escapeHtml(rule.name || rule.id) + + '
' + escapeHtml(rule.id) + '
').join('') + '
'; + primary = t('deleteProtection.viewRules'); target = '/automation'; + } else if (reason === 'action_reference_check_unavailable') { + title = t('deleteProtection.actionTitle'); + const key = {busy: 'checkBusy', loading: 'checkLoading', recovery: 'checkRecovery'}[data.check_reason] || 'checkFailed'; + bodyHtml = escapeHtml(t('deleteProtection.' + key)); + primary = t('common.close'); + } else if (reason === 'service_delete_protected') { + title = t('deleteProtection.' + (context.type === 'action' ? 'templateTitle' : 'commandTitle'), {name: context.name}); + 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')); + if (context.type === 'command') primary = t('common.close'); + else { primary = t('deleteProtection.viewCommand'); target = '/commands'; } + } else return false; + const toast = document.getElementById('toast'); + if (toast?.classList.contains('toast-info') && toast.textContent === t('toast.processing')) { + clearTimeout(toastTimer); toast.classList.remove('show'); toast.classList.add('hidden'); toastDeadline = 0; + } + const pending = confirmSheet({title, bodyHtml, primary, secondary: target ? t('common.close') : false}); + let modal = document.getElementById('confirm-sheet-' + confirmSheetSeq); + if (typeof router !== 'undefined') router.navigation?.onDispose(() => { + if (modal?.isConnected) (modal.querySelector('button[data-r="0"]') || modal.querySelector('button[data-r="1"]'))?.click(); + }); + const choice = await pending; + modal = null; // A closed sheet must not stay retained by the navigation disposer. + if (choice && target && pageCurrent()) { + if (typeof router !== 'undefined') await router.navigate(target); + else window.location.hash = target; + } + return true; +} +function renderQuickActionNotice(container, state) { + const loading = state === 'loading', recovery = state === 'recovery'; + const isRoot = api.isRoot(); + const title = t(loading ? 'deleteProtection.quickLoadingTitle' : recovery ? 'deleteProtection.quickRecoveryTitle' : 'automationPage.noQuickActions'); + const body = loading ? runtimeText('configLoading') : recovery ? runtimeText('recovery_required') : + (isRoot ? t('automationPage.quickActionsHint') : t('deleteProtection.quickEmptyNeedRoot')); + const detail = loading ? (isRoot ? t('deleteProtection.loadingDetails') : t('deleteProtection.quickLoadingNeedRoot')) : + recovery ? (isRoot ? t('deleteProtection.recoveryDetails') : t('deleteProtection.quickRecoveryNeedRoot')) : ''; + container.innerHTML = '

' + escapeHtml(title) + '

' + escapeHtml(body) + + '

' + (detail ? '

' + escapeHtml(detail) + '

' : '') + + (isRoot ? '' : '') + '
'; +} + const configurationDeletesInFlight = new Set(); -async function verifyStoppedServiceForDelete(commandId, pageCurrent) { +async function verifyStoppedServiceForDelete(commandId, pageCurrent, context = {type: 'command', name: commandId}) { const revision = advanceServiceState(commandId); const isCurrent = () => pageCurrent() && revision === serviceStateVersions.get(commandId); try { @@ -8271,8 +8342,11 @@ async function verifyStoppedServiceForDelete(commandId, pageCurrent) { const data = await requestServiceControl(commandId, false, isCurrent); if (!data || !isCurrent()) return false; renderServiceCommand(commandId, data); - if (data.state !== 'stopped' || data.busy) - throw new ApiOperationError({code: 4, error: 'service_delete_protected'}); + if (deleteServiceMessage(data)) { + await showDeleteProtection({code: 4, error: 'service_delete_protected'}, + {...context, commandId, commandName: command.name || commandId, service: data}, isCurrent); + return false; + } return true; } catch (error) { if (!isCurrent()) return false; @@ -9432,7 +9506,7 @@ async function deleteCommand(idx) { if (!await confirmAction(typeof t === 'function' ? t('ui.confirmDeleteCmd', { name: cmd.name }) : `确定要删除指令「${cmd.name}」吗?`, { primary: t('common.delete'), tone: 'danger' }) || !pageCurrent()) return; if (cmd.id) { showToast(t('toast.processing'), 'info'); - if (!await verifyStoppedServiceForDelete(cmd.id, pageCurrent) || !pageCurrent()) return; + if (!await verifyStoppedServiceForDelete(cmd.id, pageCurrent, {type: 'command', name: cmd.name || cmd.id}) || !pageCurrent()) return; await deleteSshCommandFromBackend(cmd.id); } if (!pageCurrent()) return; @@ -9442,6 +9516,7 @@ async function deleteCommand(idx) { if (selectedHostId === hostId) refreshCommandsList(); showToast((typeof t === 'function' ? t('toast.commandDeleted') : '指令已删除'), 'success'); } catch (e) { + if (pageCurrent() && await showDeleteProtection(e, {type: 'command', name: cmd.name || cmd.id}, pageCurrent)) return; if (pageCurrent()) showToast((typeof t === 'function' ? t('toast.deleteCommandFailedMsg', { msg: e.message }) : '删除指令失败: ' + e.message), 'error'); } finally { configurationDeletesInFlight.delete(key); @@ -13048,7 +13123,7 @@ function confirmSheet({ title, body = '', bodyHtml = '', primary, tone = 'neutra else if (e.key === 'Enter' && tone !== 'danger' && e.target.tagName !== 'BUTTON') { e.preventDefault(); finish(true); } }; modal.innerHTML = sheet(width, escapeHtml(title), `
${bodyHtml || escapeHtml(body)}
`, - `` + + (secondary === false ? '' : ``) + (third ? `` : '') + ``); modal.onclick = e => { @@ -15246,7 +15321,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 +15356,19 @@ 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 deleting = r.pending_change === 'delete'; + 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(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('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(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('')} `; @@ -16850,11 +16930,13 @@ async function deleteAction(id) { showToast(t('toast.processing'), 'info'); const template = requireApiSuccess(await api.call('automation.actions.get', { id }), 'automation.actions.get').data; if (!pageCurrent()) return; + const context = {type: 'action', name: template?.name || id, commandId: template?.ssh_ref?.cmd_id}; if (template?.type === 'ssh_cmd_ref' && template.ssh_ref?.cmd_id && - !await verifyStoppedServiceForDelete(template.ssh_ref.cmd_id, pageCurrent)) return; + !await verifyStoppedServiceForDelete(template.ssh_ref.cmd_id, pageCurrent, context)) return; if (!pageCurrent()) return; const result = await api.call('automation.actions.delete', { id }); if (!pageCurrent()) return; + if (result.code !== 0 && await showDeleteProtection(result, context, pageCurrent)) return; showToast(typeof t === 'function' ? t('toast.deleteActionResult', { id }) + ': ' + (result.message || 'OK') : `删除动作 ${id}: ${result.message || 'OK'}`, result.code === 0 ? 'success' : 'error'); if (result.code === 0) await refreshActions(); } catch (error) { @@ -16870,7 +16952,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(); @@ -18276,11 +18358,11 @@ async function addActionTemplateRow(templateId = '', delayMs = 0, repeatMode = ' cachedActionTemplates.forEach(tpl => { const typeLabel = getActionTypeLabel(tpl.type); const selected = tpl.id === templateId ? 'selected' : ''; - optionsHtml += ``; + optionsHtml += ``; }); if (templateId && !cachedActionTemplates.some(tpl => tpl.id === templateId)) - optionsHtml += ``; + optionsHtml += ``; const rowId = actionRowCount; const showRepeatOptions = repeatMode !== 'once'; const hasCondition = condition && condition.variable; @@ -18567,6 +18649,7 @@ async function submitAddRule(originalId = null) { // 收集动作模板引用(包含 template_id、delay_ms、重复选项和动作条件) const actions = []; + const selectedTemplateNames = new Map(); for (const row of document.querySelectorAll('#add-rule-modal .action-row')) { if (row.dataset.inline === 'true') { actions.push(structuredClone(row._originalAction)); continue; } const templateId = row.querySelector('.action-template-id')?.value; @@ -18580,6 +18663,8 @@ async function submitAddRule(originalId = null) { if (hasCondition && !templateId) { fieldError('actions-container', t('ui.alertSelectAction')); return; } if (templateId) { + const select = row.querySelector('.action-template-id'); + selectedTemplateNames.set(templateId, select.selectedOptions?.[0]?.dataset.templateName || templateId); const actionRef = row._originalAction?.template_id === templateId ? structuredClone(row._originalAction) : {}; actionRef.template_id = templateId; actionRef.delay_ms = delay_ms; @@ -18641,6 +18726,8 @@ async function submitAddRule(originalId = null) { closeModal('add-rule-modal'); await Promise.all([refreshRules(), refreshAutomationStatus()]); } else { + if (result.data?.error_code === 'action_missing') + result.missingTemplateName = selectedTemplateNames.get(result.data.missing_template_id) || result.data.missing_template_id; showToast(runtimeSaveError(result), 'error'); } } catch (error) { @@ -18982,6 +19069,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 +19087,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.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(); + } + } 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 f8e20e85..56afd925 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -2,6 +2,54 @@ * TianShanOS WebUI - English Language Pack */ 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.", + "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": { + "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.", + "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.", @@ -12,6 +60,35 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { scalarRequired: "Comparison values support strings, numbers, booleans or null, not arrays or objects.", typeInvalid: "The comparison value conflicts with its original type. Check the input.", }, + deleteProtection: { + "actionTitle": "Can't delete action template", + "actionInUse": "Action template “{name}” is used by the rules below. Remove it from those rules, or delete rules you no longer need, before deleting the template.", + "actionInUseWithoutDetails": "Action template “{name}” is still used by rules, so it can't be deleted. Remove it from those rules first.", + "viewRules": "View rules", + "commandTitle": "Can't delete command “{name}”", + "templateTitle": "Can't delete action template “{name}”", + "boundCommand": "This action template uses command “{command}”.", + "running": "The service for command “{command}” is still running. Stop the service and confirm it has stopped before deleting.", + "unconfirmed": "It hasn't been confirmed that the service for command “{command}” has stopped. On the SSH Commands page, select the refresh icon for this command to verify its state. If it is still running, stop it before deleting.", + "starting": "The service for command “{command}” is starting. Wait for the operation to finish before deleting.", + "stopping": "The service for command “{command}” is stopping. Wait for the operation to finish before deleting.", + "verifying": "The state of the service for command “{command}” is being verified. Wait for verification to finish before deleting.", + "operationPending": "An operation on the service for command “{command}” is still in progress. Wait for it to finish before deleting.", + "viewCommand": "View commands", + "checkBusy": "The automation configuration is being updated. The action template was not deleted. Wait for the update to finish before deleting.", + "checkLoading": "The rule configuration is still loading. The action template was not deleted. Wait for loading to finish before deleting.", + "checkRecovery": "The rule configuration needs a recovery check. The action template was not deleted. Open “System Logs” on the Terminal page for details.", + "checkFailed": "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.", + "actionMissing": "The rule was not saved. Action template “{name}” no longer exists; it may have been deleted. Select an existing action template and save again.", + "quickLoadingTitle": "Loading quick actions", + "quickRecoveryTitle": "Quick actions unavailable", + "recoveryDetails": "Open “System Logs” on the Terminal page for details.", + "goAutomation": "Go to Automation", + "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." +}, promptRepair: { serviceDeleteProtected: 'The bound service is not confirmed stopped, or an operation is using it. Verify or stop the service and wait for operations to finish before deleting this configuration.', taskResults: 'Configuration task history', @@ -235,6 +312,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', @@ -3045,7 +3123,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { triggerConditions: 'Trigger Conditions', // Quick actions noQuickActions: 'No quick actions', - quickActionsHint: 'Enable "Manual Trigger" option in automation rules', + 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', diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 66631573..841bf9a2 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -2,6 +2,54 @@ * TianShanOS WebUI - 简体中文语言包 */ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { + rulePack: { + pendingDelete: "已保存删除,待重启移除", + content: "查看规则内容", + overwriteLabel: "覆盖影响", + overwriteImpact: "替换已保存的第 {revision} 版;重启前仍运行当前版本。", + "disabledDependency": "此规则已禁用,部分依赖也已禁用;请在启用前检查依赖状态。", + "pending": "已保存,待重启", + "runCurrent": "执行正在使用的旧版本", + "exportCurrent": "导出正在使用的版本", + "verified": "来源、目标设备和规则内容已验证。", + "saved": "已保存,重启后生效;当前运行版本未改变。", + "alreadyActive": "此配置已保存,当前运行版本已是这一版。", + "unknown": "保存结果尚未确认,请读取规则列表核对,不要重复导入。", + "cleanupPending": "旧存储对象尚待清理,请保留 SD 卡并在重启后核对。", + "dynamicInputs": "条件变量可能由运行时产生;没有采样值不代表配置缺失。", + "summaryLabel": "规则摘要", + "summary": "{conditions} 项条件,{actions} 项动作", + "verifyFailed": "未能完成验证,请检查连接后重新选择文件。", + "errors": { + "action_unsupported": "配置包使用了当前固件尚不支持的动作。", + "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 字节)。", @@ -12,6 +60,35 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { scalarRequired: "比较值仅支持字符串、数字、布尔值或 null,不支持数组和对象。", typeInvalid: "比较值与原始类型不一致,请检查输入。", }, + deleteProtection: { + "actionTitle": "无法删除动作模板", + "actionInUse": "动作模板“{name}”正被以下规则使用。请先把它从这些规则中移除,或删除不再需要的规则,再删除动作模板。", + "actionInUseWithoutDetails": "动作模板“{name}”仍被规则使用,无法删除。请先把它从使用它的规则中移除。", + "viewRules": "查看规则", + "commandTitle": "无法删除指令“{name}”", + "templateTitle": "无法删除动作模板“{name}”", + "boundCommand": "此动作模板使用指令“{command}”。", + "running": "指令“{command}”对应的服务仍在运行。请先停止服务,确认已停止后再删除。", + "unconfirmed": "还没有确认指令“{command}”对应的服务是否已停止。请先在「指令」页点这条指令的刷新图标核验状态;如果它仍在运行,请先停止,再删除。", + "starting": "正在启动指令“{command}”对应的服务。请等待操作完成后再删除。", + "stopping": "正在停止指令“{command}”对应的服务。请等待操作完成后再删除。", + "verifying": "正在核验指令“{command}”对应的服务状态。请等待核验完成后再删除。", + "operationPending": "指令“{command}”对应的服务还有操作未完成。请等待操作完成后再删除。", + "viewCommand": "查看指令", + "checkBusy": "自动化配置正在更新,本次未删除动作模板。请等待更新完成后再删除。", + "checkLoading": "规则配置仍在加载,本次未删除动作模板。请等待加载完成后再删除。", + "checkRecovery": "规则配置需要恢复核对,本次未删除动作模板。请在「终端」页打开「系统日志」查看详情。", + "checkFailed": "没能确认是否还有规则在使用这个动作模板,所以没有删除。请在「终端」页打开「系统日志」查看原因。", + "actionMissing": "规则没有保存:动作模板“{name}”已不存在,可能已被删除。请重新选择动作模板后再保存。", + "quickLoadingTitle": "快捷操作正在加载", + "quickRecoveryTitle": "快捷操作暂不可用", + "recoveryDetails": "请在「终端」页打开「系统日志」查看详情。", + "goAutomation": "前往自动化", + "loadingDetails": "如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。", + "quickLoadingNeedRoot": "如果长时间没有变化,请联系拥有 root 权限的用户查看原因。", + "quickRecoveryNeedRoot": "请联系拥有 root 权限的用户处理。", + "quickEmptyNeedRoot": "如需添加快捷操作,请联系拥有 root 权限的用户。" +}, promptRepair: { serviceDeleteProtected: '该配置绑定的服务尚未确认停止,或有操作正在使用它。请先核验或停止服务,待操作完成后再删除。', taskResults: '配置应用任务记录', @@ -235,6 +312,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, runtimeRepair: { + restart_pending: "此规则已有待重启版本,普通编辑、启停和删除暂不可用。", configLoading: '配置正在加载,请稍后刷新。', confirmFingerprint: '请通过可信渠道核对以下服务器指纹。确认信任此指纹并继续连接?', saved: '已保存', @@ -3007,7 +3085,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { triggerConditions: '触发条件', // 快捷操作 noQuickActions: '暂无快捷操作', - quickActionsHint: '在自动化规则中启用"手动触发"选项', + quickActionsHint: "没有规则勾选「在首页显示」。你可以在「自动化」页新建规则,或编辑已有规则并勾选「在首页显示」。", loadQuickActionsFailed: '无法加载快捷操作', processRunning: '进程运行中', processNotRunning: '进程未运行', diff --git a/docs/repair/delete-protection/COPY.md b/docs/repair/delete-protection/COPY.md new file mode 100644 index 00000000..3b03551e --- /dev/null +++ b/docs/repair/delete-protection/COPY.md @@ -0,0 +1,36 @@ +# 删除保护:实际使用的已确认文案 + +日期:2026-10-09。来源:用户确认的方案 v2 + 第二轮第 6 节;以下逐字取自实际语言包。 + +| key | 中文 | English | +|---|---|---| +| deleteProtection.actionTitle | 无法删除动作模板 | Can't delete action template | +| deleteProtection.actionInUse | 动作模板“{name}”正被以下规则使用。请先把它从这些规则中移除,或删除不再需要的规则,再删除动作模板。 | Action template “{name}” is used by the rules below. Remove it from those rules, or delete rules you no longer need, before deleting the template. | +| deleteProtection.actionInUseWithoutDetails | 动作模板“{name}”仍被规则使用,无法删除。请先把它从使用它的规则中移除。 | Action template “{name}” is still used by rules, so it can't be deleted. Remove it from those rules first. | +| deleteProtection.viewRules | 查看规则 | View rules | +| deleteProtection.commandTitle | 无法删除指令“{name}” | Can't delete command “{name}” | +| deleteProtection.templateTitle | 无法删除动作模板“{name}” | Can't delete action template “{name}” | +| deleteProtection.boundCommand | 此动作模板使用指令“{command}”。 | This action template uses command “{command}”. | +| deleteProtection.running | 指令“{command}”对应的服务仍在运行。请先停止服务,确认已停止后再删除。 | The service for command “{command}” is still running. Stop the service and confirm it has stopped before deleting. | +| deleteProtection.unconfirmed | 还没有确认指令“{command}”对应的服务是否已停止。请先在「指令」页点这条指令的刷新图标核验状态;如果它仍在运行,请先停止,再删除。 | It hasn't been confirmed that the service for command “{command}” has stopped. On the SSH Commands page, select the refresh icon for this command to verify its state. If it is still running, stop it before deleting. | +| deleteProtection.starting | 正在启动指令“{command}”对应的服务。请等待操作完成后再删除。 | The service for command “{command}” is starting. Wait for the operation to finish before deleting. | +| deleteProtection.stopping | 正在停止指令“{command}”对应的服务。请等待操作完成后再删除。 | The service for command “{command}” is stopping. Wait for the operation to finish before deleting. | +| deleteProtection.verifying | 正在核验指令“{command}”对应的服务状态。请等待核验完成后再删除。 | The state of the service for command “{command}” is being verified. Wait for verification to finish before deleting. | +| deleteProtection.operationPending | 指令“{command}”对应的服务还有操作未完成。请等待操作完成后再删除。 | An operation on the service for command “{command}” is still in progress. Wait for it to finish before deleting. | +| deleteProtection.viewCommand | 查看指令 | View commands | +| deleteProtection.checkBusy | 自动化配置正在更新,本次未删除动作模板。请等待更新完成后再删除。 | The automation configuration is being updated. The action template was not deleted. Wait for the update to finish before deleting. | +| deleteProtection.checkLoading | 规则配置仍在加载,本次未删除动作模板。请等待加载完成后再删除。 | The rule configuration is still loading. The action template was not deleted. Wait for loading to finish before deleting. | +| deleteProtection.checkRecovery | 规则配置需要恢复核对,本次未删除动作模板。请在「终端」页打开「系统日志」查看详情。 | The rule configuration needs a recovery check. The action template was not deleted. Open “System Logs” on the Terminal page for details. | +| deleteProtection.checkFailed | 没能确认是否还有规则在使用这个动作模板,所以没有删除。请在「终端」页打开「系统日志」查看原因。 | 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. | +| deleteProtection.actionMissing | 规则没有保存:动作模板“{name}”已不存在,可能已被删除。请重新选择动作模板后再保存。 | The rule was not saved. Action template “{name}” no longer exists; it may have been deleted. Select an existing action template and save again. | +| deleteProtection.quickLoadingTitle | 快捷操作正在加载 | Loading quick actions | +| deleteProtection.quickRecoveryTitle | 快捷操作暂不可用 | Quick actions unavailable | +| deleteProtection.recoveryDetails | 请在「终端」页打开「系统日志」查看详情。 | Open “System Logs” on the Terminal page for details. | +| deleteProtection.goAutomation | 前往自动化 | Go to Automation | +| deleteProtection.loadingDetails | 如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。 | If nothing changes for a long time, open “System Logs” on the Terminal page to see why. | +| 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. | + +C 组不使用 goAutomation;首页三个状态仅 root 使用该跳转入口。指令自身的服务阻断框只有 common.close,模板场景保留 viewCommand。 +root 空状态 quickActionsHint 已改为「在首页显示」,加载/恢复正文复用现有 runtimeRepair.configLoading/recovery_required;后端服务兜底复用 promptRepair.serviceDeleteProtected。非 root 加载/恢复的补充句与空状态正文使用上述三个 NeedRoot 新键,用户已确认定稿,无占位符,逐字采用。 diff --git a/docs/repair/delete-protection/INTEGRATION.md b/docs/repair/delete-protection/INTEGRATION.md new file mode 100644 index 00000000..a1f5ed1d --- /dev/null +++ b/docs/repair/delete-protection/INTEGRATION.md @@ -0,0 +1,68 @@ +# PR #47:删除保护与规则包导入整合验证 + +2026-10-10。整合起点为 PR #47 的 `796b00ae0d9c2a4e7750af8d3fa0e06e29daf2f5`, +合入已审查的 `df540286a671840e03260e995a2a32da4927537c`(含原实现 `af6fcf26` 和 R1–R3 修复)。 +用一个新增 merge commit 保留两边历史;该提交同时用于个人仓库 main 和现有 PR 来源分支。 +上游 PR 保持未合并。原工作区及未跟踪文件没有作为整合输入,也未被覆盖。 + +## 冲突处理与必要联动 + +- 中英文 README 和头图保持 PR 原 HEAD 的完整字节,不恢复旧正文或旧图片。 +- 普通保存同时保留“新增/增加缺失模板引用拒绝”的既有检查与 R1 的保存/运行状态检查。 +- 模板删除先保留既有全部运行规则引用诊断,再检查已保存包依赖,最后走原服务保护/移除出口。 + 待重启包引用同样阻止删除;无法取得可靠诊断时返回已有检查失败表达,不编造规则详情。 +- 页面保留删除保护的首页加载/恢复/空状态及入口;待删除触发禁用、导入回读保护与双语错误保持。 +- 共同 binding gate 改为递归 mutex,所有取得/释放匹配;新增同一 gate 的非阻塞 try 接口。 + edit gate 保持 binding→transaction 顺序,繁忙时立即拒绝,兼容 API/导入/加载/模板删除的同线程嵌套。 + 没有调整网络执行器、服务状态政策、分区、完整集合 selector 或证书信任策略。 +- 仅适配既有生产函数提取清单和 RTOS 测试边界:真实零超时使用 trylock,函数提取正确处理单行定义。 + POSIX 编译声明适配 Linux 严格 C11;不减弱断言、不更换旧 HTTP 字节 fixtures。 + +## 当前整合树验证 + +固定 ESP-IDF v5.5.2,macOS arm64,Apple Clang 21,原有 Node/Playwright 依赖。 +临时 PKI 与 NVS/SD/RTOS/SSH 边界夹具不代表物理设备验收。 + +| 命令 | 最终退出码 | 结果 | +|---|---:|---| +| `IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf bash tests/runtime/run_all.sh` | 0 | 全部原有 runtime、规则包 crypto/store/engine、A/B/C,以及删除保护 H01–H14 与 8 份真实 HTTP 字节通过;包含 1851 存储故障场景 | +| `python3 tests/runtime/test_delete_reference.py` | 0 | 实际生产 edit/commit/delete 路径,H01–H14、并发顺序、拒绝无写入、历史引用次数、8 份字节一致 | +| `bash tests/runtime/run_service_watch.sh` | 0 | 原服务回归及实际 production binding gate 的递归持有、跨线程立即拒绝、平衡释放通过 | +| `node --test tests/prompts/delete-protection.test.cjs tests/prompts/delete-reference.test.cjs tests/prompts/static.test.cjs` | 0 | 33/33 | +| `node --test tests/prompts/delete-protection.browser.test.cjs tests/prompts/delete-reference.browser.test.cjs tests/prompts/rule-pack.browser.test.cjs` | 0 | 58/58:38 原删除保护+20 规则包双语浏览器场景 | +| `source /Users/massif/esp/v5.5.2/esp-idf/export.sh && idf.py -B /tmp/tianshan-pr47-integration-build -DSDKCONFIG=/tmp/tianshan-pr47-integration-sdkconfig build` | 0 | 当前合并源码 ESP32-S3 app 与 SPIFFS 完整构建成功,隔离配置/输出 | +| `git diff --check` | 0 | 冲突标记清除、空白检查通过 | + +规则包生产夹具追加了真实 `ts_action_template_remove_checked()` 的两种时点:保存待重启与实际运行。 +拒绝时 template_remove 出口计数为零;运行版本的引用详情仍列出对应规则。 + +验证途中修正了旧提取夹具的单行函数边界、零超时模拟;首次 Chrome/SDK 运行受沙箱限制, +在授权环境重跑成功。表格报告最终状态,不把这些未成功的初次尝试当作通过证据。 + +构建在合并提交前、相同最终生产源码上进行,版本元数据仍取起点 HEAD;二进制不作为正式发布物。 +`TianShanOS.bin` SHA-256:`6202b10e7b819e76b4eae05a1216688d8f11d17da9599387f91fb06f18c86768`。 +`www.bin` SHA-256:`367866366f92f605f4cde0093faf6dcd1bdefe223e0568fdc2d24aa30681456f`。 + +本机原始日志摘要(临时目录可能被系统清理): + +- `/tmp/tianshan-pr47-integration-runtime-final.log` SHA-256 `8968ee765e6874ec0e9768163579870e06d7c67eebfa98efa0aa9cf92e2b8d8b` +- `/tmp/tianshan-pr47-integration-delete-final.log` SHA-256 `e9f9af9f719a3d6dc95b8a7f982831438b0dd8c2bb2c0291e11c65bf59419d96` +- `/tmp/tianshan-pr47-integration-binding.log` SHA-256 `207b853b27949387940567bd141d45aae0470036fa3991827042295097cd3a08` +- `/tmp/tianshan-pr47-integration-unit.log` SHA-256 `2c0c468a14087d60a89ffbbf371350b4de606fa7477e63c4ae76b32ed38427cb` +- `/tmp/tianshan-pr47-integration-browser.log` SHA-256 `b3c6ad34c9756c59d3c63f4cb097f42ade70b595382ceabd77fbbaa22e9727f3` +- `/tmp/tianshan-pr47-integration-build.log` SHA-256 `ffc8fa6627d65f327d706537341665aedc84c774e57c6e40c125a790c58b3e2d` + +## 未执行与发布边界 + +未操作设备、刷机、重启、执行真实 SSH、合并上游 PR、打标签或发布 Release。 +真实签名根、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/docs/repair/delete-protection/PLAN.md b/docs/repair/delete-protection/PLAN.md new file mode 100644 index 00000000..6a75e3ba --- /dev/null +++ b/docs/repair/delete-protection/PLAN.md @@ -0,0 +1,65 @@ +# 删除保护与快捷操作提示:已确认的实施方案 v4(第三轮方案 v2) + +日期:2026-10-10。用户已明确回复“确认,按第二轮文案实施”,并批准第三轮方案 v2 及三句非 root 首页说明。 + +本版本采用原《修改方案与交接.md》v2,以及《评审意见-第二轮.md》的修订。本文件记录落地边界,配套 COPY.md 是实际语言包文案,RESULT.md 记录验证与剩余边界。 + +## 工作位置与范围 + +- 分支:fix/automation-delete-protection。 +- 独立目录:/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS。 +- 起点:main / 050757bb8046206797804ba5edc110ae3244bf17(0.6.1)。 +- 不修改主目录,不合入另一 AI 的 SSH 指纹分支。 +- 不提交、推送、合并、发布或刷机;不改变 SD/NVS 存储策略,不自动修复历史断裂引用。 + +## 固件合同 + +1. 删除模板在 ts_action_template_remove_checked 内先零等待取得规则 transaction,再取得 SSH binding;检查已加载且无需恢复,再遍历所有规则,包含停用规则,并去重收集 id/name。 +2. 有引用拒绝删除,不能调用 template_remove_impl 或 API 的 unlink;列表细节分配失败仍拒绝删除。无法完成检查与已确认被引用是两种结果。 +3. 拿不到 transaction 时通过 ts_rule_config_status 判断 recovery/loading/busy;未初始化按 loading,不能误报更新。 +4. 老入口 ts_action_template_remove 委托同一实现,避免另一删除入口绕过保护。 +5. 新建/新增/增加次数的模板引用,在 ts_rule_commit 的 transaction + binding 内、持久化之前验证存在。缺失返回 action_missing 和可靠 ID;内部/内存错误不说“已不存在”。 +6. 既有缺失 ID 及原有出现次数可以保留,历史展示编辑、启停维持原行为;不扩大引用,原执行保护不放宽。 +7. 缺失引用拒绝时清除 committing,释放全部锁和负载;SD/NVS 不写,revision 不增长,后续触发/保存/启停不被锁死。 +8. 490 行缺失模板时的原服务保护保留;不会把缺失配置当成远程服务停止。 +9. API 业务失败仍返回 ESP_OK + 非零 result.code,让当前 main 的真实 HTTP 成功包络保留 message/data;不依赖 SSH 分支的新通用出口。 + +## 前端与文案 + +- 第二轮第 6 节的 unconfirmed/checkFailed/actionMissing/loadingDetails 与无列表英文已采用;C 组仅有“关闭”。 +- 模板名来自用户实际选择的 option.data-template-name,取不到用 ID;后端不编造名称。保存失败保留草稿。响应详情丢失时使用既有 saveFailed,不显示空名称或原始 action_missing。 +- runtimeSaveError 保留 action_missing 的名称提示,将本轮新增原因 template_lookup_failed 映射到现有 saveFailed;不扩展其他原因。新文案在 deleteProtection 命名空间。 +- 查看规则以及从模板阻断框查看指令只改变路由,不定位、不高亮、不自动编辑或删除;指令自身被阻断时只有关闭,不重载当前页。 +- 首页 recovery > loading > 空状态,保留面板;root 原说明及前往自动化入口不变,所有非 root 无按钮,按状态使用 quickLoadingNeedRoot/quickRecoveryNeedRoot/quickEmptyNeedRoot 三句用户确认说明。不泛化提示用户检查 SD 卡。 +- 使用现有 sheet/confirmSheet、系统字体及语义色。仅增加确认框长标题换行,并约束引用列表文字列的宽度。 +- 本次“处理中”提示在阻断框出现时立即隐藏;不清除其他失败/警告。关闭后释放列表 DOM 的导航回调引用,离开页面关闭旧框。 +- app.js 查询标识及语言包使用的 TS_ASSET_VERSION 为 delete2;api.js/CSS 保持 delete1。xterm 地址随 TS_ASSET_VERSION 变化,内容未改;发布版本仍为 0.6.1。 + +## 服务状态完整映射 + +先判定未完成操作:busy=true,或 operation_phase 是 queued/executing。若 operation_kind 是 start/stop/verify,分别显示 starting/stopping/verifying;操作类型未知显示 operationPending。终态 receipt 不当成仍在操作。 + +在没有未完成操作时: + +| state | 文案键/处理 | +|---|---| +| running / ready / checking | running:仍在运行 | +| starting | starting:正在启动 | +| stopping | stopping:正在停止 | +| unknown / failed / timeout | unconfirmed:尚未确认已停止 | +| stopped | 允许进入最终删除检查 | +| 未来未知或缺失状态 | unconfirmed,保持拒绝 | + +预检后又被后端拒绝,使用已有 serviceDeleteProtected,不用旧状态猜新原因。原有核验失败、超时、网络错误反馈保留;不自动停止、不增轮询。 + +## 验证增补 + +- H13:缺失模板拒绝后 committing=false,随后有效保存、启停和手动触发仍可用。 +- H14:事务被加载占用或未初始化时显示 loading,更新占用时显示 busy。 +- H15:9 state × 2 busy × 4 kind × 3 phase × 2 language,共 432 个映射分支。 +- 两种保存/删除顺序使用持久化屏障实际并发验证;不只按顺序调用替身算法。 +- 规则/模板名称上限47字节、ID63字节;中文/引号/HTML符号;32条引用去重;最大标题手机/桌面不裁切。 +- 同一对象先被服务阻止,确认停止后的下一次删除再被引用阻止,两次说明各自准确,没有旧框残留。 +- 生产函数产生的 HTTP 字节原样回放实际 api.js 和按钮,不替换 api.call。 + +上述验证证据见 RESULT.md;不将主机或浏览器结果当作真实热拔插、断电或远程服务验收。 diff --git a/docs/repair/delete-protection/RESULT.md b/docs/repair/delete-protection/RESULT.md new file mode 100644 index 00000000..d80d4054 --- /dev/null +++ b/docs/repair/delete-protection/RESULT.md @@ -0,0 +1,115 @@ +# 删除保护修复:实现与验证交付 + +2026-10-09。本地实现已完成,等待用户/另一 AI 的代码审查;没有提交、推送、合并、发布或刷机。 + +## 位置与基线 + +- **分支**:`fix/automation-delete-protection`。 +- **独立工作目录**:`/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS`。 +- **基线**:`main` / `050757bb8046206797804ba5edc110ae3244bf17`(0.6.1)。 +- 修改保留为该工作目录中的未提交文件;仅 checkout 分支名不能在另一目录取得这些尚未提交的内容,审查请直接进入上面的工作目录。 +- `/Users/massif/TianshanOS` 仍在 main,tracked diff 为空;原有未跟踪文档/工具保留。 +- 没有合入 `fix/ssh-fingerprint-confirm`,没有修改通用 HTTP 失败出口或密钥逻辑。 + +## 用户可见结果 + +1. 动作模板仍被规则使用时,删除被拒绝,说明框列出具体规则;包含停用规则,重复引用不重复列出。解除引用后才可删除。 +2. 另一个页面先删除模板后,用旧选择保存新引用会被拒绝,提示用户实际选过的模板名称;草稿保留。后端只返回可靠 ID。 +3. 保存拒绝后 `committing` 清除,原规则仍能手动触发、再保存、启停。 +4. 模板删除拿不到事务时,区分仍在加载、正在更新与恢复异常;无法确认引用时不谎报已确认被引用。 +5. 服务阻断按九种状态与未完成操作分流;用户删模板时标题/关系说明也明确模板与指令的关系。 +6. 首页区分没有快捷操作、正在加载和恢复异常,保留面板及前往自动化入口。 +7. “查看规则/查看指令”只跳转,不定位、不高亮、不自动停止或删除。C 组只有关闭。 +8. 现有 sheet 的字体/色彩/容器保持;长名称、长标题和63字节ID可以换行,手机/桌面不裁切。阻断框出现时,本次“处理中”提示立即隐藏,不遮挡按钮。 + +## 技术边界 + +- 删除在 transaction → binding → 短期规则/模板 mutex 的顺序下完成检查和原移除实现;不拿反向锁、不新增锁或常驻任务。 +- 新增或增加次数的缺失引用不能保存;历史缺失引用的原有出现次数可保留,不自动修复,不放宽执行保护。 +- 490 行的原服务保护保持。 +- API 业务失败保持 ESP_OK + 非零 code,data 从 main 的生产成功包络带出。细节无法生成/丢失时仍拒绝并有回退。 +- 不改变 SD/NVS 加载、镜像、导入和存储格式,不根据“拔卡”推断内部备份必然可用。 +- 现有模板实际持久化、外部手动改卡、热拔插与断电恢复并未因本轮主机模拟获得真机验收。 + +## 上一轮验证(第三轮实施前的源码) + +| 检查 | 实际结果 | 证据 | +|---|---|---| +| 运行时总入口 `IDF_PATH=… bash tests/runtime/run_all.sh` | 通过,包含原存储、服务、SSH、规则生命周期等回归 | [runtime.log](evidence/runtime.log) | +| 生产删除/提交/HTTP字节 | 通过,8个固定响应;两种并发顺序用屏障控制;拒绝时无移除/文件清理/提交;历史次数、committing、47/63边界 | [reference-wire.log](evidence/reference-wire.log) | +| 静态/交互总入口 `npm --prefix tests/prompts test` | **126/126,通过** | [unit.log](evidence/unit.log) | +| 实际 Chrome 浏览器总入口 `npm --prefix tests/prompts run test:browser` | **262/262,通过** | [browser.log](evidence/browser.log) | +| H15 状态矩阵 | 9×2×4×3×2,共432个映射分支,中英都不显示原枚举 | `tests/prompts/delete-reference.test.cjs` | +| 完整 ESP32-S3 固件与 SPIFFS 构建 | 通过,最终源码;构建版本 `0.6.1+050757bbd.10092140` | [build.log](evidence/build.log) | +| diff 空白/语法与主目录隔离 | `git diff --check` 通过;主目录 tracked diff 为空 | 本工作目录与主目录的 git status/diff | + +生产函数测试的 RTOS、存储、SSH 等外围使用合成边界,不连接设备。浏览器原样回放生产 HTTP 字节,实际 api.js、按钮、确认框和路由参与;没有用假的 api.call 绕过出口。 + +视觉验证包括中英文、390/1440宽度、32条引用、名称47/ID63字节、最大服务标题、子元素裁切、进度提示遮挡、Esc/Enter/关闭、双重阻断、迟到操作与页面离开。 + +## 上一轮资源与工件(不代表第三轮净增量或新构建) + +- 最终 ELF 类型尺寸:引用条目112B、删除诊断20B、提交诊断84B(新增64B请求局部ID),规则结构仍240B。 +- 引用细节默认32条最多3584B临时PSRAM;配置上限64条时7168B。新增引用存在性查询使用一个1224B临时模板缓冲,退出即释放。 +- 无新增常驻任务、每条规则持久化字段、分区或轮询频率。 +- 六个修改的 Web 源文件按相同 gzip 参数测得净增加 **2807B**;这是源文件gzip对比,不是假称实际网络包或板卡内存测量。实际SPIFFS构建通过。 +- [类型尺寸](target-sizes.txt)、[逐文件资源变化](resource-deltas.json)。 +- 本地待审工件:`build/delete-protection/TianShanOS.bin` 与 `www.bin`,以及配套 bootloader/partition/ota 数据;[SHA-256清单](evidence/artifacts.json)。未写入设备。 +- [源文件哈希](evidence/source-hashes.json)对应本次本地实现,便于审查时识别后续变动。 + +## 文案与上一轮截图 + +- [已确认实施方案 v3](PLAN.md)。 +- [实际中英文文案](COPY.md):包含用户确认的第二轮修订;占位参数一致。 +- [中文手机](../../../output/playwright/delete-protection/zh-CN-390.png) / [中文桌面](../../../output/playwright/delete-protection/zh-CN-1440.png)。 +- [英文手机](../../../output/playwright/delete-protection/en-US-390.png) / [英文桌面](../../../output/playwright/delete-protection/en-US-1440.png)。 +- 同目录截图旁的 `*-style.json` 记录实际字体、圆角、宽度。 + +## 下一位 AI 的代码审查入口 + +请在上面的独立工作目录执行只读 `git diff` 并查看新增未跟踪文件,重点核查: + +- `ts_action_template_remove_checked` 的事务/绑定顺序、列表去重、确认结论与细节可用性的分离、lease释放及拒绝后无副作用。 +- `validate_new_template_refs` 的旧/新次数比较、NOT_FOUND与读错误区分,拒绝分支清除committing,历史兼容不放宽执行保护。 +- API真实包络及前端回退、选择名称来源、失效页面回调、单按钮关闭、处理中提示与后续toast正常恢复。 +- COPY.md是否逐字符合已批准文案,long-title/list样式是否仍符合既有设计规则。 +- 上述日志和工件确实对应当前源码;若审查后修改源码,重跑与修改相关的检查,而不是继承旧PASS。 + +审查不等于授权推送、合并、发布或刷机。原历史断裂配置仍保留原安全保护,不能宣称本轮自动清理了所有现场旧配置。 + + +## 第三轮补记: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、合并或刷机,未修改其他分支/工作目录。 + + +## 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/artifacts.json b/docs/repair/delete-protection/evidence/artifacts.json new file mode 100644 index 00000000..23103c36 --- /dev/null +++ b/docs/repair/delete-protection/evidence/artifacts.json @@ -0,0 +1,27 @@ +[ + { + "path": "build/delete-protection/TianShanOS.bin", + "bytes": 2203840, + "sha256": "88bdf5decf53a6b78ac1cfec6ca2945bc61804f856cbd0937b1c9377481e7b7a" + }, + { + "path": "build/delete-protection/www.bin", + "bytes": 3145728, + "sha256": "a940b8126b63bf3bf2616908779fc96d7634715ea22e8230d290f5ede4e3feb2" + }, + { + "path": "build/delete-protection/bootloader/bootloader.bin", + "bytes": 22496, + "sha256": "f1b82145e7489946f3a0e3de25dc82551acd05851453efa650f0f174e7baf510" + }, + { + "path": "build/delete-protection/partition_table/partition-table.bin", + "bytes": 3072, + "sha256": "973dfb7f44bdd42d525e53fcc1eb06ac5745687dddc1c73223715df841d785e5" + }, + { + "path": "build/delete-protection/ota_data_initial.bin", + "bytes": 8192, + "sha256": "7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f" + } +] diff --git a/docs/repair/delete-protection/evidence/browser.log b/docs/repair/delete-protection/evidence/browser.log new file mode 100644 index 00000000..b7ec8cd7 --- /dev/null +++ b/docs/repair/delete-protection/evidence/browser.log @@ -0,0 +1,280 @@ + +> 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 + +✔ zh-CN: new confirmation sheet cancels writes and guards dangerous keyboard actions (2838.921875ms) +✔ en-US: new confirmation sheet cancels writes and guards dangerous keyboard actions (1235.065125ms) +✔ zh-CN: deploy button uses first trust directly, renders registered host and rejects changed keys (1319.456584ms) +✔ en-US: deploy button uses first trust directly, renders registered host and rejects changed keys (1231.945167ms) +✔ zh-CN: dashboard verifies unknown service with JSON before triggering (911.245208ms) +✔ zh-CN: real index cold start, safe toast/file text, current websocket state (655.26ms) +✔ zh-CN: initial language failure blocks controls and retry recovers (513.298166ms) +✔ en-US: dashboard verifies unknown service with JSON before triggering (1059.412208ms) +✔ en-US: real index cold start, safe toast/file text, current websocket state (802.417709ms) +✔ en-US: initial language failure blocks controls and retry recovers (654.9365ms) +✔ delayed language package and rapid switches keep the latest requested language (566.667834ms) +✔ terminal resource failure presents retry; a rejected cached promise is not reused (652.332833ms) +✔ zh-CN: package confirmation treats names as text and applies only on explicit verified action (653.5175ms) +✔ en-US: package confirmation treats names as text and applies only on explicit verified action (636.092ms) +✔ initial delayed language keeps controls inert until the real package arrives (560.57775ms) +✔ failed language switch preserves the loaded language; retry clears failure and stale toast (793.150459ms) +✔ terminal can initialize after failed resource load using newly served resources (629.26375ms) +✔ zh-CN: managed commands share service state, start, verification and stop (649.907834ms) +✔ en-US: managed commands share service state, start, verification and stop (841.122375ms) +✔ zh-CN: ready survives both editors and two saves (2885.286042ms) +✔ zh-CN: numeric-string survives both editors and two saves (882.351709ms) +✔ zh-CN: boolean-string survives both editors and two saves (775.050875ms) +✔ zh-CN: empty-string survives both editors and two saves (737.874167ms) +✔ zh-CN: quotes-and-escapes survives both editors and two saves (543.267084ms) +✔ zh-CN: entity-literal survives both editors and two saves (658.793834ms) +✔ zh-CN: spaces survives both editors and two saves (594.540459ms) +✔ zh-CN: zero survives both editors and two saves (497.758417ms) +✔ zh-CN: negative survives both editors and two saves (493.408667ms) +✔ zh-CN: fraction survives both editors and two saves (420.21125ms) +✔ zh-CN: integral-float survives both editors and two saves (497.383958ms) +✔ zh-CN: true survives both editors and two saves (573.399042ms) +✔ zh-CN: false survives both editors and two saves (583.391958ms) +✔ zh-CN: null survives both editors and two saves (586.046458ms) +✔ zh-CN: limit-string survives both editors and two saves (418.181458ms) +✔ zh-CN: unicode survives both editors and two saves (517.408917ms) +✔ en-US: ready survives both editors and two saves (604.991959ms) +✔ en-US: numeric-string survives both editors and two saves (694.44075ms) +✔ en-US: boolean-string survives both editors and two saves (645.396042ms) +✔ en-US: empty-string survives both editors and two saves (651.138042ms) +✔ en-US: quotes-and-escapes survives both editors and two saves (749.073333ms) +✔ en-US: entity-literal survives both editors and two saves (580.132ms) +✔ en-US: spaces survives both editors and two saves (569.025041ms) +✔ en-US: zero survives both editors and two saves (515.255417ms) +✔ en-US: negative survives both editors and two saves (508.265875ms) +✔ en-US: fraction survives both editors and two saves (581.07425ms) +✔ en-US: integral-float survives both editors and two saves (522.709667ms) +✔ en-US: true survives both editors and two saves (377.507166ms) +✔ en-US: false survives both editors and two saves (433.694459ms) +✔ en-US: null survives both editors and two saves (417.864583ms) +✔ en-US: limit-string survives both editors and two saves (397.335458ms) +✔ en-US: unicode survives both editors and two saves (420.794042ms) +✔ editing ready uses the same type contract in both editors (375.46275ms) +✔ editing "0" uses the same type contract in both editors (235.66275ms) +✔ editing 0 uses the same type contract in both editors (244.983667ms) +✔ editing "true" uses the same type contract in both editors (244.035917ms) +✔ editing true uses the same type contract in both editors (313.032208ms) +✔ editing false uses the same type contract in both editors (237.79525ms) +✔ editing null uses the same type contract in both editors (264.19975ms) +✔ editing "" uses the same type contract in both editors (217.234334ms) +✔ trigger: invalid [] stays in draft and never writes (244.347333ms) +✔ trigger: invalid {} stays in draft and never writes (271.896541ms) +✔ trigger: invalid 1e999 stays in draft and never writes (212.712834ms) +✔ trigger: invalid "\u0000" stays in draft and never writes (260.943667ms) +✔ trigger: invalid "aaaaaaaaaaaaaaaaaaa stays in draft and never writes (297.46725ms) +✔ trigger: invalid "中中中中中中中中中中中中中中中中中中中 stays in draft and never writes (213.438834ms) +✔ action: invalid [] stays in draft and never writes (292.940708ms) +✔ action: invalid {} stays in draft and never writes (262.144625ms) +✔ action: invalid 1e999 stays in draft and never writes (212.453167ms) +✔ action: invalid "\u0000" stays in draft and never writes (202.9375ms) +✔ action: invalid "aaaaaaaaaaaaaaaaaaa stays in draft and never writes (213.983209ms) +✔ action: invalid "中中中中中中中中中中中中中中中中中中中 stays in draft and never writes (192.334125ms) +✔ trigger: missing variable cannot silently remove a condition (232.515125ms) +✔ action: missing variable cannot silently remove a condition (183.680292ms) +✔ manual-only preserves dormant trigger conditions (223.267458ms) +✔ unsupported operator is visible and blocked rather than changed to equality (204.188792ms) +✔ all supported operators stay identical across both editors (195.784833ms) +✔ unchecked action condition is intentionally removed; empty string is valid (265.731875ms) +✔ one escape implementation preserves text and attribute values without double encoding (234.722875ms) +✔ source: export button passes quoted IDs as data rather than JavaScript source (180.037ms) +✔ rule: export button passes quoted IDs as data rather than JavaScript source (217.002333ms) +✔ action: export button passes quoted IDs as data rather than JavaScript source (197.4545ms) +✔ ssh-cmd: export button passes quoted IDs as data rather than JavaScript source (255.230584ms) +✔ ssh-host: export button passes quoted IDs as data rather than JavaScript source (235.224167ms) +✔ inline action conditions remain unchanged even though they are read-only (207.967917ms) +✔ unresolved action references retain their conditions and type (248.124958ms) +✔ rejected save preserves the editor, typed value and operator (204.504542ms) +✔ action: real REST verification gates stopped deletion (2699.246041ms) +✔ action: real REST verification gates ready deletion (999.477042ms) +✔ command: real REST verification gates stopped deletion (782.756791ms) +✔ command: real REST verification gates ready deletion (631.646125ms) +✔ action: delayed verification cannot overwrite a newer real start (578.567042ms) +✔ command: delayed verification cannot overwrite a newer real start (414.387542ms) +✔ zh-CN: navigation buttons open existing pages without positioning or deletion (3453.770583ms) +✔ en-US: navigation buttons open existing pages without positioning or deletion (1688.95325ms) +✔ zh-CN 390: 32 production references, escaping, design, close and navigation (959.383166ms) +✔ zh-CN 390: 47-byte label and 63-byte ID remain readable (797.740959ms) +✔ zh-CN 390: service-blocked 47-byte template title does not clip (555.523125ms) +✔ zh-CN 390: C group has only Close; missing list remains blocked (753.001209ms) +✔ zh-CN 390: successive service and reference blockers have no stale sheet (907.931ms) +✔ zh-CN 390: homepage empty/loading/recovery retains grid and entry (473.392208ms) +✔ zh-CN 390: stale selection failure names the chosen option and retains draft (633.413834ms) +✔ zh-CN 1440: 32 production references, escaping, design, close and navigation (945.358042ms) +✔ zh-CN 1440: 47-byte label and 63-byte ID remain readable (591.569166ms) +✔ zh-CN 1440: service-blocked 47-byte template title does not clip (679.112833ms) +✔ zh-CN 1440: C group has only Close; missing list remains blocked (871.66225ms) +✔ zh-CN 1440: successive service and reference blockers have no stale sheet (812.506333ms) +✔ zh-CN 1440: homepage empty/loading/recovery retains grid and entry (794.637792ms) +✔ zh-CN 1440: stale selection failure names the chosen option and retains draft (572.292458ms) +✔ en-US 390: 32 production references, escaping, design, close and navigation (898.132833ms) +✔ en-US 390: 47-byte label and 63-byte ID remain readable (784.100334ms) +✔ en-US 390: service-blocked 47-byte template title does not clip (676.337083ms) +✔ en-US 390: C group has only Close; missing list remains blocked (687.530458ms) +✔ en-US 390: successive service and reference blockers have no stale sheet (765.389125ms) +✔ en-US 390: homepage empty/loading/recovery retains grid and entry (432.676833ms) +✔ en-US 390: stale selection failure names the chosen option and retains draft (532.282708ms) +✔ en-US 1440: 32 production references, escaping, design, close and navigation (732.156042ms) +✔ en-US 1440: 47-byte label and 63-byte ID remain readable (446.1835ms) +✔ en-US 1440: service-blocked 47-byte template title does not clip (496.637709ms) +✔ en-US 1440: C group has only Close; missing list remains blocked (579.539917ms) +✔ en-US 1440: successive service and reference blockers have no stale sheet (603.57ms) +✔ en-US 1440: homepage empty/loading/recovery retains grid and entry (353.903834ms) +✔ en-US 1440: stale selection failure names the chosen option and retains draft (450.894333ms) +✔ zh-CN: widget zero, negative bound, expressions and thresholds survive save/reopen (2730.2965ms) +✔ zh-CN: invalid widget numbers retain the whole draft without saving (1054.630958ms) +✔ zh-CN: all 14 available filter templates retain parameters across reopen and two updates (30977.601125ms) +✔ zh-CN: failed or invalid LED edit keeps its template and never deletes it (998.06275ms) +✔ zh-CN: editing only a legacy filter label preserves its default execution (1620.917375ms) +✔ zh-CN: modal and matrix manual filters convert frequency and retain zero decay (200.019333ms) +✔ en-US: widget zero, negative bound, expressions and thresholds survive save/reopen (257.792ms) +✔ en-US: invalid widget numbers retain the whole draft without saving (266.390416ms) +✔ en-US: all 14 available filter templates retain parameters across reopen and two updates (30372.641083ms) +✔ en-US: failed or invalid LED edit keeps its template and never deletes it (1043.026792ms) +✔ en-US: editing only a legacy filter label preserves its default execution (1767.772958ms) +✔ en-US: modal and matrix manual filters convert frequency and retain zero decay (292.6735ms) +✔ files and image picker preserve special paths, plain names and both directory types (311.226375ms) +✔ JSON field and command-variable buttons pass unmodified quotes and Unicode (274.172417ms) +✔ CSR UI uses UTF-8 byte bounds and preserves the organization data (179.610959ms) +✔ zh-CN: undefined preserves field presence through metadata and one-field edits (1644.020917ms) +✔ zh-CN: {"angle":0} preserves field presence through metadata and one-field edits (1697.92825ms) +✔ zh-CN: {"speed":40,"wavelength":8,"amplitude":128,"angle":0} preserves field presence through metadata and one-field edits (1680.298792ms) +✔ en-US: undefined preserves field presence through metadata and one-field edits (1765.225959ms) +✔ en-US: {"angle":0} preserves field presence through metadata and one-field edits (1783.382584ms) +✔ en-US: {"speed":40,"wavelength":8,"amplitude":128,"angle":0} preserves field presence through metadata and one-field edits (1762.053958ms) +✔ zh-CN: cached old API, app and language are replaced by new URLs (3327.130667ms) +✔ en-US: cached old API, app and language are replaced by new URLs (1449.291167ms) +✔ zh-CN rsa2048: real modal persistent feedback and generated row (6245.92825ms) +✔ zh-CN rsa4096: real modal persistent feedback and generated row (3861.391209ms) +✔ zh-CN ec256: real modal persistent feedback and generated row (3853.995667ms) +✔ zh-CN ec384: real modal persistent feedback and generated row (3817.103083ms) +✔ zh-CN: business and transport failures retain modal with accurate state (1694.652375ms) +✔ zh-CN: list error after saving never reports generation failure (388.037958ms) +✔ zh-CN: closed and reopened modal owns its new inputs (442.711375ms) +✔ en-US rsa2048: real modal persistent feedback and generated row (3675.746792ms) +✔ en-US rsa4096: real modal persistent feedback and generated row (3584.246167ms) +✔ en-US ec256: real modal persistent feedback and generated row (3616.483541ms) +✔ en-US ec384: real modal persistent feedback and generated row (3592.051708ms) +✔ en-US: business and transport failures retain modal with accurate state (1470.548875ms) +✔ en-US: list error after saving never reports generation failure (331.967958ms) +✔ en-US: closed and reopened modal owns its new inputs (362.543667ms) +✔ zh-CN: language rebuild releases only controls attached to the pending generation (294.087458ms) +✔ en-US: language rebuild releases only controls attached to the pending generation (374.940958ms) +✔ zh-CN: keyboard trap and Escape clear the password without submission (2934.082792ms) +✔ zh-CN: Enter submits once and Escape closes the finished result (977.655458ms) +✔ zh-CN: logs are lazy, use byte offsets and retain the complete cached transcript (1093.080083ms) +✔ zh-CN: logs stop after a no-progress page and do not retry on reopen (868.279333ms) +✔ zh-CN: logs pause when folded and resume from the next byte offset (779.585834ms) +✔ zh-CN: logs stale cannot replace the configuration result (652.487041ms) +✔ zh-CN: logs sd cannot replace the configuration result (567.325083ms) +✔ zh-CN: logs wrongRun cannot replace the configuration result (531.377166ms) +✔ zh-CN: logs allow an empty terminal page (673.982625ms) +✔ zh-CN: logs failure summary shows the last diagnosis and keeps technical status separate (532.209583ms) +✔ zh-CN: explicit no-internet error survives a following route table and raw logs remain available (674.848125ms) +✔ zh-CN: log group follows dialog typography and fits desktop and narrow viewports (1267.648916ms) +✔ zh-CN: running remains authoritative until cleanup completes; result appears once (538.217917ms) +✔ zh-CN: navigation clears the password and cannot submit from Files (604.128792ms) +✔ zh-CN: late start response cannot update a new navigation (657.828042ms) +✔ zh-CN: late start rejection cannot update a new navigation (622.503875ms) +✔ zh-CN: status failure preserves remote outcome and never starts another command (487.098666ms) +✔ zh-CN: late manual status refresh cannot restart polling in a new navigation (493.117458ms) +✔ zh-CN: native API success has one POST and accurate outcome (573.123625ms) +✔ zh-CN: native API rejected has one POST and accurate outcome (523.875541ms) +✔ zh-CN: native API lostResponse has one POST and accurate outcome (497.235333ms) +✔ zh-CN: rejected duplicate start adopts the existing task instead of reporting script failure (455.697916ms) +✔ zh-CN: old status cannot overwrite a newer execution or release its in-flight guard (331.576375ms) +✔ zh-CN: leaving closes a result dialog and ignores its late log response (485.536334ms) +✔ zh-CN: returning to a completed run does not pop the result again (414.601209ms) +✔ en-US: keyboard trap and Escape clear the password without submission (473.512958ms) +✔ en-US: Enter submits once and Escape closes the finished result (439.373834ms) +✔ en-US: logs are lazy, use byte offsets and retain the complete cached transcript (636.0125ms) +✔ en-US: logs stop after a no-progress page and do not retry on reopen (627.212792ms) +✔ en-US: logs pause when folded and resume from the next byte offset (632.155084ms) +✔ en-US: logs stale cannot replace the configuration result (418.17975ms) +✔ en-US: logs sd cannot replace the configuration result (472.3885ms) +✔ en-US: logs wrongRun cannot replace the configuration result (439.036375ms) +✔ en-US: logs allow an empty terminal page (415.047333ms) +✔ en-US: logs failure summary shows the last diagnosis and keeps technical status separate (311.019833ms) +✔ en-US: explicit no-internet error survives a following route table and raw logs remain available (460.969ms) +✔ en-US: log group follows dialog typography and fits desktop and narrow viewports (713.629416ms) +✔ en-US: running remains authoritative until cleanup completes; result appears once (343.323542ms) +✔ en-US: navigation clears the password and cannot submit from Files (368.904167ms) +✔ en-US: late start response cannot update a new navigation (365.310334ms) +✔ en-US: late start rejection cannot update a new navigation (382.665375ms) +✔ en-US: status failure preserves remote outcome and never starts another command (282.919ms) +✔ en-US: late manual status refresh cannot restart polling in a new navigation (313.511875ms) +✔ en-US: native API success has one POST and accurate outcome (360.923334ms) +✔ en-US: native API rejected has one POST and accurate outcome (364.678292ms) +✔ en-US: native API lostResponse has one POST and accurate outcome (376.816583ms) +✔ en-US: rejected duplicate start adopts the existing task instead of reporting script failure (418.776833ms) +✔ en-US: old status cannot overwrite a newer execution or release its in-flight guard (294.925333ms) +✔ en-US: leaving closes a result dialog and ignores its late log response (396.044375ms) +✔ en-US: returning to a completed run does not pop the result again (278.681625ms) +{"case":"successful retry","language":"zh-CN","failure":"/sdcard/a.tscfg: 应用失败: 设备处理请求时发生错误,请查看详细原因。\n详细原因:fixture failure","afterSuccess":"/sdcard/a.tscfg: 配置包已应用。","dialogClosed":true,"errors":[]} +✔ review2: successful retry retains old failure zh-CN (2714.623459ms) +{"case":"late completion","language":"zh-CN","secondShown":true,"secondRemoved":false,"toast":"/sdcard/a.tscfg: 正在应用配置...","errors":[]} +✔ review2: prior completion closes new package confirmation zh-CN (857.617917ms) +{"case":"stale file list","language":"zh-CN","callsBefore":1,"callsAfter":2,"visibleAfterUpload":true,"visibleAfterManualRefresh":true,"errors":[]} +✔ review2: uploaded file missing after dialog close zh-CN (719.912084ms) +{"case":"successful retry","language":"en-US","failure":"/sdcard/a.tscfg: Apply failed: The device encountered an error while processing the request. See the details below.\nDetails: fixture failure","afterSuccess":"/sdcard/a.tscfg: The configuration package was applied.","dialogClosed":true,"errors":[]} +✔ review2: successful retry retains old failure en-US (850.363958ms) +{"case":"late completion","language":"en-US","secondShown":true,"secondRemoved":false,"toast":"/sdcard/a.tscfg: Applying config...","errors":[]} +✔ review2: prior completion closes new package confirmation en-US (750.84225ms) +{"case":"stale file list","language":"en-US","callsBefore":1,"callsAfter":2,"visibleAfterUpload":true,"visibleAfterManualRefresh":true,"errors":[]} +✔ review2: uploaded file missing after dialog close en-US (616.830542ms) +✔ R1: endpoint-specific logout and HTTP errors zh-CN (533.760625ms) +✔ R3: late validation cannot revive an old apply entry zh-CN (364.386584ms) +✔ R3: previously opened confirmation loses validity on reupload zh-CN (416.29675ms) +✔ R4/R5: obsolete errors and finally cannot update newer attempts zh-CN (350.654333ms) +✔ R6: fixed upload destination and separate refresh failure zh-CN (431.230875ms) +✔ R6: out-of-order directory success and error do not replace latest list zh-CN (422.48925ms) +✔ R1: endpoint-specific logout and HTTP errors en-US (638.856458ms) +✔ R3: late validation cannot revive an old apply entry en-US (672.797875ms) +✔ R3: previously opened confirmation loses validity on reupload en-US (680.950667ms) +✔ R4/R5: obsolete errors and finally cannot update newer attempts en-US (494.123084ms) +✔ R6: fixed upload destination and separate refresh failure en-US (651.390083ms) +✔ R6: out-of-order directory success and error do not replace latest list en-US (548.720167ms) +✔ R2: authenticated startup initializes once (581.831583ms) +✔ R2: obsolete system loader cannot update new page; failed navigation retries (570.2225ms) +✔ R4/R5: newest result wins over late success and late failure (732.855ms) +✔ R3: overlapping writes to one path cannot claim current verified content (445.756208ms) +✔ R2: disposed page only removes its dashboard callback (525.687833ms) +✔ R2: stale finally cannot unlock the new page read (487.388583ms) +✔ R1: delayed sign-out does not clear a newer sign-in (484.588375ms) +✔ WS protocol: actual page subscription class, independent intervals and acknowledgements zh-CN (598.511208ms) +✔ WS protocol: actual page subscription class, independent intervals and acknowledgements en-US (616.103041ms) +✔ zh-CN: upgrade replaces a cached old deployment API without clearing browser cache (3217.452584ms) +✔ en-US: upgrade replaces a cached old deployment API without clearing browser cache (1925.619ms) +✔ 375px: all widget values stay inside cards and preserve grid widths (2411.815792ms) +✔ 600px: all widget values stay inside cards and preserve grid widths (881.739458ms) +✔ 768px: all widget values stay inside cards and preserve grid widths (650.538042ms) +✔ 900px: all widget values stay inside cards and preserve grid widths (838.749291ms) +✔ 1024px: all widget values stay inside cards and preserve grid widths (868.395542ms) +✔ 1200px: all widget values stay inside cards and preserve grid widths (492.096083ms) +✔ 1440px: all widget values stay inside cards and preserve grid widths (459.894375ms) +✔ wide ring labels overlay the whole card; narrow dual values stack (530.799292ms) +✔ 180px cards contain long units and labels and remain editable (446.17425ms) +✔ widget layout visual samples (862.554583ms) +✔ 375px: mixed layout sizes never create implicit grid columns (556.846958ms) +✔ 600px: mixed layout sizes never create implicit grid columns (470.174584ms) +✔ 601px: mixed layout sizes never create implicit grid columns (510.303041ms) +✔ 768px: mixed layout sizes never create implicit grid columns (465.864334ms) +✔ 900px: mixed layout sizes never create implicit grid columns (454.808083ms) +✔ 901px: mixed layout sizes never create implicit grid columns (333.960083ms) +✔ 1200px: mixed layout sizes never create implicit grid columns (443.036333ms) +✔ 1201px: mixed layout sizes never create implicit grid columns (527.399834ms) +✔ 1440px: mixed layout sizes never create implicit grid columns (416.623542ms) +✔ zh-CN: narrow log toolbar contains controls and status in both states (612.228042ms) +✔ en-US: narrow log toolbar contains controls and status in both states (577.2315ms) +✔ device service cards cap at three columns across the fan breakpoint (813.159209ms) +ℹ tests 262 +ℹ suites 0 +ℹ pass 262 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 83107.817875 diff --git a/docs/repair/delete-protection/evidence/build.log b/docs/repair/delete-protection/evidence/build.log new file mode 100644 index 00000000..03d80e12 --- /dev/null +++ b/docs/repair/delete-protection/evidence/build.log @@ -0,0 +1,52 @@ +Executing action: all (aliases: build) +Running ninja in directory /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection +Executing "ninja all"... +[1/7] Copying web assets to build directory +[2/7] cd /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/esptool_py && /Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python /Users/massif/esp/v5.5.2/esp-idf/components/partition_table/check_sizes.py --offset 0x8000 partition --type app /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/partition_table/partition-table.bin /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/TianShanOS.bin +TianShanOS.bin binary size 0x21a0c0 bytes. Smallest app partition is 0x300000 bytes. 0xe5f40 bytes (30%) free. +[3/7] Performing build step for 'bootloader' +[1/1] cd /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/bootloader/esp-idf/esptool_py && /Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python /Users/massif/esp/v5.5.2/esp-idf/components/partition_table/check_sizes.py --offset 0x8000 bootloader 0x0 /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/bootloader/bootloader.bin +Bootloader binary size 0x57e0 bytes. 0x2820 bytes (31%) free. +[4/7] No install step for 'bootloader' +[5/7] Completed 'bootloader' +[6/7] Minifying + gzipping web resources +Minifying web resources in /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui/web_optimized... + JS js/terminal.js: 31,110 -> 20,056 (35.5% saved) + JS js/router.js: 6,435 -> 3,860 (40.0% saved) + JS js/dragSort.js: 11,460 -> 7,603 (33.7% saved) + JS js/api.js: 38,400 -> 25,786 (32.8% saved) + JS js/i18n.js: 7,208 -> 4,320 (40.1% saved) + JS js/app.js: 940,003 -> 732,729 (22.1% saved) + JS js/lang/en-US.js: 196,195 -> 158,799 (19.1% saved) + JS js/lang/zh-CN.js: 188,643 -> 151,801 (19.5% saved) + CSS css/style.css: 42,660 -> 40,297 (5.5% saved) + + Total saved: 316,863 bytes (309.4 KB) + +Gzipping web resources in /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui/web_optimized... + GZ js/terminal.js: 20,056 -> 5,558 (72.3% smaller) + GZ js/router.js: 3,860 -> 1,346 (65.1% smaller) + GZ js/dragSort.js: 7,603 -> 1,869 (75.4% smaller) + GZ js/api.js: 25,786 -> 6,594 (74.4% smaller) + GZ js/i18n.js: 4,320 -> 1,460 (66.2% smaller) + GZ js/app.js: 732,729 -> 157,338 (78.5% smaller) + GZ js/lang/en-US.js: 158,799 -> 47,941 (69.8% smaller) + GZ js/lang/zh-CN.js: 151,801 -> 50,024 (67.0% smaller) + GZ vendor/xterm/fit.min.js: 1,789 -> 822 (54.1% smaller) + GZ vendor/xterm/xterm.min.js: 283,670 -> 65,684 (76.8% smaller) + GZ css/style.css: 40,297 -> 9,018 (77.6% smaller) + GZ vendor/xterm/xterm.css: 5,383 -> 2,037 (62.2% smaller) + GZ index.html: 62,010 -> 18,969 (69.4% smaller) + + Gzipped 13 files: 1,498,103 -> 368,660 (75.4% overall) +Done. +[7/7] cd /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui && /Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python /Users/massif/esp/v5.5.2/esp-idf/components/spiffs/spiffsgen.py 0x300000 /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui/web_optimized /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/www.bin --page-size=256 --obj-name-len=32 --meta-len=4 --use-magic --use-magic-len + +Project build complete. To flash, run: + idf.py flash +or + idf.py -p PORT flash +or + python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m 0x0 build/delete-protection/bootloader/bootloader.bin 0x8000 build/delete-protection/partition_table/partition-table.bin 0x16000 build/delete-protection/ota_data_initial.bin 0x20000 build/delete-protection/TianShanOS.bin 0x6a0000 build/delete-protection/www.bin +or from the "/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection" directory + python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash "@flash_args" 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 00000000..c41ad750 --- /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/reference-wire.log b/docs/repair/delete-protection/evidence/reference-wire.log new file mode 100644 index 00000000..c41ad750 --- /dev/null +++ b/docs/repair/delete-protection/evidence/reference-wire.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/round3-browser.log b/docs/repair/delete-protection/evidence/round3-browser.log new file mode 100644 index 00000000..c6ebdd32 --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3-browser.log @@ -0,0 +1,46 @@ +✔ action: real REST verification gates stopped deletion (677.755291ms) +✔ action: real REST verification gates ready deletion (190.431375ms) +✔ command: real REST verification gates stopped deletion (141.920542ms) +✔ command: real REST verification gates ready deletion (219.136458ms) +✔ action: delayed verification cannot overwrite a newer real start (175.024709ms) +✔ command: delayed verification cannot overwrite a newer real start (168.640959ms) +✔ zh-CN: navigation buttons open existing pages without positioning or deletion (964.0105ms) +✔ en-US: navigation buttons open existing pages without positioning or deletion (517.2845ms) +✔ zh-CN 390: 32 production references, escaping, design, close and navigation (390.682792ms) +✔ zh-CN 390: 47-byte label and 63-byte ID remain readable (267.434875ms) +✔ zh-CN 390: service-blocked 47-byte template title does not clip (264.435041ms) +✔ zh-CN 390: C group has only Close; missing list remains blocked (363.04725ms) +✔ zh-CN 390: successive service and reference blockers have no stale sheet (414.364625ms) +✔ zh-CN 390: homepage empty/loading/recovery retains grid and entry (191.583333ms) +✔ zh-CN 390: stale selection failure names the chosen option and retains draft (266.922375ms) +✔ zh-CN 1440: 32 production references, escaping, design, close and navigation (379.798125ms) +✔ zh-CN 1440: 47-byte label and 63-byte ID remain readable (274.992625ms) +✔ zh-CN 1440: service-blocked 47-byte template title does not clip (258.833958ms) +✔ zh-CN 1440: C group has only Close; missing list remains blocked (375.246541ms) +✔ zh-CN 1440: successive service and reference blockers have no stale sheet (409.31575ms) +✔ zh-CN 1440: homepage empty/loading/recovery retains grid and entry (185.908041ms) +✔ zh-CN 1440: stale selection failure names the chosen option and retains draft (270.203458ms) +✔ en-US 390: 32 production references, escaping, design, close and navigation (414.266375ms) +✔ en-US 390: 47-byte label and 63-byte ID remain readable (318.463917ms) +✔ en-US 390: service-blocked 47-byte template title does not clip (306.5255ms) +✔ en-US 390: C group has only Close; missing list remains blocked (442.086083ms) +✔ en-US 390: successive service and reference blockers have no stale sheet (450.665083ms) +✔ en-US 390: homepage empty/loading/recovery retains grid and entry (254.423167ms) +✔ en-US 390: stale selection failure names the chosen option and retains draft (309.357459ms) +✔ en-US 1440: 32 production references, escaping, design, close and navigation (395.291ms) +✔ en-US 1440: 47-byte label and 63-byte ID remain readable (335.782666ms) +✔ en-US 1440: service-blocked 47-byte template title does not clip (298.214833ms) +✔ en-US 1440: C group has only Close; missing list remains blocked (445.234291ms) +✔ en-US 1440: successive service and reference blockers have no stale sheet (491.603334ms) +✔ en-US 1440: homepage empty/loading/recovery retains grid and entry (235.669625ms) +✔ en-US 1440: stale selection failure names the chosen option and retains draft (320.05925ms) +✔ zh-CN: non-root homepage has approved guidance and no management entry (198.403083ms) +✔ zh-CN: template lookup failure uses saveFailed and retains the actual draft (263.494208ms) +ℹ tests 38 +ℹ suites 0 +ℹ pass 38 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 11611.17775 diff --git a/docs/repair/delete-protection/evidence/round3-unit.log b/docs/repair/delete-protection/evidence/round3-unit.log new file mode 100644 index 00000000..748693f0 --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3-unit.log @@ -0,0 +1,42 @@ +✔ zh-CN: protected delete explains service state rather than internal failure (16.509792ms) +✔ en-US: protected delete explains service state rather than internal failure (5.778917ms) +✔ action: unregistered service is verified stopped before deletion (6.317ms) +✔ action: running never leads to delete or stop (5.484833ms) +✔ action: ready never leads to delete or stop (5.237917ms) +✔ action: unknown never leads to delete or stop (5.097834ms) +✔ action: failed verification never deletes (5.028959ms) +✔ action: ordinary command has no service verification (4.297542ms) +✔ action: repeated click submits only one verification and delete (4.405ms) +✔ action: leaving the page during verification cancels subsequent deletion (5.282917ms) +✔ command: unregistered service is verified stopped before deletion (4.273167ms) +✔ command: running never leads to delete or stop (3.944958ms) +✔ command: ready never leads to delete or stop (5.8435ms) +✔ command: unknown never leads to delete or stop (4.614542ms) +✔ command: failed verification never deletes (4.517209ms) +✔ command: ordinary command has no service verification (5.151209ms) +✔ command: repeated click submits only one verification and delete (5.937667ms) +✔ command: leaving the page during verification cancels subsequent deletion (3.846917ms) +✔ switching hosts during verification removes the original command only (3.861625ms) +✔ action: newer start prevents stale deletion and status painting (6.249666ms) +✔ action: newer stop prevents stale deletion and status painting (3.94125ms) +✔ action: newer verify prevents stale deletion and status painting (4.029584ms) +✔ command: newer start prevents stale deletion and status painting (5.167167ms) +✔ command: newer stop prevents stale deletion and status painting (4.964041ms) +✔ command: newer verify prevents stale deletion and status painting (4.144833ms) +✔ action: stale admission failure cannot replace newer start feedback (4.126417ms) +✔ command: stale admission failure cannot replace newer start feedback (5.768083ms) +✔ zh-CN: H15 nine states, busy and operation kind select an approved explanation (16.52475ms) +✔ en-US: H15 nine states, busy and operation kind select an approved explanation (6.080042ms) +✔ zh-CN: approved keys, placeholders and missing-template display label (5.785667ms) +✔ en-US: approved keys, placeholders and missing-template display label (5.5785ms) +Checked 3076 references including conditional and finite dynamic families. +✔ actual UI literal/conditional and finite dynamic keys exist in both languages with matching contracts (6.108292ms) +✔ production i18n is the inline entry; no second translator script is loaded (0.121792ms) +ℹ tests 33 +ℹ suites 0 +ℹ pass 33 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 192.045542 diff --git a/docs/repair/delete-protection/evidence/round3-verification.json b/docs/repair/delete-protection/evidence/round3-verification.json new file mode 100644 index 00000000..5f10aa19 --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3-verification.json @@ -0,0 +1,54 @@ +{ + "date": "2026-10-10", + "worktree": "/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS", + "branch": "fix/automation-delete-protection", + "base": "050757bb8046206797804ba5edc110ae3244bf17", + "unit": { + "command": "node --test tests/prompts/static.test.cjs tests/prompts/delete-reference.test.cjs tests/prompts/delete-protection.test.cjs", + "exit_code": 0, + "pass": 33, + "fail": 0 + }, + "browser": { + "command": "node --test tests/prompts/delete-reference.browser.test.cjs tests/prompts/delete-protection.browser.test.cjs", + "exit_code": 0, + "pass": 38, + "fail": 0 + }, + "diff_check": { + "command": "git diff --check", + "exit_code": 0 + }, + "v1_sha256": "9102fb703caf49e1e312f07efec7e3131bc6999009e0eb0d40243b3d53c26345", + "protected_file_sha256": { + "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/js/api.js": "f635a33081cbd9e022e5674467ac3ae995c21bdb125e64439b41d8d6b53394b4", + "components/ts_webui/web/css/style.css": "5aef54b61efe7a5d9882a647cfcc6b88949a5f115897401f5293822653437b43" + }, + "round_changed_files": [ + "components/ts_webui/web/js/app.js", + "components/ts_webui/web/index.html", + "components/ts_webui/web/js/lang/zh-CN.js", + "components/ts_webui/web/js/lang/en-US.js", + "tests/prompts/static.test.cjs", + "tests/prompts/delete-reference.test.cjs", + "tests/prompts/delete-protection.test.cjs", + "tests/prompts/delete-reference.browser.test.cjs", + "tests/prompts/delete-protection.browser.test.cjs", + "docs/repair/delete-protection/PLAN.md", + "docs/repair/delete-protection/COPY.md", + "docs/repair/delete-protection/RESULT.md", + "docs/repair/delete-protection/evidence/source-hashes.json" + ], + "not_run": [ + "firmware tests", + "full tests", + "ESP-IDF build", + "hardware tests" + ], + "departures": [] +} diff --git a/docs/repair/delete-protection/evidence/round3.patch b/docs/repair/delete-protection/evidence/round3.patch new file mode 100644 index 00000000..dbc7f1f3 --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3.patch @@ -0,0 +1,231 @@ +--- a/components/ts_webui/web/js/app.js ++++ b/components/ts_webui/web/js/app.js +@@ -8 +8 @@ +- const known = ['revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config', 'action_missing']; ++ const known = ['revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config']; +@@ -12,0 +13 @@ ++ if (code === 'template_lookup_failed') return runtimeText('saveFailed'); +@@ -8269 +8270 @@ +- return 'operationPending'; ++ return 'unconfirmed'; +@@ -8295 +8296,2 @@ +- primary = t('deleteProtection.viewCommand'); target = '/commands'; ++ if (context.type === 'command') primary = t('common.close'); ++ else { primary = t('deleteProtection.viewCommand'); target = '/commands'; } +@@ -8315,0 +8318 @@ ++ const isRoot = api.isRoot(); +@@ -8317,2 +8320,4 @@ +- const body = loading ? runtimeText('configLoading') : recovery ? runtimeText('recovery_required') : t('automationPage.quickActionsHint'); +- const detail = loading ? t('deleteProtection.loadingDetails') : recovery ? t('deleteProtection.recoveryDetails') : ''; ++ const body = loading ? runtimeText('configLoading') : recovery ? runtimeText('recovery_required') : ++ (isRoot ? t('automationPage.quickActionsHint') : t('deleteProtection.quickEmptyNeedRoot')); ++ const detail = loading ? (isRoot ? t('deleteProtection.loadingDetails') : t('deleteProtection.quickLoadingNeedRoot')) : ++ recovery ? (isRoot ? t('deleteProtection.recoveryDetails') : t('deleteProtection.quickRecoveryNeedRoot')) : ''; +@@ -8321 +8326 @@ +- ''; ++ (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 00000000..7990a002 --- /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 00000000..60f0cddb --- /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 00000000..b3e8511e --- /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": "7aad0c8571393bd1b5e4709cc1da15a626612cc4a3f29caddfed7794a2ea6ad5", + "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 00000000..1504247f --- /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 00000000..6aee9936 --- /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 00000000..95ed6681 --- /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/certificate/test_material.c b/tests/certificate/test_material.c index 8483f216..bf3cc0a0 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/fixtures/delete-reference/README.md b/tests/fixtures/delete-reference/README.md new file mode 100644 index 00000000..4fa4b477 --- /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 00000000..cfa1d921 --- /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 00000000..83958541 --- /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 00000000..3905d6f2 --- /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 00000000..2a3bcaf6 --- /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 00000000..3905d6f2 --- /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 00000000..ba017c27 --- /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 00000000..fd19886e --- /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 00000000..076fa1f2 --- /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 3c62911c..de838d16 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 b335b01e..46e158ff 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 00000000..fb5256ae --- /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 00000000..cc08b1b1 --- /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 1f5604a9..79f4a4a4 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/rule-pack.browser.test.cjs b/tests/prompts/rule-pack.browser.test.cjs new file mode 100644 index 00000000..35dac3d5 --- /dev/null +++ b/tests/prompts/rule-pack.browser.test.cjs @@ -0,0 +1,206 @@ +// 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==='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}; + 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'||mode==='deleted'){ + 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}]:[]}}; + 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; + } + 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+': 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();}); + 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/prompts/static.test.cjs b/tests/prompts/static.test.cjs index 3a29c347..193b82a3 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 5cf84263..137f6f91 100644 --- a/tests/runtime/extract_engine.py +++ b/tests/runtime/extract_engine.py @@ -2,8 +2,23 @@ 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_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','validate_new_template_refs','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 00000000..a0f00a12 --- /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/esp_err.h b/tests/runtime/rule_pack/stubs/esp_err.h new file mode 100644 index 00000000..f732eefd --- /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 00000000..ab2dc38b --- /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 00000000..d56bc66d --- /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 00000000..276729cf --- /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 00000000..c42c4964 --- /dev/null +++ b/tests/runtime/rule_pack/stubs/ts_action_manager.h @@ -0,0 +1,23 @@ +#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); +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); + +typedef struct {char id[TS_AUTO_NAME_MAX_LEN],name[TS_AUTO_LABEL_MAX_LEN];} ts_action_template_reference_t; +typedef struct {const char *reason,*check_reason;bool references_confirmed;size_t rule_count;ts_action_template_reference_t *rules;} ts_action_template_delete_result_t; 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 00000000..fa22baf0 --- /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 00000000..5e367a19 --- /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 00000000..39360133 --- /dev/null +++ b/tests/runtime/rule_pack/test_engine_pack.c @@ -0,0 +1,382 @@ +/* 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 +#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 +static int xSemaphoreTake(SemaphoreHandle_t m,unsigned timeout){return !(timeout?pthread_mutex_lock(m):pthread_mutex_trylock(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; +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));} +bool ts_ssh_binding_try_lock(void){return pthread_mutex_trylock(&binding)==0;} +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); +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,template_removals; +static bool template_binding_protected(const char *id,const ts_action_template_t *next){return false;} +static esp_err_t template_remove_impl(const char *id){++template_removals;template_exists=false;return ESP_OK;} +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_delete_result_t deletion; + assert(ts_action_template_remove_checked("template",&deletion)!=ESP_OK&&template_removals==0&&!deletion.references_confirmed);free(deletion.rules); + 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); + assert(ts_action_template_remove_checked("template",&deletion)!=ESP_OK&&template_removals==0&&deletion.references_confirmed&&deletion.rule_count==1);free(deletion.rules); + /* 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||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; + 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 00000000..071b2c68 --- /dev/null +++ b/tests/runtime/rule_pack/test_store_set.c @@ -0,0 +1,250 @@ +#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 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(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; + 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(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; +} +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=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; + 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 00000000..268f53e7 --- /dev/null +++ b/tests/runtime/rule_pack_contract.txt @@ -0,0 +1,65 @@ +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. + +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_all.sh b/tests/runtime/run_all.sh index cba25f44..8fa0c42f 100755 --- a/tests/runtime/run_all.sh +++ b/tests/runtime/run_all.sh @@ -5,10 +5,12 @@ 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 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/run_rule_pack.sh b/tests/runtime/run_rule_pack.sh new file mode 100755 index 00000000..db35e578 --- /dev/null +++ b/tests/runtime/run_rule_pack.sh @@ -0,0 +1,13 @@ +#!/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 +# 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/run_service_watch.sh b/tests/runtime/run_service_watch.sh index 35ad9929..983ce73c 100755 --- a/tests/runtime/run_service_watch.sh +++ b/tests/runtime/run_service_watch.sh @@ -4,8 +4,8 @@ cd "$(dirname "$0")/../.." export DEVELOPER_DIR="${DEVELOPER_DIR:-/Library/Developer/CommandLineTools}" build=/tmp/tianshan-runtime-tests mkdir -p "$build" -cc -std=c11 -g -fsanitize=address,undefined -Itests/runtime/state_stubs -Itests/runtime/ssh_stubs -Itests/runtime/stubs -Itests/certificate/stubs -Icomponents/ts_security/include -Icomponents/ts_automation/include tests/runtime/test_service_watch.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 "$build/service_watch" +cc -std=c11 -D_POSIX_C_SOURCE=200809L -g -fsanitize=address,undefined -Itests/runtime/state_stubs -Itests/runtime/ssh_stubs -Itests/runtime/stubs -Itests/certificate/stubs -Icomponents/ts_security/include -Icomponents/ts_automation/include tests/runtime/test_service_watch.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 "$build/service_watch" "$build/service_watch" -cc -std=c11 -g -fsanitize=address,undefined -Itests/runtime/state_stubs -Itests/runtime/ssh_stubs -Itests/runtime/stubs -Itests/certificate/stubs -Icomponents/ts_security/include -Icomponents/ts_automation/include tests/runtime/test_service_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 "$build/service_protocol" +cc -std=c11 -D_POSIX_C_SOURCE=200809L -g -fsanitize=address,undefined -Itests/runtime/state_stubs -Itests/runtime/ssh_stubs -Itests/runtime/stubs -Itests/certificate/stubs -Icomponents/ts_security/include -Icomponents/ts_automation/include tests/runtime/test_service_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 "$build/service_protocol" "$build/service_protocol" diff --git a/tests/runtime/state_stubs/freertos/semphr.h b/tests/runtime/state_stubs/freertos/semphr.h index c3d27019..a955af11 100644 --- a/tests/runtime/state_stubs/freertos/semphr.h +++ b/tests/runtime/state_stubs/freertos/semphr.h @@ -1,3 +1,7 @@ #pragma once #include "platform.h" static inline SemaphoreHandle_t xSemaphoreCreateMutex(void){pthread_mutex_t *p=malloc(sizeof(*p));pthread_mutex_init(p,NULL);return p;} + +static inline SemaphoreHandle_t xSemaphoreCreateRecursiveMutex(void){pthread_mutex_t *p=malloc(sizeof(*p));pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE);pthread_mutex_init(p,&attr);pthread_mutexattr_destroy(&attr);return p;} +static inline int xSemaphoreTakeRecursive(SemaphoreHandle_t p,unsigned timeout){return (timeout?pthread_mutex_lock(p):pthread_mutex_trylock(p))==0;} +static inline int xSemaphoreGiveRecursive(SemaphoreHandle_t p){return pthread_mutex_unlock(p)==0;} diff --git a/tests/runtime/test_action_pack.py b/tests/runtime/test_action_pack.py index de59b67c..405a9056 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; 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;} +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 212a50d8..ebc18b1a 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 a190fba9..9e228838 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; @@ -51,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_delete_api.py b/tests/runtime/test_delete_api.py index 8ab768c8..9b1a6a14 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 00000000..0f55fc57 --- /dev/null +++ b/tests/runtime/test_delete_reference.py @@ -0,0 +1,162 @@ +"""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 + masked=re.sub(r'/\*[\s\S]*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"',lambda match:' '*len(match[0]),s) + opening=s.index('{',m.start());depth=0 + for end in range(opening,len(masked)): + depth+=(masked[end]=='{')-(masked[end]=='}') + if not depth:break + return s[m.start():end+1]+'\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) +namespace={};exec(Path('tests/runtime/extract_engine.py').read_text(),namespace) +parts=[namespace['header'],namespace['stubs'],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','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','ts_rule_count','ts_rule_config_status','ts_rule_edit_begin','ts_rule_edit_end']: + 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; +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next){return ESP_OK;} /* No package instance in this legacy fixture. */ +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')] + # 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) + 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 13dcea9b..e3e272f2 100644 --- a/tests/runtime/test_engine.c +++ b/tests/runtime/test_engine.c @@ -12,10 +12,11 @@ #include "../../components/ts_automation/src/ts_rule_store.h" #define CONFIG_TS_AUTOMATION_MAX_RULES 4 #define pdTRUE 1 -#define xSemaphoreTakeRecursive xSemaphoreTake +#define xSemaphoreTakeRecursive fixture_recursive_take +static int fixture_recursive_take(SemaphoreHandle_t p,unsigned timeout){return (timeout?pthread_mutex_lock(p):pthread_mutex_trylock(p))==0;} #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; @@ -25,6 +26,7 @@ static int64_t esp_timer_get_time(void){return 1000000;} void *heap_caps_calloc(size_t n,size_t size,unsigned caps){if(fail_alloc==0)return NULL;if(fail_alloc>0)--fail_alloc;return calloc(n,size);} void *heap_caps_malloc(size_t n,unsigned caps){return heap_caps_calloc(1,n,caps);} void ts_ssh_binding_lock(void){pthread_mutex_lock(&binding);} +bool ts_ssh_binding_try_lock(void){return pthread_mutex_trylock(&binding)==0;} void ts_ssh_binding_unlock(void){pthread_mutex_unlock(&binding);} bool ts_action_manager_accepting(void){return true;} bool ts_ssh_service_rule_protected(const char *rule){return independent_service;} @@ -32,7 +34,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 +64,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 +72,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"); diff --git a/tests/runtime/test_keystore.py b/tests/runtime/test_keystore.py index 85625603..8f90dd4b 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