diff --git a/README.md b/README.md
index 2614d6bf..b1376bca 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-

+
[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 @@
-
+
[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 @@
-
+
-
+