From bdc0354bde6318a9997737b517ffd32c2c349496 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 02:46:50 +0800 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=E9=87=8D=E5=86=99=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E6=96=87=20README=EF=BC=8C=E6=9B=B4=E6=96=B0=200.6.1?= =?UTF-8?q?=20=E9=A1=B9=E7=9B=AE=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 257 ++++++++------------------------------------------- README_EN.md | 210 +++++++++-------------------------------- 2 files changed, 84 insertions(+), 383 deletions(-) diff --git a/README.md b/README.md index 5d50611..ebdeb6b 100644 --- a/README.md +++ b/README.md @@ -2,238 +2,63 @@ [English](README_EN.md) | [中文](README.md) -[![Build Status](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml) -![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-97-brightgreen?logo=lighthouse&logoColor=white) -[![License](https://img.shields.io/github/license/RMinte-AI/TianshanOS)](LICENSE) -![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5+-green?logo=espressif) -![C](https://img.shields.io/badge/C-99-blue?logo=c) -![ESP32-S3](https://img.shields.io/badge/ESP32--S3-supported-blue?logo=espressif) -![Configuration Oriented](https://img.shields.io/badge/Configuration-Oriented-orange) +# TianshanOS · 天山OS -> 天山操作系统 - ESP32 机架管理操作系统 -> -> 天山控制南北两大盆地——北向 AGX 提供 AI 算力,南向 LPMU 提供通用计算和存储服务 +TianshanOS 是 RM-01 的设备管理系统,运行在 ESP32 带外管理控制芯片上。你可以通过浏览器查看设备状态,管理电源、风扇和灯光,配置网络,也可以设置 SSH 指令和自动化规则。 ---- +**当前版本:0.6.1** · Web 界面支持中文和英文 · [0.6.1 更新说明](docs/releases/v0.6.1.md) -## 项目概述 +## 主要功能 -TianshanOS 是一个**面向配置而非面向代码**的嵌入式操作系统框架,基于 ESP-IDF v5.5+ 开发,用于 NVIDIA Jetson AGX + DFRobot LattePanda Mu 载板的机架管理。 +| 功能 | 你可以做什么 | +|---|---| +| 设备状态与电源 | 查看温度、电压、功率和系统状态,控制 AGX 与 LPMU 电源。 | +| 风扇与灯光 | 调整风扇转速,设置自动控制和温度曲线,管理灯光、特效和显示内容。 | +| 网络与文件 | 配置 Wi-Fi 和以太网,浏览、上传和下载设备文件。 | +| SSH 指令 | 管理远程主机和指令,执行命令,查看服务状态并启动或停止服务。 | +| 自动化与快捷操作 | 设置数据源、条件和动作,将规则显示为首页快捷操作。 | +| 安全与更新 | 管理账号、SSH 密钥和证书,查看版本并更新固件。 | -### 核心特性 +## 开始使用 -- **完全模块化** - 236 个 C 源文件,119 个头文件,20 个独立组件 -- **面向配置** - 通过 JSON 配置文件定义硬件引脚和系统行为 -- **统一配置系统** - SD 卡优先 + NVS 备份双写,支持热插拔同步、Schema 版本迁移 -- **配置包加密** - ECDH + AES-256-GCM 混合加密,ECDSA 签名,PKI 证书集成 -- **自动化引擎** - 数据源采集、规则引擎、动作执行、变量系统 -- **OTA 升级** - HTTPS/SD 卡双通道固件升级,自动回滚保护 -- **跨平台设计** - 支持 ESP32-S3 和 ESP32-P4 -- **安全优先** - HTTPS/mTLS、SSH 公钥认证、PKI 证书管理、分级权限 -- **统一接口** - CLI 和 WebUI 共享 Core API,行为一致 -- **多语言支持** - 中/英双语界面 -- **CI/CD** - GitHub Actions 自动编译、Release 发布 +1. 为 RM-01 接通电源,并将设备接入网络。 +2. 在同一网络中的电脑上打开浏览器,输入设备的 IP 地址。 +3. 使用设备账号登录,在首页查看状态并打开需要的功能页面。 -### 系统架构 +终端、指令和自动化页面需要 root 权限。配置保存、SD 卡备份和固件更新的操作步骤,请参阅用户说明。更新 0.6.1 固件时,应用固件和 Web 界面资源应使用同一版本。 -```mermaid -flowchart TB - UI["CLI / WebUI / HTTPS API"] --> API["Core API (ts_api)"] - API --> S1["服务管理"] & S2["自动化引擎"] & S3["安全模块"] - S1 & S2 & S3 --> Event["事件总线 (ts_event)"] - Event --> Config["配置管理 (NVS/SD卡)"] - Config --> HAL["硬件抽象层 (GPIO/PWM/I2C/SPI/UART/ADC)"] - HAL --> Platform["平台适配层 (ESP32-S3 / ESP32-P4)"] -``` - ---- - -## 项目结构 - -``` -TianshanOS/ -├── .github/ # GitHub 配置 -│ └── workflows/ # CI/CD 工作流 -│ └── build.yml # 自动编译 & Release -├── components/ # ESP-IDF 组件 (20个) -│ ├── ts_core/ # 核心框架 (配置/事件/服务/日志) -│ ├── ts_hal/ # 硬件抽象层 (GPIO/PWM/I2C/SPI/UART/ADC) -│ ├── ts_console/ # 控制台 (命令/多语言/脚本引擎) -│ ├── ts_api/ # Core API (统一接口层) -│ ├── ts_automation/ # 自动化引擎 (数据源/规则/动作/变量) -│ ├── ts_config_pack/ # 配置包加密 (ECDH/AES-GCM/ECDSA) -│ ├── ts_led/ # LED 系统 (WS2812/图层/特效/滤镜) -│ ├── ts_net/ # 网络 (WiFi/以太网/DHCP/NAT/HTTP) -│ ├── ts_security/ # 安全 (SSH客户端/认证/加密/SFTP) -│ ├── ts_https/ # HTTPS 服务器 (mTLS/API网关) -│ ├── ts_cert/ # 证书管理 (生成/签发/验证) -│ ├── ts_pki_client/ # PKI 客户端 (证书申请/续期) -│ ├── ts_webui/ # WebUI (REST API/WebSocket/仪表盘) -│ ├── ts_storage/ # 存储 (SPIFFS/SD卡/文件操作) -│ ├── ts_ota/ # OTA升级 (HTTPS/SD卡/回滚) -│ ├── ts_drivers/ # 设备驱动 (风扇/电源/AGX/温度) -│ ├── ts_mempool/ # 内存池管理 -│ └── ts_jsonpath/ # JSONPath 解析器 -├── boards/ # 板级配置 (pins.json/services.json) -├── main/ # 主程序入口 -├── docs/ # 文档 -├── tools/ # 构建工具 & OTA 服务器 -├── sdcard/ # SD 卡内容模板 -├── partitions.csv # 分区表 (factory 3MB + storage) -└── sdkconfig.defaults # 默认配置 -``` - ---- - -## 开发环境 - -### 依赖 -- ESP-IDF v5.5.2+ -- Python 3.10+ -- CMake 3.16+ +## 用户说明 -### 快速开始 +详细用户说明请前往 [RMinte 官网 www.rminte.com](https://www.rminte.com/) 下载,包括日常操作、账号权限、配置保存和故障处理。 -本地最新 **0.6.1** 固件位于 `build/`:`TianShanOS.bin`、`www.bin`、`bootloader/bootloader.bin`、`partition_table/partition-table.bin`、`ota_data_initial.bin`。 -`build/firmware-manifest.json` 记录版本、构建标识和文件 SHA-256。刷写时读取同目录的 `flasher_args.json`,不要混用历史构建目录中的镜像。 +## 架构 -```bash -# 克隆项目 -git clone https://github.com/RMinte-AI/TianshanOS.git -cd TianshanOS +系统基于 ESP-IDF。当前仓库提供 RM-01(ESP32-S3)的板级配置。 -# 设置 ESP-IDF 环境 -. $HOME/esp/v5.5.2/esp-idf/export.sh - -# 设置目标芯片 -idf.py set-target esp32s3 - -# 配置项目 (TianshanOS 选项在顶层菜单) -idf.py menuconfig - -# 编译(带版本号更新) -./tools/build.sh --fresh - -# 烧录并监控 -idf.py -p /dev/ttyACM0 flash monitor +```mermaid +flowchart TB + UI["Web 界面 / 控制台 / API"] --> API["统一接口 · ts_api"] + API --> Services["设备管理 / 网络 / SSH / 自动化"] + Services --> Config["配置与存储 · SD 卡 / NVS"] + Services --> HAL["设备驱动与硬件抽象层"] + HAL --> MCU["ESP32 带外管理控制芯片"] ``` -### 预编译固件 +## 主要目录 -从 [Releases](https://github.com/RMinte-AI/TianshanOS/releases/latest) 下载预编译固件,使用 esptool 烧录: - -```bash -esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \ - 0x0 bootloader.bin \ - 0x8000 partition-table.bin \ - 0x20000 TianShanOS.bin +```text +TianshanOS/ +├── boards/ # 板级配置:引脚、设备和服务 +├── components/ # 系统服务、设备驱动和 Web 界面 +├── main/ # 系统启动入口 +├── docs/ # 技术文档与版本说明 +├── tools/ # 构建、版本管理及服务工具 +├── sdcard/ # SD 卡资源 +├── partitions.csv # Flash 分区表 +├── sdkconfig.defaults # 默认构建配置 +└── version.txt # 版本号 ``` -### VS Code 开发 - -推荐使用 ESP-IDF 扩展,支持一键构建/烧录/监控、Menuconfig 图形界面、代码补全和跳转。 - -详细说明请参阅 [快速入门指南](docs/QUICK_START.md)。 - ---- - -## 文档 - -| 文档 | 描述 | -|-----|------| -| [快速入门](docs/QUICK_START.md) | 环境搭建与首次运行 | -| [串口刷机](docs/FLASH_VIA_SERIAL.md) | 串口烧录与分区偏移说明 | -| [Root/Admin 密码恢复固件](docs/ROOT_PASSWORD_RESET_FIRMWARE.md) | root/admin 用户密码恢复固件的构建与刷写流程 | -| [架构设计](docs/ARCHITECTURE_DESIGN.md) | 系统架构与设计决策 | -| [配置系统设计](docs/CONFIG_SYSTEM_DESIGN.md) | 统一配置系统详细设计 | -| [自动化引擎](docs/AUTOMATION_ENGINE.md) | 触发器-条件-动作系统 | -| [GPIO 引脚映射](docs/GPIO_MAPPING.md) | 硬件引脚定义与 PCB 对应关系 | -| [API 设计](docs/API_DESIGN.md) | REST API 架构与设计理念 | -| [API 参考](docs/API_REFERENCE.md) | REST API 和 CLI 命令 | -| [命令规范](docs/COMMAND_SPECIFICATION.md) | CLI 命令格式规范 | -| [板级配置](docs/BOARD_CONFIGURATION.md) | 引脚和服务配置指南 | -| [LED 架构](docs/LED_ARCHITECTURE.md) | LED 系统多设备多图层架构 | -| [安全实现](docs/SECURITY_IMPLEMENTATION.md) | SSH、PKI、认证机制 | -| [开发进度](docs/DEVELOPMENT_PROGRESS.md) | 功能实现状态跟踪 | -| [测试计划](docs/TEST_PLAN.md) | 测试策略与用例 | -| [故障排除](docs/TROUBLESHOOTING.md) | 常见问题与解决方案 | - ---- - -## 当前状态 - -**版本**: 0.4.7 -**阶段**: Phase 40 完成 - 自动风扇温度失效降速优化 - -### 已完成功能 - -| 模块 | 功能 | -|-----|------| -| 核心框架 | 配置管理、事件总线、8 阶段服务管理、日志系统 | -| 配置系统 | SD 卡优先 + NVS 备份双写、热插拔同步、Schema 版本迁移、独立文件存储 | -| **配置包加密** | ECDH + AES-256-GCM 混合加密、ECDSA 签名、PKI 集成、启动自动加载 | -| 硬件抽象 | GPIO、PWM、I2C、SPI、UART、ADC | -| LED 系统 | WS2812 驱动、多设备多图层、特效引擎、BMP/PNG/JPG/GIF | -| 控制台 | 命令系统、多语言、脚本引擎、配置持久化 | -| 网络 | WiFi、以太网 W5500、HTTP/HTTPS 服务器、LPMU 接入上层网络 | -| 安全 | 会话管理、Token 认证、AES-GCM、RSA/EC、SSH 客户端、PKI 证书管理 | -| 驱动 | 风扇控制、电源监控 (ADC/INA3221/PZEM)、AGX/LPMU 电源控制、USB MUX | -| WebUI | REST API 网关、WebSocket 广播、前端仪表盘、认证系统、中英文国际化 | -| OTA | 双分区升级、版本检测、完整性校验、自动回滚 | -| 自动化引擎 | 触发器-条件-动作系统、SSH 远程执行、正则解析、变量系统、电压保护集成 | -| CI/CD | GitHub Actions 自动编译、Tag/Release 触发固件发布 | - -### 配置包加密系统 (Config Pack) - -- **混合加密**:ECDH (P-256) 密钥协商 + AES-256-GCM 对称加密 -- **数字签名**:ECDSA-SHA256,使用设备 PKI 证书签名 -- **权限控制**:Developer 设备可导出,Device 设备仅可导入 -- **启动加载**:`.tscfg` 文件在 SECURITY 阶段自动加载并解密 -- **CLI 命令**:`config --pack-export/import/verify/info` -- **WebUI**:安全页面提供完整的配置包管理界面 - -### 自动化引擎特性 - -- **数据源**:定时器、SSH 命令输出、系统指标、GPIO、变量、HTTP -- **触发器**:阈值、正则匹配、时间表达式、变化检测 -- **动作**:变量设置、命令执行、事件发布、日志输出 -- **SSH 集成**:支持连续监控命令(如 `ping`)、实时变量更新、服务模式日志监控 -- **独立文件存储**:每个数据源/规则/动作模板存储为独立 JSON 文件 -- **电压保护变量**:Power Policy 状态机集成到变量系统 - -### 统一配置系统特性 - -- **7 个配置模块**:网络、DHCP、WiFi、LED、风扇、设备、系统 -- **双写同步机制**:NVS + SD 卡同时写入,确保一致性 -- **热插拔处理**:SD 卡拔出期间标记 `pending_sync`,插入后自动同步 -- **优先级管理**:SD 卡独立文件 > SD 卡单一文件 > NVS > Schema 默认值 -- **序列号机制**:无需 RTC,通过单调递增序列号判断配置新旧 -- **Schema 迁移**:支持配置格式升级与向后兼容 -- **显式持久化**:CLI 修改默认临时,`--save` 显式持久化 -- **PSRAM 优先**:所有 ≥128 字节动态分配优先使用 PSRAM - -查看 [开发进度](docs/DEVELOPMENT_PROGRESS.md) 了解详细状态。 - ---- - -## 多语言支持 - -WebUI 支持中英文界面,根据浏览器设置自动检测: - -| 语言 | 代码 | -|------|------| -| 简体中文 | zh-CN | -| English | en-US | - ---- - -## 贡献者 - -- Thomas (项目负责人) -- massif-01 - ---- - ## 许可证 -本项目采用 GPL-3.0 许可证,详见 [LICENSE](LICENSE) 文件。 +本项目采用 [GPL-3.0 许可证](LICENSE)。 diff --git a/README_EN.md b/README_EN.md index b5a1520..11ae279 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,187 +2,63 @@ [English](README_EN.md) | [中文](README.md) -[![Build Status](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml/badge.svg)](https://github.com/RMinte-AI/TianshanOS/actions/workflows/build.yml) -![Lighthouse Performance](https://img.shields.io/badge/Lighthouse-97-brightgreen?logo=lighthouse&logoColor=white) -[![Release](https://img.shields.io/github/v/release/RMinte-AI/TianshanOS)](https://github.com/RMinte-AI/TianshanOS/releases/latest) -[![License](https://img.shields.io/github/license/RMinte-AI/TianshanOS)](LICENSE) -![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5+-green?logo=espressif) -![C](https://img.shields.io/badge/C-99-blue?logo=c) -![ESP32-S3](https://img.shields.io/badge/ESP32--S3-supported-blue?logo=espressif) -![Configuration Oriented](https://img.shields.io/badge/Configuration-Oriented-orange) +# TianshanOS -> Tianshan Operating System - ESP32 Rack Management Operating System -> -> Tianshan controls both the northern and southern basins — northward to AGX for AI computing power, southward to LPMU for general computing and storage services +TianshanOS is the device management system for RM-01. It runs on the ESP32 out-of-band management controller. Use a browser to check device status, manage power, fans and lighting, configure networking, and set up SSH commands and automation rules. ---- +**Current version: 0.6.1** · Web interface in Chinese and English · [0.6.1 release notes (Chinese)](docs/releases/v0.6.1.md) -## Project Overview +## Features -TianshanOS is a **configuration-oriented rather than code-oriented** embedded operating system framework, developed based on ESP-IDF v5.5+, designed for rack management of NVIDIA Jetson AGX + DFRobot LattePanda Mu carrier boards. +| Feature | What you can do | +|---|---| +| Device status and power | Check temperature, voltage, power consumption and system status. Control power to the AGX and LPMU. | +| Fans and lighting | Adjust fan speed, configure automatic control and temperature curves, and manage lighting, effects and display content. | +| Networking and files | Configure Wi-Fi and Ethernet. Browse, upload and download files on the device. | +| SSH commands | Manage remote hosts and commands, run commands, check service status, and start or stop services. | +| Automation and quick actions | Configure data sources, conditions and actions. Show rules as quick actions on the dashboard. | +| Security and updates | Manage accounts, SSH keys and certificates. Check the firmware version and install updates. | -### Core Features +## Getting started -- **Fully Modular** - 220+ C source files, 110+ header files, 18 independent components -- **Configuration-Oriented** - Define hardware pins and system behavior through JSON configuration files -- **Unified Configuration System** - SD card priority + NVS backup dual-write, supports hot-swap sync, Schema version migration -- **Automation Engine** - Data source collection, rule engine, action execution, variable system -- **OTA Upgrade** - HTTPS/SD card dual-channel firmware upgrade with automatic rollback protection -- **Cross-Platform Design** - Supports ESP32-S3 and ESP32-P4 -- **Security First** - HTTPS/mTLS, SSH public key authentication, PKI certificate management, tiered permissions -- **Unified Interface** - CLI and WebUI share Core API with consistent behavior -- **Multi-Language Support** - Chinese and English interface support +1. Connect RM-01 to power and a network. +2. Open a browser on a computer connected to the same network and enter the device's IP address. +3. Sign in with your device account. Check status on the dashboard and open the page you need. -### System Architecture +The Terminal, SSH Commands and Automation pages require root access. See the user guide for configuration saving, SD card backups and firmware updates. When updating to 0.6.1, use matching versions of the application firmware and Web interface resources. -```mermaid -flowchart TB - UI["CLI / WebUI / HTTPS API"] --> API["Core API (ts_api)"] - API --> S1["Service Mgmt"] & S2["Automation"] & S3["Security"] - S1 & S2 & S3 --> Event["Event Bus (ts_event)"] - Event --> Config["Config Management (NVS/SD)"] - Config --> HAL["HAL (GPIO/PWM/I2C/SPI/UART/ADC)"] - HAL --> Platform["Platform Layer (ESP32-S3 / ESP32-P4)"] -``` - ---- - -## Project Structure - -``` -TianshanOS/ -├── components/ # ESP-IDF Components (18 total) -│ ├── ts_core/ # Core Framework (config/event/service/log) -│ ├── ts_hal/ # Hardware Abstraction Layer (GPIO/PWM/I2C/SPI/UART/ADC) -│ ├── ts_console/ # Console (commands/multi-language/script engine) -│ ├── ts_api/ # Core API (unified interface layer) -│ ├── ts_automation/ # Automation Engine (datasource/rules/actions/variables) -│ ├── ts_led/ # LED System (WS2812/layers/effects/filters) -│ ├── ts_net/ # Networking (WiFi/Ethernet/DHCP/NAT/HTTP) -│ ├── ts_security/ # Security (SSH client/auth/encryption/SFTP) -│ ├── ts_https/ # HTTPS Server (mTLS/API gateway) -│ ├── ts_cert/ # Certificate Management (generate/issue/verify) -│ ├── ts_pki_client/ # PKI Client (certificate request/renewal) -│ ├── ts_webui/ # WebUI (REST API/WebSocket/dashboard) -│ ├── ts_storage/ # Storage (SPIFFS/SD card/file operations) -│ ├── ts_ota/ # OTA Upgrade (HTTPS/SD card/rollback) -│ ├── ts_drivers/ # Device Drivers (fan/power/AGX/temperature) -│ └── ts_jsonpath/ # JSONPath Parser -├── boards/ # Board Configurations (pins.json/services.json) -├── main/ # Main Program Entry -├── docs/ # Documentation -├── sdcard/ # SD Card Content Template -├── partitions.csv # Partition Table (factory 3MB + storage) -└── sdkconfig.defaults # Default Configuration -``` +## User guides ---- +Download detailed user guides from the [RMinte website at www.rminte.com](https://www.rminte.com/). They cover everyday tasks, account permissions, configuration saving and troubleshooting. -## Development Environment +## Architecture -### Dependencies -- ESP-IDF v5.5.2+ -- Python 3.10+ -- CMake 3.16+ +The system is built on ESP-IDF. This repository includes the board configuration for RM-01 (ESP32-S3). -### Quick Start - -The latest local **0.6.1** firmware is under `build/`: `TianShanOS.bin`, `www.bin`, `bootloader/bootloader.bin`, `partition_table/partition-table.bin`, and `ota_data_initial.bin`. -`build/firmware-manifest.json` records the version, build identifier, and file SHA-256 values. Use `flasher_args.json` from the same directory; do not mix images from historical build directories. - -```bash -# Clone the repository -git clone https://github.com/RMinte-AI/TianshanOS.git -cd TianshanOS - -# Set up ESP-IDF environment -. $HOME/esp/v5.5.2/esp-idf/export.sh - -# Set target chip -idf.py set-target esp32s3 - -# Configure project (TianshanOS options are in the top-level menu) -idf.py menuconfig - -# Build -idf.py build - -# Flash and monitor -idf.py -p /dev/ttyACM0 flash monitor +```mermaid +flowchart TB + UI["Web interface / Console / API"] --> API["Shared API · ts_api"] + API --> Services["Device management / Networking / SSH / Automation"] + Services --> Config["Configuration and storage · SD card / NVS"] + Services --> HAL["Device drivers and hardware abstraction"] + HAL --> MCU["ESP32 out-of-band management controller"] ``` -### VS Code Development +## Main directories -Using the ESP-IDF extension is recommended, which supports one-click build/flash/monitor, Menuconfig GUI, code completion and navigation. - -For detailed instructions, please refer to the [Quick Start Guide](docs/QUICK_START.md). - ---- - -## Documentation - -| Document | Description | -|----------|-------------| -| [Quick Start](docs/QUICK_START.md) | Environment setup and first run | -| [Architecture Design](docs/ARCHITECTURE_DESIGN.md) | System architecture and design decisions | -| [Config System Design](docs/CONFIG_SYSTEM_DESIGN.md) | Detailed design of unified configuration system | -| [Automation Engine](docs/AUTOMATION_ENGINE.md) | Trigger-condition-action system | -| [GPIO Pin Mapping](docs/GPIO_MAPPING.md) | Hardware pin definitions and PCB mapping | -| [API Design](docs/API_DESIGN.md) | REST API architecture and design philosophy | -| [API Reference](docs/API_REFERENCE.md) | REST API and CLI commands | -| [Command Specification](docs/COMMAND_SPECIFICATION.md) | CLI command format specification | -| [Board Configuration](docs/BOARD_CONFIGURATION.md) | Pin and service configuration guide | -| [LED Architecture](docs/LED_ARCHITECTURE.md) | LED system multi-device multi-layer architecture | -| [Security Implementation](docs/SECURITY_IMPLEMENTATION.md) | SSH, PKI, authentication mechanisms | -| [Development Progress](docs/DEVELOPMENT_PROGRESS.md) | Feature implementation status tracking | -| [Test Plan](docs/TEST_PLAN.md) | Testing strategy and use cases | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common issues and solutions | - ---- - -## Current Status - -**Version**: 0.4.6 -**Phase**: Phase 39 Complete - LPMU Upstream Network Access - -### Completed Features - -| Module | Features | -|--------|----------| -| Core Framework | Configuration management, event bus, 8-stage service management, logging system | -| Configuration System | SD card priority + NVS backup dual-write, hot-swap sync, Schema version migration | -| Hardware Abstraction | GPIO, PWM, I2C, SPI, UART, ADC | -| LED System | WS2812 driver, multi-device multi-layer, effects engine, BMP/PNG/JPG/GIF | -| Console | Command system, multi-language, script engine, configuration persistence | -| Networking | WiFi, Ethernet W5500, HTTP/HTTPS server, LPMU upstream network access | -| Security | Session management, Token authentication, AES-GCM, RSA/EC, SSH client, PKI certificate management | -| Drivers | Fan control, power monitoring (ADC/INA3221/PZEM), AGX/LPMU power control, USB MUX | -| WebUI | REST API gateway, WebSocket broadcast, frontend dashboard | -| OTA | Dual-partition upgrade, version detection, integrity verification, automatic rollback | -| Automation Engine | Trigger-condition-action system, SSH remote execution, regex parsing, variable system | - -### Automation Engine Features - -- **Data Sources**: Timer, SSH command output, system metrics, GPIO, variables, HTTP -- **Triggers**: Threshold, regex matching, time expressions, change detection -- **Actions**: Variable setting, command execution, event publishing, logging -- **SSH Integration**: Supports continuous monitoring commands (e.g., `ping`), real-time variable updates - -### Unified Configuration System Features - -- **7 Configuration Modules**: Network, DHCP, WiFi, LED, Fan, Device, System -- **Dual-Write Sync Mechanism**: NVS + SD card simultaneous write ensures consistency -- **Hot-Swap Handling**: Mark as `pending_sync` during SD card removal, auto-sync on insertion -- **Priority Management**: Memory cache > SD card > NVS > Schema defaults -- **Sequence Number Mechanism**: No RTC required, uses monotonically increasing sequence numbers to determine configuration versions -- **Schema Migration**: Supports configuration format upgrades and backward compatibility -- **Explicit Persistence**: CLI modifications are temporary by default, use `--save` for explicit persistence - -See [Development Progress](docs/DEVELOPMENT_PROGRESS.md) for detailed status. - ---- +```text +TianshanOS/ +├── boards/ # Board configuration: pins, devices and services +├── components/ # System services, device drivers and Web interface +├── main/ # System startup +├── docs/ # Technical documentation and release notes +├── tools/ # Build, version management and service tools +├── sdcard/ # SD card resources +├── partitions.csv # Flash partition table +├── sdkconfig.defaults # Default build configuration +└── version.txt # Version number +``` -## Contributors +## License -- Thomas (Project Lead) -- massif-01 +This project is licensed under [GPL-3.0](LICENSE). From 2bdd714c9c956dbea440e4338df6a3d76540dcee Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 05:15:47 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=88=A0=E9=99=A4=E4=BA=A7=E7=94=9F=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E8=A7=84=E5=88=99=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ts_api/src/ts_api_automation.c | 34 ++- .../ts_automation/include/ts_action_manager.h | 12 + .../ts_automation/include/ts_rule_engine.h | 7 +- .../ts_automation/src/ts_action_manager.c | 79 ++++- components/ts_automation/src/ts_rule_engine.c | 37 +++ components/ts_webui/web/css/style.css | 1 + components/ts_webui/web/index.html | 10 +- components/ts_webui/web/js/api.js | 1 + components/ts_webui/web/js/app.js | 110 ++++++- components/ts_webui/web/js/lang/en-US.js | 31 +- components/ts_webui/web/js/lang/zh-CN.js | 31 +- docs/repair/delete-protection/COPY.md | 36 +++ docs/repair/delete-protection/PLAN.md | 65 ++++ docs/repair/delete-protection/RESULT.md | 104 +++++++ .../delete-protection/evidence/artifacts.json | 27 ++ .../delete-protection/evidence/browser.log | 280 ++++++++++++++++++ .../delete-protection/evidence/build.log | 52 ++++ .../evidence/reference-wire.log | 2 + .../evidence/round3-browser.log | 46 +++ .../evidence/round3-unit.log | 42 +++ .../evidence/round3-verification.json | 54 ++++ .../delete-protection/evidence/round3.patch | 231 +++++++++++++++ .../delete-protection/evidence/runtime.log | 107 +++++++ .../evidence/source-hashes-before-round3.json | 32 ++ .../evidence/source-hashes.json | 33 +++ .../delete-protection/evidence/unit.log | 139 +++++++++ .../delete-protection/resource-deltas.json | 32 ++ .../repair/delete-protection/target-sizes.txt | 9 + tests/fixtures/delete-reference/README.md | 7 + .../delete-reference/action-missing.json | 1 + tests/fixtures/delete-reference/boundary.json | 1 + .../delete-reference/check-loading.json | 1 + .../delete-reference/check-recovery.json | 1 + .../delete-reference/check-uninitialized.json | 1 + .../delete-reference/in-use-no-details.json | 1 + tests/fixtures/delete-reference/in-use.json | 1 + .../delete-reference/service-protected.json | 1 + .../delete-protection.browser.test.cjs | 17 +- tests/prompts/delete-protection.test.cjs | 7 +- .../prompts/delete-reference.browser.test.cjs | 148 +++++++++ tests/prompts/delete-reference.test.cjs | 28 ++ tests/prompts/package.json | 4 +- tests/prompts/static.test.cjs | 2 +- tests/runtime/extract_engine.py | 2 +- tests/runtime/run_all.sh | 1 + tests/runtime/test_delete_api.py | 7 +- tests/runtime/test_delete_reference.py | 156 ++++++++++ tests/runtime/test_engine.c | 9 +- 48 files changed, 1993 insertions(+), 47 deletions(-) create mode 100644 docs/repair/delete-protection/COPY.md create mode 100644 docs/repair/delete-protection/PLAN.md create mode 100644 docs/repair/delete-protection/RESULT.md create mode 100644 docs/repair/delete-protection/evidence/artifacts.json create mode 100644 docs/repair/delete-protection/evidence/browser.log create mode 100644 docs/repair/delete-protection/evidence/build.log create mode 100644 docs/repair/delete-protection/evidence/reference-wire.log create mode 100644 docs/repair/delete-protection/evidence/round3-browser.log create mode 100644 docs/repair/delete-protection/evidence/round3-unit.log create mode 100644 docs/repair/delete-protection/evidence/round3-verification.json create mode 100644 docs/repair/delete-protection/evidence/round3.patch create mode 100644 docs/repair/delete-protection/evidence/runtime.log create mode 100644 docs/repair/delete-protection/evidence/source-hashes-before-round3.json create mode 100644 docs/repair/delete-protection/evidence/source-hashes.json create mode 100644 docs/repair/delete-protection/evidence/unit.log create mode 100644 docs/repair/delete-protection/resource-deltas.json create mode 100644 docs/repair/delete-protection/target-sizes.txt create mode 100644 tests/fixtures/delete-reference/README.md create mode 100644 tests/fixtures/delete-reference/action-missing.json create mode 100644 tests/fixtures/delete-reference/boundary.json create mode 100644 tests/fixtures/delete-reference/check-loading.json create mode 100644 tests/fixtures/delete-reference/check-recovery.json create mode 100644 tests/fixtures/delete-reference/check-uninitialized.json create mode 100644 tests/fixtures/delete-reference/in-use-no-details.json create mode 100644 tests/fixtures/delete-reference/in-use.json create mode 100644 tests/fixtures/delete-reference/service-protected.json create mode 100644 tests/prompts/delete-reference.browser.test.cjs create mode 100644 tests/prompts/delete-reference.test.cjs create mode 100644 tests/runtime/test_delete_reference.py diff --git a/components/ts_api/src/ts_api_automation.c b/components/ts_api/src/ts_api_automation.c index 96f8a0a..b6dd7a3 100644 --- a/components/ts_api/src/ts_api_automation.c +++ b/components/ts_api/src/ts_api_automation.c @@ -542,6 +542,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"), @@ -2335,7 +2337,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 +2354,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; } diff --git a/components/ts_automation/include/ts_action_manager.h b/components/ts_automation/include/ts_action_manager.h index 49bedcd..9e68828 100644 --- a/components/ts_automation/include/ts_action_manager.h +++ b/components/ts_automation/include/ts_action_manager.h @@ -465,6 +465,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 43f555c..2cf9153 100644 --- a/components/ts_automation/include/ts_rule_engine.h +++ b/components/ts_automation/include/ts_rule_engine.h @@ -83,7 +83,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); diff --git a/components/ts_automation/src/ts_action_manager.c b/components/ts_automation/src/ts_action_manager.c index 127efed..3fa100e 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" @@ -3314,12 +3315,80 @@ esp_err_t ts_action_template_update(const char *id, const ts_action_template_t * ts_ssh_binding_unlock(); return ret; } -esp_err_t ts_action_template_remove(const char *id) { - if (!id) - return ESP_ERR_INVALID_ARG; +/* The caller holds transaction before binding: 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) { + 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_rule_engine.c b/components/ts_automation/src/ts_rule_engine.c index e7f9060..8721226 100644 --- a/components/ts_automation/src/ts_rule_engine.c +++ b/components/ts_automation/src/ts_rule_engine.c @@ -505,6 +505,36 @@ static bool protected_bindings(const ts_auto_rule_t *old, const ts_auto_rule_t * } return false; } +/* 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; +} 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_rule_commit_result_t local = {.error_code = "invalid_argument"}; @@ -587,6 +617,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) { + ts_ssh_binding_unlock(); + 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); diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index c2683f3..ea6f7f2 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 d93c320..a18d968 100644 --- a/components/ts_webui/web/index.html +++ b/components/ts_webui/web/index.html @@ -7,8 +7,8 @@ - - + + @@ -88,7 +88,7 @@ - + - + diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index b34d6b3..22dfef8 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -75,6 +75,7 @@ class ApiOperationError extends Error { this.name = 'ApiOperationError'; this.code = result?.code; this.rawMessage = apiErrorReason(result); + this.data = result?.data; this.operation = operation; this.httpStatus = result?.httpStatus; Object.assign(this, metadata); diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 7914498..e6db28f 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -6,6 +6,11 @@ function runtimeText(key) { function runtimeSaveError(result) { const code = result.data?.error_code || result.rawMessage || result.error || result.message; const known = ['revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config']; + if (code === 'action_missing') { + const name = result.missingTemplateName || result.data?.missing_template_id; + return name ? t('deleteProtection.actionMissing', {name}) : runtimeText('saveFailed'); + } + if (code === 'template_lookup_failed') return runtimeText('saveFailed'); return known.includes(code) ? runtimeText(code) : (typeof code === 'string' ? code : runtimeText('saveFailed')); } async function ruleWriteWithRevision(method, id) { @@ -3833,7 +3838,7 @@ async function refreshQuickActions() { if (result.code === 0 && result.data && result.data.rules) { // 过滤出启用且标记为可手动触发的规则 if (!result.data.loaded || result.data.recovery_required) { - container.textContent = runtimeText(result.data.recovery_required ? 'recovery_required' : 'configLoading'); return; + renderQuickActionNotice(container, result.data.recovery_required ? 'recovery' : 'loading'); return; } const allRules = result.data.rules; const manualRules = allRules.filter(r => r.show_on_dashboard === true); @@ -3900,12 +3905,7 @@ async function refreshQuickActions() { // 启动定时刷新服务状态(每 3 秒) startServiceStatusRefresh(); } else { - container.innerHTML = ` -
-

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

-

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

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

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

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

' + escapeHtml(title) + '

' + escapeHtml(body) + + '

' + (detail ? '

' + escapeHtml(detail) + '

' : '') + + (isRoot ? '' : '') + '
'; +} + const configurationDeletesInFlight = new Set(); -async function verifyStoppedServiceForDelete(commandId, pageCurrent) { +async function verifyStoppedServiceForDelete(commandId, pageCurrent, context = {type: 'command', name: commandId}) { const revision = advanceServiceState(commandId); const isCurrent = () => pageCurrent() && revision === serviceStateVersions.get(commandId); try { @@ -8271,8 +8340,11 @@ async function verifyStoppedServiceForDelete(commandId, pageCurrent) { const data = await requestServiceControl(commandId, false, isCurrent); if (!data || !isCurrent()) return false; renderServiceCommand(commandId, data); - if (data.state !== 'stopped' || data.busy) - throw new ApiOperationError({code: 4, error: 'service_delete_protected'}); + if (deleteServiceMessage(data)) { + await showDeleteProtection({code: 4, error: 'service_delete_protected'}, + {...context, commandId, commandName: command.name || commandId, service: data}, isCurrent); + return false; + } return true; } catch (error) { if (!isCurrent()) return false; @@ -9432,7 +9504,7 @@ async function deleteCommand(idx) { if (!await confirmAction(typeof t === 'function' ? t('ui.confirmDeleteCmd', { name: cmd.name }) : `确定要删除指令「${cmd.name}」吗?`, { primary: t('common.delete'), tone: 'danger' }) || !pageCurrent()) return; if (cmd.id) { showToast(t('toast.processing'), 'info'); - if (!await verifyStoppedServiceForDelete(cmd.id, pageCurrent) || !pageCurrent()) return; + if (!await verifyStoppedServiceForDelete(cmd.id, pageCurrent, {type: 'command', name: cmd.name || cmd.id}) || !pageCurrent()) return; await deleteSshCommandFromBackend(cmd.id); } if (!pageCurrent()) return; @@ -9442,6 +9514,7 @@ async function deleteCommand(idx) { if (selectedHostId === hostId) refreshCommandsList(); showToast((typeof t === 'function' ? t('toast.commandDeleted') : '指令已删除'), 'success'); } catch (e) { + if (pageCurrent() && await showDeleteProtection(e, {type: 'command', name: cmd.name || cmd.id}, pageCurrent)) return; if (pageCurrent()) showToast((typeof t === 'function' ? t('toast.deleteCommandFailedMsg', { msg: e.message }) : '删除指令失败: ' + e.message), 'error'); } finally { configurationDeletesInFlight.delete(key); @@ -13048,7 +13121,7 @@ function confirmSheet({ title, body = '', bodyHtml = '', primary, tone = 'neutra else if (e.key === 'Enter' && tone !== 'danger' && e.target.tagName !== 'BUTTON') { e.preventDefault(); finish(true); } }; modal.innerHTML = sheet(width, escapeHtml(title), `
${bodyHtml || escapeHtml(body)}
`, - `` + + (secondary === false ? '' : ``) + (third ? `` : '') + ``); modal.onclick = e => { @@ -16850,11 +16923,13 @@ async function deleteAction(id) { showToast(t('toast.processing'), 'info'); const template = requireApiSuccess(await api.call('automation.actions.get', { id }), 'automation.actions.get').data; if (!pageCurrent()) return; + const context = {type: 'action', name: template?.name || id, commandId: template?.ssh_ref?.cmd_id}; if (template?.type === 'ssh_cmd_ref' && template.ssh_ref?.cmd_id && - !await verifyStoppedServiceForDelete(template.ssh_ref.cmd_id, pageCurrent)) return; + !await verifyStoppedServiceForDelete(template.ssh_ref.cmd_id, pageCurrent, context)) return; if (!pageCurrent()) return; const result = await api.call('automation.actions.delete', { id }); if (!pageCurrent()) return; + if (result.code !== 0 && await showDeleteProtection(result, context, pageCurrent)) return; showToast(typeof t === 'function' ? t('toast.deleteActionResult', { id }) + ': ' + (result.message || 'OK') : `删除动作 ${id}: ${result.message || 'OK'}`, result.code === 0 ? 'success' : 'error'); if (result.code === 0) await refreshActions(); } catch (error) { @@ -18276,11 +18351,11 @@ async function addActionTemplateRow(templateId = '', delayMs = 0, repeatMode = ' cachedActionTemplates.forEach(tpl => { const typeLabel = getActionTypeLabel(tpl.type); const selected = tpl.id === templateId ? 'selected' : ''; - optionsHtml += ``; + optionsHtml += ``; }); if (templateId && !cachedActionTemplates.some(tpl => tpl.id === templateId)) - optionsHtml += ``; + optionsHtml += ``; const rowId = actionRowCount; const showRepeatOptions = repeatMode !== 'once'; const hasCondition = condition && condition.variable; @@ -18567,6 +18642,7 @@ async function submitAddRule(originalId = null) { // 收集动作模板引用(包含 template_id、delay_ms、重复选项和动作条件) const actions = []; + const selectedTemplateNames = new Map(); for (const row of document.querySelectorAll('#add-rule-modal .action-row')) { if (row.dataset.inline === 'true') { actions.push(structuredClone(row._originalAction)); continue; } const templateId = row.querySelector('.action-template-id')?.value; @@ -18580,6 +18656,8 @@ async function submitAddRule(originalId = null) { if (hasCondition && !templateId) { fieldError('actions-container', t('ui.alertSelectAction')); return; } if (templateId) { + const select = row.querySelector('.action-template-id'); + selectedTemplateNames.set(templateId, select.selectedOptions?.[0]?.dataset.templateName || templateId); const actionRef = row._originalAction?.template_id === templateId ? structuredClone(row._originalAction) : {}; actionRef.template_id = templateId; actionRef.delay_ms = delay_ms; @@ -18641,6 +18719,8 @@ async function submitAddRule(originalId = null) { closeModal('add-rule-modal'); await Promise.all([refreshRules(), refreshAutomationStatus()]); } else { + if (result.data?.error_code === 'action_missing') + result.missingTemplateName = selectedTemplateNames.get(result.data.missing_template_id) || result.data.missing_template_id; showToast(runtimeSaveError(result), 'error'); } } catch (error) { diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index f8e20e8..ccc03c8 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -12,6 +12,35 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { scalarRequired: "Comparison values support strings, numbers, booleans or null, not arrays or objects.", typeInvalid: "The comparison value conflicts with its original type. Check the input.", }, + deleteProtection: { + "actionTitle": "Can't delete action template", + "actionInUse": "Action template “{name}” is used by the rules below. Remove it from those rules, or delete rules you no longer need, before deleting the template.", + "actionInUseWithoutDetails": "Action template “{name}” is still used by rules, so it can't be deleted. Remove it from those rules first.", + "viewRules": "View rules", + "commandTitle": "Can't delete command “{name}”", + "templateTitle": "Can't delete action template “{name}”", + "boundCommand": "This action template uses command “{command}”.", + "running": "The service for command “{command}” is still running. Stop the service and confirm it has stopped before deleting.", + "unconfirmed": "It hasn't been confirmed that the service for command “{command}” has stopped. On the SSH Commands page, select the refresh icon for this command to verify its state. If it is still running, stop it before deleting.", + "starting": "The service for command “{command}” is starting. Wait for the operation to finish before deleting.", + "stopping": "The service for command “{command}” is stopping. Wait for the operation to finish before deleting.", + "verifying": "The state of the service for command “{command}” is being verified. Wait for verification to finish before deleting.", + "operationPending": "An operation on the service for command “{command}” is still in progress. Wait for it to finish before deleting.", + "viewCommand": "View commands", + "checkBusy": "The automation configuration is being updated. The action template was not deleted. Wait for the update to finish before deleting.", + "checkLoading": "The rule configuration is still loading. The action template was not deleted. Wait for loading to finish before deleting.", + "checkRecovery": "The rule configuration needs a recovery check. The action template was not deleted. Open “System Logs” on the Terminal page for details.", + "checkFailed": "Couldn't confirm whether any rule still uses this action template, so it was not deleted. Open “System Logs” on the Terminal page to see why.", + "actionMissing": "The rule was not saved. Action template “{name}” no longer exists; it may have been deleted. Select an existing action template and save again.", + "quickLoadingTitle": "Loading quick actions", + "quickRecoveryTitle": "Quick actions unavailable", + "recoveryDetails": "Open “System Logs” on the Terminal page for details.", + "goAutomation": "Go to Automation", + "loadingDetails": "If nothing changes for a long time, open “System Logs” on the Terminal page to see why.", + "quickLoadingNeedRoot": "If nothing changes for a long time, contact a user with root access to find out why.", + "quickRecoveryNeedRoot": "Contact a user with root access to resolve this.", + "quickEmptyNeedRoot": "To add quick actions, contact a user with root access." +}, promptRepair: { serviceDeleteProtected: 'The bound service is not confirmed stopped, or an operation is using it. Verify or stop the service and wait for operations to finish before deleting this configuration.', taskResults: 'Configuration task history', @@ -3045,7 +3074,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { triggerConditions: 'Trigger Conditions', // Quick actions noQuickActions: 'No quick actions', - quickActionsHint: 'Enable "Manual Trigger" option in automation rules', + quickActionsHint: "No rule has “Show on dashboard” enabled. Create a rule on the Automation page, or edit an existing rule and enable “Show on dashboard”.", loadQuickActionsFailed: 'Failed to load quick actions', processRunning: 'Process running', processNotRunning: 'Process not running', diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 6663157..331ae59 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -12,6 +12,35 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { scalarRequired: "比较值仅支持字符串、数字、布尔值或 null,不支持数组和对象。", typeInvalid: "比较值与原始类型不一致,请检查输入。", }, + deleteProtection: { + "actionTitle": "无法删除动作模板", + "actionInUse": "动作模板“{name}”正被以下规则使用。请先把它从这些规则中移除,或删除不再需要的规则,再删除动作模板。", + "actionInUseWithoutDetails": "动作模板“{name}”仍被规则使用,无法删除。请先把它从使用它的规则中移除。", + "viewRules": "查看规则", + "commandTitle": "无法删除指令“{name}”", + "templateTitle": "无法删除动作模板“{name}”", + "boundCommand": "此动作模板使用指令“{command}”。", + "running": "指令“{command}”对应的服务仍在运行。请先停止服务,确认已停止后再删除。", + "unconfirmed": "还没有确认指令“{command}”对应的服务是否已停止。请先在「指令」页点这条指令的刷新图标核验状态;如果它仍在运行,请先停止,再删除。", + "starting": "正在启动指令“{command}”对应的服务。请等待操作完成后再删除。", + "stopping": "正在停止指令“{command}”对应的服务。请等待操作完成后再删除。", + "verifying": "正在核验指令“{command}”对应的服务状态。请等待核验完成后再删除。", + "operationPending": "指令“{command}”对应的服务还有操作未完成。请等待操作完成后再删除。", + "viewCommand": "查看指令", + "checkBusy": "自动化配置正在更新,本次未删除动作模板。请等待更新完成后再删除。", + "checkLoading": "规则配置仍在加载,本次未删除动作模板。请等待加载完成后再删除。", + "checkRecovery": "规则配置需要恢复核对,本次未删除动作模板。请在「终端」页打开「系统日志」查看详情。", + "checkFailed": "没能确认是否还有规则在使用这个动作模板,所以没有删除。请在「终端」页打开「系统日志」查看原因。", + "actionMissing": "规则没有保存:动作模板“{name}”已不存在,可能已被删除。请重新选择动作模板后再保存。", + "quickLoadingTitle": "快捷操作正在加载", + "quickRecoveryTitle": "快捷操作暂不可用", + "recoveryDetails": "请在「终端」页打开「系统日志」查看详情。", + "goAutomation": "前往自动化", + "loadingDetails": "如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。", + "quickLoadingNeedRoot": "如果长时间没有变化,请联系拥有 root 权限的用户查看原因。", + "quickRecoveryNeedRoot": "请联系拥有 root 权限的用户处理。", + "quickEmptyNeedRoot": "如需添加快捷操作,请联系拥有 root 权限的用户。" +}, promptRepair: { serviceDeleteProtected: '该配置绑定的服务尚未确认停止,或有操作正在使用它。请先核验或停止服务,待操作完成后再删除。', taskResults: '配置应用任务记录', @@ -3007,7 +3036,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { triggerConditions: '触发条件', // 快捷操作 noQuickActions: '暂无快捷操作', - quickActionsHint: '在自动化规则中启用"手动触发"选项', + quickActionsHint: "没有规则勾选「在首页显示」。你可以在「自动化」页新建规则,或编辑已有规则并勾选「在首页显示」。", loadQuickActionsFailed: '无法加载快捷操作', processRunning: '进程运行中', processNotRunning: '进程未运行', diff --git a/docs/repair/delete-protection/COPY.md b/docs/repair/delete-protection/COPY.md new file mode 100644 index 0000000..3b03551 --- /dev/null +++ b/docs/repair/delete-protection/COPY.md @@ -0,0 +1,36 @@ +# 删除保护:实际使用的已确认文案 + +日期:2026-10-09。来源:用户确认的方案 v2 + 第二轮第 6 节;以下逐字取自实际语言包。 + +| key | 中文 | English | +|---|---|---| +| deleteProtection.actionTitle | 无法删除动作模板 | Can't delete action template | +| deleteProtection.actionInUse | 动作模板“{name}”正被以下规则使用。请先把它从这些规则中移除,或删除不再需要的规则,再删除动作模板。 | Action template “{name}” is used by the rules below. Remove it from those rules, or delete rules you no longer need, before deleting the template. | +| deleteProtection.actionInUseWithoutDetails | 动作模板“{name}”仍被规则使用,无法删除。请先把它从使用它的规则中移除。 | Action template “{name}” is still used by rules, so it can't be deleted. Remove it from those rules first. | +| deleteProtection.viewRules | 查看规则 | View rules | +| deleteProtection.commandTitle | 无法删除指令“{name}” | Can't delete command “{name}” | +| deleteProtection.templateTitle | 无法删除动作模板“{name}” | Can't delete action template “{name}” | +| deleteProtection.boundCommand | 此动作模板使用指令“{command}”。 | This action template uses command “{command}”. | +| deleteProtection.running | 指令“{command}”对应的服务仍在运行。请先停止服务,确认已停止后再删除。 | The service for command “{command}” is still running. Stop the service and confirm it has stopped before deleting. | +| deleteProtection.unconfirmed | 还没有确认指令“{command}”对应的服务是否已停止。请先在「指令」页点这条指令的刷新图标核验状态;如果它仍在运行,请先停止,再删除。 | It hasn't been confirmed that the service for command “{command}” has stopped. On the SSH Commands page, select the refresh icon for this command to verify its state. If it is still running, stop it before deleting. | +| deleteProtection.starting | 正在启动指令“{command}”对应的服务。请等待操作完成后再删除。 | The service for command “{command}” is starting. Wait for the operation to finish before deleting. | +| deleteProtection.stopping | 正在停止指令“{command}”对应的服务。请等待操作完成后再删除。 | The service for command “{command}” is stopping. Wait for the operation to finish before deleting. | +| deleteProtection.verifying | 正在核验指令“{command}”对应的服务状态。请等待核验完成后再删除。 | The state of the service for command “{command}” is being verified. Wait for verification to finish before deleting. | +| deleteProtection.operationPending | 指令“{command}”对应的服务还有操作未完成。请等待操作完成后再删除。 | An operation on the service for command “{command}” is still in progress. Wait for it to finish before deleting. | +| deleteProtection.viewCommand | 查看指令 | View commands | +| deleteProtection.checkBusy | 自动化配置正在更新,本次未删除动作模板。请等待更新完成后再删除。 | The automation configuration is being updated. The action template was not deleted. Wait for the update to finish before deleting. | +| deleteProtection.checkLoading | 规则配置仍在加载,本次未删除动作模板。请等待加载完成后再删除。 | The rule configuration is still loading. The action template was not deleted. Wait for loading to finish before deleting. | +| deleteProtection.checkRecovery | 规则配置需要恢复核对,本次未删除动作模板。请在「终端」页打开「系统日志」查看详情。 | The rule configuration needs a recovery check. The action template was not deleted. Open “System Logs” on the Terminal page for details. | +| deleteProtection.checkFailed | 没能确认是否还有规则在使用这个动作模板,所以没有删除。请在「终端」页打开「系统日志」查看原因。 | Couldn't confirm whether any rule still uses this action template, so it was not deleted. Open “System Logs” on the Terminal page to see why. | +| deleteProtection.actionMissing | 规则没有保存:动作模板“{name}”已不存在,可能已被删除。请重新选择动作模板后再保存。 | The rule was not saved. Action template “{name}” no longer exists; it may have been deleted. Select an existing action template and save again. | +| deleteProtection.quickLoadingTitle | 快捷操作正在加载 | Loading quick actions | +| deleteProtection.quickRecoveryTitle | 快捷操作暂不可用 | Quick actions unavailable | +| deleteProtection.recoveryDetails | 请在「终端」页打开「系统日志」查看详情。 | Open “System Logs” on the Terminal page for details. | +| deleteProtection.goAutomation | 前往自动化 | Go to Automation | +| deleteProtection.loadingDetails | 如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。 | If nothing changes for a long time, open “System Logs” on the Terminal page to see why. | +| deleteProtection.quickLoadingNeedRoot | 如果长时间没有变化,请联系拥有 root 权限的用户查看原因。 | If nothing changes for a long time, contact a user with root access to find out why. | +| deleteProtection.quickRecoveryNeedRoot | 请联系拥有 root 权限的用户处理。 | Contact a user with root access to resolve this. | +| deleteProtection.quickEmptyNeedRoot | 如需添加快捷操作,请联系拥有 root 权限的用户。 | To add quick actions, contact a user with root access. | + +C 组不使用 goAutomation;首页三个状态仅 root 使用该跳转入口。指令自身的服务阻断框只有 common.close,模板场景保留 viewCommand。 +root 空状态 quickActionsHint 已改为「在首页显示」,加载/恢复正文复用现有 runtimeRepair.configLoading/recovery_required;后端服务兜底复用 promptRepair.serviceDeleteProtected。非 root 加载/恢复的补充句与空状态正文使用上述三个 NeedRoot 新键,用户已确认定稿,无占位符,逐字采用。 diff --git a/docs/repair/delete-protection/PLAN.md b/docs/repair/delete-protection/PLAN.md new file mode 100644 index 0000000..6a75e3b --- /dev/null +++ b/docs/repair/delete-protection/PLAN.md @@ -0,0 +1,65 @@ +# 删除保护与快捷操作提示:已确认的实施方案 v4(第三轮方案 v2) + +日期:2026-10-10。用户已明确回复“确认,按第二轮文案实施”,并批准第三轮方案 v2 及三句非 root 首页说明。 + +本版本采用原《修改方案与交接.md》v2,以及《评审意见-第二轮.md》的修订。本文件记录落地边界,配套 COPY.md 是实际语言包文案,RESULT.md 记录验证与剩余边界。 + +## 工作位置与范围 + +- 分支:fix/automation-delete-protection。 +- 独立目录:/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS。 +- 起点:main / 050757bb8046206797804ba5edc110ae3244bf17(0.6.1)。 +- 不修改主目录,不合入另一 AI 的 SSH 指纹分支。 +- 不提交、推送、合并、发布或刷机;不改变 SD/NVS 存储策略,不自动修复历史断裂引用。 + +## 固件合同 + +1. 删除模板在 ts_action_template_remove_checked 内先零等待取得规则 transaction,再取得 SSH binding;检查已加载且无需恢复,再遍历所有规则,包含停用规则,并去重收集 id/name。 +2. 有引用拒绝删除,不能调用 template_remove_impl 或 API 的 unlink;列表细节分配失败仍拒绝删除。无法完成检查与已确认被引用是两种结果。 +3. 拿不到 transaction 时通过 ts_rule_config_status 判断 recovery/loading/busy;未初始化按 loading,不能误报更新。 +4. 老入口 ts_action_template_remove 委托同一实现,避免另一删除入口绕过保护。 +5. 新建/新增/增加次数的模板引用,在 ts_rule_commit 的 transaction + binding 内、持久化之前验证存在。缺失返回 action_missing 和可靠 ID;内部/内存错误不说“已不存在”。 +6. 既有缺失 ID 及原有出现次数可以保留,历史展示编辑、启停维持原行为;不扩大引用,原执行保护不放宽。 +7. 缺失引用拒绝时清除 committing,释放全部锁和负载;SD/NVS 不写,revision 不增长,后续触发/保存/启停不被锁死。 +8. 490 行缺失模板时的原服务保护保留;不会把缺失配置当成远程服务停止。 +9. API 业务失败仍返回 ESP_OK + 非零 result.code,让当前 main 的真实 HTTP 成功包络保留 message/data;不依赖 SSH 分支的新通用出口。 + +## 前端与文案 + +- 第二轮第 6 节的 unconfirmed/checkFailed/actionMissing/loadingDetails 与无列表英文已采用;C 组仅有“关闭”。 +- 模板名来自用户实际选择的 option.data-template-name,取不到用 ID;后端不编造名称。保存失败保留草稿。响应详情丢失时使用既有 saveFailed,不显示空名称或原始 action_missing。 +- runtimeSaveError 保留 action_missing 的名称提示,将本轮新增原因 template_lookup_failed 映射到现有 saveFailed;不扩展其他原因。新文案在 deleteProtection 命名空间。 +- 查看规则以及从模板阻断框查看指令只改变路由,不定位、不高亮、不自动编辑或删除;指令自身被阻断时只有关闭,不重载当前页。 +- 首页 recovery > loading > 空状态,保留面板;root 原说明及前往自动化入口不变,所有非 root 无按钮,按状态使用 quickLoadingNeedRoot/quickRecoveryNeedRoot/quickEmptyNeedRoot 三句用户确认说明。不泛化提示用户检查 SD 卡。 +- 使用现有 sheet/confirmSheet、系统字体及语义色。仅增加确认框长标题换行,并约束引用列表文字列的宽度。 +- 本次“处理中”提示在阻断框出现时立即隐藏;不清除其他失败/警告。关闭后释放列表 DOM 的导航回调引用,离开页面关闭旧框。 +- app.js 查询标识及语言包使用的 TS_ASSET_VERSION 为 delete2;api.js/CSS 保持 delete1。xterm 地址随 TS_ASSET_VERSION 变化,内容未改;发布版本仍为 0.6.1。 + +## 服务状态完整映射 + +先判定未完成操作:busy=true,或 operation_phase 是 queued/executing。若 operation_kind 是 start/stop/verify,分别显示 starting/stopping/verifying;操作类型未知显示 operationPending。终态 receipt 不当成仍在操作。 + +在没有未完成操作时: + +| state | 文案键/处理 | +|---|---| +| running / ready / checking | running:仍在运行 | +| starting | starting:正在启动 | +| stopping | stopping:正在停止 | +| unknown / failed / timeout | unconfirmed:尚未确认已停止 | +| stopped | 允许进入最终删除检查 | +| 未来未知或缺失状态 | unconfirmed,保持拒绝 | + +预检后又被后端拒绝,使用已有 serviceDeleteProtected,不用旧状态猜新原因。原有核验失败、超时、网络错误反馈保留;不自动停止、不增轮询。 + +## 验证增补 + +- H13:缺失模板拒绝后 committing=false,随后有效保存、启停和手动触发仍可用。 +- H14:事务被加载占用或未初始化时显示 loading,更新占用时显示 busy。 +- H15:9 state × 2 busy × 4 kind × 3 phase × 2 language,共 432 个映射分支。 +- 两种保存/删除顺序使用持久化屏障实际并发验证;不只按顺序调用替身算法。 +- 规则/模板名称上限47字节、ID63字节;中文/引号/HTML符号;32条引用去重;最大标题手机/桌面不裁切。 +- 同一对象先被服务阻止,确认停止后的下一次删除再被引用阻止,两次说明各自准确,没有旧框残留。 +- 生产函数产生的 HTTP 字节原样回放实际 api.js 和按钮,不替换 api.call。 + +上述验证证据见 RESULT.md;不将主机或浏览器结果当作真实热拔插、断电或远程服务验收。 diff --git a/docs/repair/delete-protection/RESULT.md b/docs/repair/delete-protection/RESULT.md new file mode 100644 index 0000000..2763114 --- /dev/null +++ b/docs/repair/delete-protection/RESULT.md @@ -0,0 +1,104 @@ +# 删除保护修复:实现与验证交付 + +2026-10-09。本地实现已完成,等待用户/另一 AI 的代码审查;没有提交、推送、合并、发布或刷机。 + +## 位置与基线 + +- **分支**:`fix/automation-delete-protection`。 +- **独立工作目录**:`/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS`。 +- **基线**:`main` / `050757bb8046206797804ba5edc110ae3244bf17`(0.6.1)。 +- 修改保留为该工作目录中的未提交文件;仅 checkout 分支名不能在另一目录取得这些尚未提交的内容,审查请直接进入上面的工作目录。 +- `/Users/massif/TianshanOS` 仍在 main,tracked diff 为空;原有未跟踪文档/工具保留。 +- 没有合入 `fix/ssh-fingerprint-confirm`,没有修改通用 HTTP 失败出口或密钥逻辑。 + +## 用户可见结果 + +1. 动作模板仍被规则使用时,删除被拒绝,说明框列出具体规则;包含停用规则,重复引用不重复列出。解除引用后才可删除。 +2. 另一个页面先删除模板后,用旧选择保存新引用会被拒绝,提示用户实际选过的模板名称;草稿保留。后端只返回可靠 ID。 +3. 保存拒绝后 `committing` 清除,原规则仍能手动触发、再保存、启停。 +4. 模板删除拿不到事务时,区分仍在加载、正在更新与恢复异常;无法确认引用时不谎报已确认被引用。 +5. 服务阻断按九种状态与未完成操作分流;用户删模板时标题/关系说明也明确模板与指令的关系。 +6. 首页区分没有快捷操作、正在加载和恢复异常,保留面板及前往自动化入口。 +7. “查看规则/查看指令”只跳转,不定位、不高亮、不自动停止或删除。C 组只有关闭。 +8. 现有 sheet 的字体/色彩/容器保持;长名称、长标题和63字节ID可以换行,手机/桌面不裁切。阻断框出现时,本次“处理中”提示立即隐藏,不遮挡按钮。 + +## 技术边界 + +- 删除在 transaction → binding → 短期规则/模板 mutex 的顺序下完成检查和原移除实现;不拿反向锁、不新增锁或常驻任务。 +- 新增或增加次数的缺失引用不能保存;历史缺失引用的原有出现次数可保留,不自动修复,不放宽执行保护。 +- 490 行的原服务保护保持。 +- API 业务失败保持 ESP_OK + 非零 code,data 从 main 的生产成功包络带出。细节无法生成/丢失时仍拒绝并有回退。 +- 不改变 SD/NVS 加载、镜像、导入和存储格式,不根据“拔卡”推断内部备份必然可用。 +- 现有模板实际持久化、外部手动改卡、热拔插与断电恢复并未因本轮主机模拟获得真机验收。 + +## 上一轮验证(第三轮实施前的源码) + +| 检查 | 实际结果 | 证据 | +|---|---|---| +| 运行时总入口 `IDF_PATH=… bash tests/runtime/run_all.sh` | 通过,包含原存储、服务、SSH、规则生命周期等回归 | [runtime.log](evidence/runtime.log) | +| 生产删除/提交/HTTP字节 | 通过,8个固定响应;两种并发顺序用屏障控制;拒绝时无移除/文件清理/提交;历史次数、committing、47/63边界 | [reference-wire.log](evidence/reference-wire.log) | +| 静态/交互总入口 `npm --prefix tests/prompts test` | **126/126,通过** | [unit.log](evidence/unit.log) | +| 实际 Chrome 浏览器总入口 `npm --prefix tests/prompts run test:browser` | **262/262,通过** | [browser.log](evidence/browser.log) | +| H15 状态矩阵 | 9×2×4×3×2,共432个映射分支,中英都不显示原枚举 | `tests/prompts/delete-reference.test.cjs` | +| 完整 ESP32-S3 固件与 SPIFFS 构建 | 通过,最终源码;构建版本 `0.6.1+050757bbd.10092140` | [build.log](evidence/build.log) | +| diff 空白/语法与主目录隔离 | `git diff --check` 通过;主目录 tracked diff 为空 | 本工作目录与主目录的 git status/diff | + +生产函数测试的 RTOS、存储、SSH 等外围使用合成边界,不连接设备。浏览器原样回放生产 HTTP 字节,实际 api.js、按钮、确认框和路由参与;没有用假的 api.call 绕过出口。 + +视觉验证包括中英文、390/1440宽度、32条引用、名称47/ID63字节、最大服务标题、子元素裁切、进度提示遮挡、Esc/Enter/关闭、双重阻断、迟到操作与页面离开。 + +## 上一轮资源与工件(不代表第三轮净增量或新构建) + +- 最终 ELF 类型尺寸:引用条目112B、删除诊断20B、提交诊断84B(新增64B请求局部ID),规则结构仍240B。 +- 引用细节默认32条最多3584B临时PSRAM;配置上限64条时7168B。新增引用存在性查询使用一个1224B临时模板缓冲,退出即释放。 +- 无新增常驻任务、每条规则持久化字段、分区或轮询频率。 +- 六个修改的 Web 源文件按相同 gzip 参数测得净增加 **2807B**;这是源文件gzip对比,不是假称实际网络包或板卡内存测量。实际SPIFFS构建通过。 +- [类型尺寸](target-sizes.txt)、[逐文件资源变化](resource-deltas.json)。 +- 本地待审工件:`build/delete-protection/TianShanOS.bin` 与 `www.bin`,以及配套 bootloader/partition/ota 数据;[SHA-256清单](evidence/artifacts.json)。未写入设备。 +- [源文件哈希](evidence/source-hashes.json)对应本次本地实现,便于审查时识别后续变动。 + +## 文案与上一轮截图 + +- [已确认实施方案 v3](PLAN.md)。 +- [实际中英文文案](COPY.md):包含用户确认的第二轮修订;占位参数一致。 +- [中文手机](../../../output/playwright/delete-protection/zh-CN-390.png) / [中文桌面](../../../output/playwright/delete-protection/zh-CN-1440.png)。 +- [英文手机](../../../output/playwright/delete-protection/en-US-390.png) / [英文桌面](../../../output/playwright/delete-protection/en-US-1440.png)。 +- 同目录截图旁的 `*-style.json` 记录实际字体、圆角、宽度。 + +## 下一位 AI 的代码审查入口 + +请在上面的独立工作目录执行只读 `git diff` 并查看新增未跟踪文件,重点核查: + +- `ts_action_template_remove_checked` 的事务/绑定顺序、列表去重、确认结论与细节可用性的分离、lease释放及拒绝后无副作用。 +- `validate_new_template_refs` 的旧/新次数比较、NOT_FOUND与读错误区分,拒绝分支清除committing,历史兼容不放宽执行保护。 +- API真实包络及前端回退、选择名称来源、失效页面回调、单按钮关闭、处理中提示与后续toast正常恢复。 +- COPY.md是否逐字符合已批准文案,long-title/list样式是否仍符合既有设计规则。 +- 上述日志和工件确实对应当前源码;若审查后修改源码,重跑与修改相关的检查,而不是继承旧PASS。 + +审查不等于授权推送、合并、发布或刷机。原历史断裂配置仍保留原安全保护,不能宣称本轮自动清理了所有现场旧配置。 + + +## 第三轮补记:2026-10-10,按第三轮方案 v2 完成 + +用户已授权实施 R1–R5 并确认三句非 root 首页说明。完整方案在主目录 `output/review-delete-protection-copy/第三轮修复方案与交接-v2.md`,回执在同目录 `第三轮实施回执.md`。仍为原独立工作目录中的未提交修改。 + +- R1:静态检查加入 deleteProtection,当前原24键加新增3键均由相同检查覆盖。 +- R2:无未完成操作证据的未知/缺失服务状态使用 unconfirmed;真实 pending 分支及删除安全门保持。 +- R3:template_lookup_failed 使用现有 saveFailed;action_missing 名称说明保持,保存失败草稿保留。 +- R4:只有 root 显示自动化入口;三句已确认非 root 说明按场景使用,root 原正文/补充句保持。新 t() 使用字面量键。 +- R5:指令自身的服务阻断框只有关闭,关闭不导航或重载;动作模板查看指令和查看规则继续只跳转。 +- TS_ASSET_VERSION 和 app.js 查询串为 delete2;api.js/CSS 保持 delete1,xterm 地址随共享标识变化。没有改变发布版本。 + +| 本轮实际命令(独立目录) | 结果 | 证据 | +|---|---|---| +| `node --test tests/prompts/static.test.cjs tests/prompts/delete-reference.test.cjs tests/prompts/delete-protection.test.cjs` | 33/33,通过 | [round3-unit.log](evidence/round3-unit.log) | +| `node --test tests/prompts/delete-reference.browser.test.cjs tests/prompts/delete-protection.browser.test.cjs` | 38/38,通过 | [round3-browser.log](evidence/round3-browser.log) | +| `git diff --check` | 通过,无空白错误 | [round3-verification.json](evidence/round3-verification.json) | + +新增浏览器场景只有中文 admin 三状态和中文查询失败保留草稿;已有相关文件的语言/视口/服务覆盖照常执行,没有新增矩阵。admin 同时用于 auth/status 返回等级和初始化 localStorage,实际 api.getLevel 为 admin、isRoot 为 false。三状态无按钮、对应定稿出现、旧 root 管理操作提示不出现;加载/恢复标题与正文保持,空状态仅替换正文。 + +本轮未运行固件测试、全量测试、ESP-IDF构建或真机测试;固件/HTTP未修改,用户明确限定验证范围。上方126/262结果、构建日志、截图、二进制及2807B资源统计均为上一轮历史证据,不能当作本轮固件工件或资源增量。没有重新生成 HTTP fixture、截图或二进制。 + +[source-hashes.json](evidence/source-hashes.json)现对应本轮源码;[source-hashes-before-round3.json](evidence/source-hashes-before-round3.json)保留上一轮源码记录,旧工件哈希不变。复审可用[round3.patch](evidence/round3.patch)查看相对上一轮未提交实现的精确增量,而不只与 main 比较。补丁采用无上下文格式,避免空白上下文行被 Git 当作尾随空格;需要应用时使用 git apply --unidiff-zero。 + +方案无实质偏差,没有需要用户另作决定的事项。固件、通用HTTP出口、锁顺序、committing及api.js/CSS等受保护文件与本轮开始时逐字相同;main源码未改。未提交、推送、开PR、合并或刷机,未修改其他分支/工作目录。 diff --git a/docs/repair/delete-protection/evidence/artifacts.json b/docs/repair/delete-protection/evidence/artifacts.json new file mode 100644 index 0000000..23103c3 --- /dev/null +++ b/docs/repair/delete-protection/evidence/artifacts.json @@ -0,0 +1,27 @@ +[ + { + "path": "build/delete-protection/TianShanOS.bin", + "bytes": 2203840, + "sha256": "88bdf5decf53a6b78ac1cfec6ca2945bc61804f856cbd0937b1c9377481e7b7a" + }, + { + "path": "build/delete-protection/www.bin", + "bytes": 3145728, + "sha256": "a940b8126b63bf3bf2616908779fc96d7634715ea22e8230d290f5ede4e3feb2" + }, + { + "path": "build/delete-protection/bootloader/bootloader.bin", + "bytes": 22496, + "sha256": "f1b82145e7489946f3a0e3de25dc82551acd05851453efa650f0f174e7baf510" + }, + { + "path": "build/delete-protection/partition_table/partition-table.bin", + "bytes": 3072, + "sha256": "973dfb7f44bdd42d525e53fcc1eb06ac5745687dddc1c73223715df841d785e5" + }, + { + "path": "build/delete-protection/ota_data_initial.bin", + "bytes": 8192, + "sha256": "7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f" + } +] diff --git a/docs/repair/delete-protection/evidence/browser.log b/docs/repair/delete-protection/evidence/browser.log new file mode 100644 index 0000000..b7ec8cd --- /dev/null +++ b/docs/repair/delete-protection/evidence/browser.log @@ -0,0 +1,280 @@ + +> test:browser +> cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs tests/prompts/delete-reference.browser.test.cjs + +✔ zh-CN: new confirmation sheet cancels writes and guards dangerous keyboard actions (2838.921875ms) +✔ en-US: new confirmation sheet cancels writes and guards dangerous keyboard actions (1235.065125ms) +✔ zh-CN: deploy button uses first trust directly, renders registered host and rejects changed keys (1319.456584ms) +✔ en-US: deploy button uses first trust directly, renders registered host and rejects changed keys (1231.945167ms) +✔ zh-CN: dashboard verifies unknown service with JSON before triggering (911.245208ms) +✔ zh-CN: real index cold start, safe toast/file text, current websocket state (655.26ms) +✔ zh-CN: initial language failure blocks controls and retry recovers (513.298166ms) +✔ en-US: dashboard verifies unknown service with JSON before triggering (1059.412208ms) +✔ en-US: real index cold start, safe toast/file text, current websocket state (802.417709ms) +✔ en-US: initial language failure blocks controls and retry recovers (654.9365ms) +✔ delayed language package and rapid switches keep the latest requested language (566.667834ms) +✔ terminal resource failure presents retry; a rejected cached promise is not reused (652.332833ms) +✔ zh-CN: package confirmation treats names as text and applies only on explicit verified action (653.5175ms) +✔ en-US: package confirmation treats names as text and applies only on explicit verified action (636.092ms) +✔ initial delayed language keeps controls inert until the real package arrives (560.57775ms) +✔ failed language switch preserves the loaded language; retry clears failure and stale toast (793.150459ms) +✔ terminal can initialize after failed resource load using newly served resources (629.26375ms) +✔ zh-CN: managed commands share service state, start, verification and stop (649.907834ms) +✔ en-US: managed commands share service state, start, verification and stop (841.122375ms) +✔ zh-CN: ready survives both editors and two saves (2885.286042ms) +✔ zh-CN: numeric-string survives both editors and two saves (882.351709ms) +✔ zh-CN: boolean-string survives both editors and two saves (775.050875ms) +✔ zh-CN: empty-string survives both editors and two saves (737.874167ms) +✔ zh-CN: quotes-and-escapes survives both editors and two saves (543.267084ms) +✔ zh-CN: entity-literal survives both editors and two saves (658.793834ms) +✔ zh-CN: spaces survives both editors and two saves (594.540459ms) +✔ zh-CN: zero survives both editors and two saves (497.758417ms) +✔ zh-CN: negative survives both editors and two saves (493.408667ms) +✔ zh-CN: fraction survives both editors and two saves (420.21125ms) +✔ zh-CN: integral-float survives both editors and two saves (497.383958ms) +✔ zh-CN: true survives both editors and two saves (573.399042ms) +✔ zh-CN: false survives both editors and two saves (583.391958ms) +✔ zh-CN: null survives both editors and two saves (586.046458ms) +✔ zh-CN: limit-string survives both editors and two saves (418.181458ms) +✔ zh-CN: unicode survives both editors and two saves (517.408917ms) +✔ en-US: ready survives both editors and two saves (604.991959ms) +✔ en-US: numeric-string survives both editors and two saves (694.44075ms) +✔ en-US: boolean-string survives both editors and two saves (645.396042ms) +✔ en-US: empty-string survives both editors and two saves (651.138042ms) +✔ en-US: quotes-and-escapes survives both editors and two saves (749.073333ms) +✔ en-US: entity-literal survives both editors and two saves (580.132ms) +✔ en-US: spaces survives both editors and two saves (569.025041ms) +✔ en-US: zero survives both editors and two saves (515.255417ms) +✔ en-US: negative survives both editors and two saves (508.265875ms) +✔ en-US: fraction survives both editors and two saves (581.07425ms) +✔ en-US: integral-float survives both editors and two saves (522.709667ms) +✔ en-US: true survives both editors and two saves (377.507166ms) +✔ en-US: false survives both editors and two saves (433.694459ms) +✔ en-US: null survives both editors and two saves (417.864583ms) +✔ en-US: limit-string survives both editors and two saves (397.335458ms) +✔ en-US: unicode survives both editors and two saves (420.794042ms) +✔ editing ready uses the same type contract in both editors (375.46275ms) +✔ editing "0" uses the same type contract in both editors (235.66275ms) +✔ editing 0 uses the same type contract in both editors (244.983667ms) +✔ editing "true" uses the same type contract in both editors (244.035917ms) +✔ editing true uses the same type contract in both editors (313.032208ms) +✔ editing false uses the same type contract in both editors (237.79525ms) +✔ editing null uses the same type contract in both editors (264.19975ms) +✔ editing "" uses the same type contract in both editors (217.234334ms) +✔ trigger: invalid [] stays in draft and never writes (244.347333ms) +✔ trigger: invalid {} stays in draft and never writes (271.896541ms) +✔ trigger: invalid 1e999 stays in draft and never writes (212.712834ms) +✔ trigger: invalid "\u0000" stays in draft and never writes (260.943667ms) +✔ trigger: invalid "aaaaaaaaaaaaaaaaaaa stays in draft and never writes (297.46725ms) +✔ trigger: invalid "中中中中中中中中中中中中中中中中中中中 stays in draft and never writes (213.438834ms) +✔ action: invalid [] stays in draft and never writes (292.940708ms) +✔ action: invalid {} stays in draft and never writes (262.144625ms) +✔ action: invalid 1e999 stays in draft and never writes (212.453167ms) +✔ action: invalid "\u0000" stays in draft and never writes (202.9375ms) +✔ action: invalid "aaaaaaaaaaaaaaaaaaa stays in draft and never writes (213.983209ms) +✔ action: invalid "中中中中中中中中中中中中中中中中中中中 stays in draft and never writes (192.334125ms) +✔ trigger: missing variable cannot silently remove a condition (232.515125ms) +✔ action: missing variable cannot silently remove a condition (183.680292ms) +✔ manual-only preserves dormant trigger conditions (223.267458ms) +✔ unsupported operator is visible and blocked rather than changed to equality (204.188792ms) +✔ all supported operators stay identical across both editors (195.784833ms) +✔ unchecked action condition is intentionally removed; empty string is valid (265.731875ms) +✔ one escape implementation preserves text and attribute values without double encoding (234.722875ms) +✔ source: export button passes quoted IDs as data rather than JavaScript source (180.037ms) +✔ rule: export button passes quoted IDs as data rather than JavaScript source (217.002333ms) +✔ action: export button passes quoted IDs as data rather than JavaScript source (197.4545ms) +✔ ssh-cmd: export button passes quoted IDs as data rather than JavaScript source (255.230584ms) +✔ ssh-host: export button passes quoted IDs as data rather than JavaScript source (235.224167ms) +✔ inline action conditions remain unchanged even though they are read-only (207.967917ms) +✔ unresolved action references retain their conditions and type (248.124958ms) +✔ rejected save preserves the editor, typed value and operator (204.504542ms) +✔ action: real REST verification gates stopped deletion (2699.246041ms) +✔ action: real REST verification gates ready deletion (999.477042ms) +✔ command: real REST verification gates stopped deletion (782.756791ms) +✔ command: real REST verification gates ready deletion (631.646125ms) +✔ action: delayed verification cannot overwrite a newer real start (578.567042ms) +✔ command: delayed verification cannot overwrite a newer real start (414.387542ms) +✔ zh-CN: navigation buttons open existing pages without positioning or deletion (3453.770583ms) +✔ en-US: navigation buttons open existing pages without positioning or deletion (1688.95325ms) +✔ zh-CN 390: 32 production references, escaping, design, close and navigation (959.383166ms) +✔ zh-CN 390: 47-byte label and 63-byte ID remain readable (797.740959ms) +✔ zh-CN 390: service-blocked 47-byte template title does not clip (555.523125ms) +✔ zh-CN 390: C group has only Close; missing list remains blocked (753.001209ms) +✔ zh-CN 390: successive service and reference blockers have no stale sheet (907.931ms) +✔ zh-CN 390: homepage empty/loading/recovery retains grid and entry (473.392208ms) +✔ zh-CN 390: stale selection failure names the chosen option and retains draft (633.413834ms) +✔ zh-CN 1440: 32 production references, escaping, design, close and navigation (945.358042ms) +✔ zh-CN 1440: 47-byte label and 63-byte ID remain readable (591.569166ms) +✔ zh-CN 1440: service-blocked 47-byte template title does not clip (679.112833ms) +✔ zh-CN 1440: C group has only Close; missing list remains blocked (871.66225ms) +✔ zh-CN 1440: successive service and reference blockers have no stale sheet (812.506333ms) +✔ zh-CN 1440: homepage empty/loading/recovery retains grid and entry (794.637792ms) +✔ zh-CN 1440: stale selection failure names the chosen option and retains draft (572.292458ms) +✔ en-US 390: 32 production references, escaping, design, close and navigation (898.132833ms) +✔ en-US 390: 47-byte label and 63-byte ID remain readable (784.100334ms) +✔ en-US 390: service-blocked 47-byte template title does not clip (676.337083ms) +✔ en-US 390: C group has only Close; missing list remains blocked (687.530458ms) +✔ en-US 390: successive service and reference blockers have no stale sheet (765.389125ms) +✔ en-US 390: homepage empty/loading/recovery retains grid and entry (432.676833ms) +✔ en-US 390: stale selection failure names the chosen option and retains draft (532.282708ms) +✔ en-US 1440: 32 production references, escaping, design, close and navigation (732.156042ms) +✔ en-US 1440: 47-byte label and 63-byte ID remain readable (446.1835ms) +✔ en-US 1440: service-blocked 47-byte template title does not clip (496.637709ms) +✔ en-US 1440: C group has only Close; missing list remains blocked (579.539917ms) +✔ en-US 1440: successive service and reference blockers have no stale sheet (603.57ms) +✔ en-US 1440: homepage empty/loading/recovery retains grid and entry (353.903834ms) +✔ en-US 1440: stale selection failure names the chosen option and retains draft (450.894333ms) +✔ zh-CN: widget zero, negative bound, expressions and thresholds survive save/reopen (2730.2965ms) +✔ zh-CN: invalid widget numbers retain the whole draft without saving (1054.630958ms) +✔ zh-CN: all 14 available filter templates retain parameters across reopen and two updates (30977.601125ms) +✔ zh-CN: failed or invalid LED edit keeps its template and never deletes it (998.06275ms) +✔ zh-CN: editing only a legacy filter label preserves its default execution (1620.917375ms) +✔ zh-CN: modal and matrix manual filters convert frequency and retain zero decay (200.019333ms) +✔ en-US: widget zero, negative bound, expressions and thresholds survive save/reopen (257.792ms) +✔ en-US: invalid widget numbers retain the whole draft without saving (266.390416ms) +✔ en-US: all 14 available filter templates retain parameters across reopen and two updates (30372.641083ms) +✔ en-US: failed or invalid LED edit keeps its template and never deletes it (1043.026792ms) +✔ en-US: editing only a legacy filter label preserves its default execution (1767.772958ms) +✔ en-US: modal and matrix manual filters convert frequency and retain zero decay (292.6735ms) +✔ files and image picker preserve special paths, plain names and both directory types (311.226375ms) +✔ JSON field and command-variable buttons pass unmodified quotes and Unicode (274.172417ms) +✔ CSR UI uses UTF-8 byte bounds and preserves the organization data (179.610959ms) +✔ zh-CN: undefined preserves field presence through metadata and one-field edits (1644.020917ms) +✔ zh-CN: {"angle":0} preserves field presence through metadata and one-field edits (1697.92825ms) +✔ zh-CN: {"speed":40,"wavelength":8,"amplitude":128,"angle":0} preserves field presence through metadata and one-field edits (1680.298792ms) +✔ en-US: undefined preserves field presence through metadata and one-field edits (1765.225959ms) +✔ en-US: {"angle":0} preserves field presence through metadata and one-field edits (1783.382584ms) +✔ en-US: {"speed":40,"wavelength":8,"amplitude":128,"angle":0} preserves field presence through metadata and one-field edits (1762.053958ms) +✔ zh-CN: cached old API, app and language are replaced by new URLs (3327.130667ms) +✔ en-US: cached old API, app and language are replaced by new URLs (1449.291167ms) +✔ zh-CN rsa2048: real modal persistent feedback and generated row (6245.92825ms) +✔ zh-CN rsa4096: real modal persistent feedback and generated row (3861.391209ms) +✔ zh-CN ec256: real modal persistent feedback and generated row (3853.995667ms) +✔ zh-CN ec384: real modal persistent feedback and generated row (3817.103083ms) +✔ zh-CN: business and transport failures retain modal with accurate state (1694.652375ms) +✔ zh-CN: list error after saving never reports generation failure (388.037958ms) +✔ zh-CN: closed and reopened modal owns its new inputs (442.711375ms) +✔ en-US rsa2048: real modal persistent feedback and generated row (3675.746792ms) +✔ en-US rsa4096: real modal persistent feedback and generated row (3584.246167ms) +✔ en-US ec256: real modal persistent feedback and generated row (3616.483541ms) +✔ en-US ec384: real modal persistent feedback and generated row (3592.051708ms) +✔ en-US: business and transport failures retain modal with accurate state (1470.548875ms) +✔ en-US: list error after saving never reports generation failure (331.967958ms) +✔ en-US: closed and reopened modal owns its new inputs (362.543667ms) +✔ zh-CN: language rebuild releases only controls attached to the pending generation (294.087458ms) +✔ en-US: language rebuild releases only controls attached to the pending generation (374.940958ms) +✔ zh-CN: keyboard trap and Escape clear the password without submission (2934.082792ms) +✔ zh-CN: Enter submits once and Escape closes the finished result (977.655458ms) +✔ zh-CN: logs are lazy, use byte offsets and retain the complete cached transcript (1093.080083ms) +✔ zh-CN: logs stop after a no-progress page and do not retry on reopen (868.279333ms) +✔ zh-CN: logs pause when folded and resume from the next byte offset (779.585834ms) +✔ zh-CN: logs stale cannot replace the configuration result (652.487041ms) +✔ zh-CN: logs sd cannot replace the configuration result (567.325083ms) +✔ zh-CN: logs wrongRun cannot replace the configuration result (531.377166ms) +✔ zh-CN: logs allow an empty terminal page (673.982625ms) +✔ zh-CN: logs failure summary shows the last diagnosis and keeps technical status separate (532.209583ms) +✔ zh-CN: explicit no-internet error survives a following route table and raw logs remain available (674.848125ms) +✔ zh-CN: log group follows dialog typography and fits desktop and narrow viewports (1267.648916ms) +✔ zh-CN: running remains authoritative until cleanup completes; result appears once (538.217917ms) +✔ zh-CN: navigation clears the password and cannot submit from Files (604.128792ms) +✔ zh-CN: late start response cannot update a new navigation (657.828042ms) +✔ zh-CN: late start rejection cannot update a new navigation (622.503875ms) +✔ zh-CN: status failure preserves remote outcome and never starts another command (487.098666ms) +✔ zh-CN: late manual status refresh cannot restart polling in a new navigation (493.117458ms) +✔ zh-CN: native API success has one POST and accurate outcome (573.123625ms) +✔ zh-CN: native API rejected has one POST and accurate outcome (523.875541ms) +✔ zh-CN: native API lostResponse has one POST and accurate outcome (497.235333ms) +✔ zh-CN: rejected duplicate start adopts the existing task instead of reporting script failure (455.697916ms) +✔ zh-CN: old status cannot overwrite a newer execution or release its in-flight guard (331.576375ms) +✔ zh-CN: leaving closes a result dialog and ignores its late log response (485.536334ms) +✔ zh-CN: returning to a completed run does not pop the result again (414.601209ms) +✔ en-US: keyboard trap and Escape clear the password without submission (473.512958ms) +✔ en-US: Enter submits once and Escape closes the finished result (439.373834ms) +✔ en-US: logs are lazy, use byte offsets and retain the complete cached transcript (636.0125ms) +✔ en-US: logs stop after a no-progress page and do not retry on reopen (627.212792ms) +✔ en-US: logs pause when folded and resume from the next byte offset (632.155084ms) +✔ en-US: logs stale cannot replace the configuration result (418.17975ms) +✔ en-US: logs sd cannot replace the configuration result (472.3885ms) +✔ en-US: logs wrongRun cannot replace the configuration result (439.036375ms) +✔ en-US: logs allow an empty terminal page (415.047333ms) +✔ en-US: logs failure summary shows the last diagnosis and keeps technical status separate (311.019833ms) +✔ en-US: explicit no-internet error survives a following route table and raw logs remain available (460.969ms) +✔ en-US: log group follows dialog typography and fits desktop and narrow viewports (713.629416ms) +✔ en-US: running remains authoritative until cleanup completes; result appears once (343.323542ms) +✔ en-US: navigation clears the password and cannot submit from Files (368.904167ms) +✔ en-US: late start response cannot update a new navigation (365.310334ms) +✔ en-US: late start rejection cannot update a new navigation (382.665375ms) +✔ en-US: status failure preserves remote outcome and never starts another command (282.919ms) +✔ en-US: late manual status refresh cannot restart polling in a new navigation (313.511875ms) +✔ en-US: native API success has one POST and accurate outcome (360.923334ms) +✔ en-US: native API rejected has one POST and accurate outcome (364.678292ms) +✔ en-US: native API lostResponse has one POST and accurate outcome (376.816583ms) +✔ en-US: rejected duplicate start adopts the existing task instead of reporting script failure (418.776833ms) +✔ en-US: old status cannot overwrite a newer execution or release its in-flight guard (294.925333ms) +✔ en-US: leaving closes a result dialog and ignores its late log response (396.044375ms) +✔ en-US: returning to a completed run does not pop the result again (278.681625ms) +{"case":"successful retry","language":"zh-CN","failure":"/sdcard/a.tscfg: 应用失败: 设备处理请求时发生错误,请查看详细原因。\n详细原因:fixture failure","afterSuccess":"/sdcard/a.tscfg: 配置包已应用。","dialogClosed":true,"errors":[]} +✔ review2: successful retry retains old failure zh-CN (2714.623459ms) +{"case":"late completion","language":"zh-CN","secondShown":true,"secondRemoved":false,"toast":"/sdcard/a.tscfg: 正在应用配置...","errors":[]} +✔ review2: prior completion closes new package confirmation zh-CN (857.617917ms) +{"case":"stale file list","language":"zh-CN","callsBefore":1,"callsAfter":2,"visibleAfterUpload":true,"visibleAfterManualRefresh":true,"errors":[]} +✔ review2: uploaded file missing after dialog close zh-CN (719.912084ms) +{"case":"successful retry","language":"en-US","failure":"/sdcard/a.tscfg: Apply failed: The device encountered an error while processing the request. See the details below.\nDetails: fixture failure","afterSuccess":"/sdcard/a.tscfg: The configuration package was applied.","dialogClosed":true,"errors":[]} +✔ review2: successful retry retains old failure en-US (850.363958ms) +{"case":"late completion","language":"en-US","secondShown":true,"secondRemoved":false,"toast":"/sdcard/a.tscfg: Applying config...","errors":[]} +✔ review2: prior completion closes new package confirmation en-US (750.84225ms) +{"case":"stale file list","language":"en-US","callsBefore":1,"callsAfter":2,"visibleAfterUpload":true,"visibleAfterManualRefresh":true,"errors":[]} +✔ review2: uploaded file missing after dialog close en-US (616.830542ms) +✔ R1: endpoint-specific logout and HTTP errors zh-CN (533.760625ms) +✔ R3: late validation cannot revive an old apply entry zh-CN (364.386584ms) +✔ R3: previously opened confirmation loses validity on reupload zh-CN (416.29675ms) +✔ R4/R5: obsolete errors and finally cannot update newer attempts zh-CN (350.654333ms) +✔ R6: fixed upload destination and separate refresh failure zh-CN (431.230875ms) +✔ R6: out-of-order directory success and error do not replace latest list zh-CN (422.48925ms) +✔ R1: endpoint-specific logout and HTTP errors en-US (638.856458ms) +✔ R3: late validation cannot revive an old apply entry en-US (672.797875ms) +✔ R3: previously opened confirmation loses validity on reupload en-US (680.950667ms) +✔ R4/R5: obsolete errors and finally cannot update newer attempts en-US (494.123084ms) +✔ R6: fixed upload destination and separate refresh failure en-US (651.390083ms) +✔ R6: out-of-order directory success and error do not replace latest list en-US (548.720167ms) +✔ R2: authenticated startup initializes once (581.831583ms) +✔ R2: obsolete system loader cannot update new page; failed navigation retries (570.2225ms) +✔ R4/R5: newest result wins over late success and late failure (732.855ms) +✔ R3: overlapping writes to one path cannot claim current verified content (445.756208ms) +✔ R2: disposed page only removes its dashboard callback (525.687833ms) +✔ R2: stale finally cannot unlock the new page read (487.388583ms) +✔ R1: delayed sign-out does not clear a newer sign-in (484.588375ms) +✔ WS protocol: actual page subscription class, independent intervals and acknowledgements zh-CN (598.511208ms) +✔ WS protocol: actual page subscription class, independent intervals and acknowledgements en-US (616.103041ms) +✔ zh-CN: upgrade replaces a cached old deployment API without clearing browser cache (3217.452584ms) +✔ en-US: upgrade replaces a cached old deployment API without clearing browser cache (1925.619ms) +✔ 375px: all widget values stay inside cards and preserve grid widths (2411.815792ms) +✔ 600px: all widget values stay inside cards and preserve grid widths (881.739458ms) +✔ 768px: all widget values stay inside cards and preserve grid widths (650.538042ms) +✔ 900px: all widget values stay inside cards and preserve grid widths (838.749291ms) +✔ 1024px: all widget values stay inside cards and preserve grid widths (868.395542ms) +✔ 1200px: all widget values stay inside cards and preserve grid widths (492.096083ms) +✔ 1440px: all widget values stay inside cards and preserve grid widths (459.894375ms) +✔ wide ring labels overlay the whole card; narrow dual values stack (530.799292ms) +✔ 180px cards contain long units and labels and remain editable (446.17425ms) +✔ widget layout visual samples (862.554583ms) +✔ 375px: mixed layout sizes never create implicit grid columns (556.846958ms) +✔ 600px: mixed layout sizes never create implicit grid columns (470.174584ms) +✔ 601px: mixed layout sizes never create implicit grid columns (510.303041ms) +✔ 768px: mixed layout sizes never create implicit grid columns (465.864334ms) +✔ 900px: mixed layout sizes never create implicit grid columns (454.808083ms) +✔ 901px: mixed layout sizes never create implicit grid columns (333.960083ms) +✔ 1200px: mixed layout sizes never create implicit grid columns (443.036333ms) +✔ 1201px: mixed layout sizes never create implicit grid columns (527.399834ms) +✔ 1440px: mixed layout sizes never create implicit grid columns (416.623542ms) +✔ zh-CN: narrow log toolbar contains controls and status in both states (612.228042ms) +✔ en-US: narrow log toolbar contains controls and status in both states (577.2315ms) +✔ device service cards cap at three columns across the fan breakpoint (813.159209ms) +ℹ tests 262 +ℹ suites 0 +ℹ pass 262 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 83107.817875 diff --git a/docs/repair/delete-protection/evidence/build.log b/docs/repair/delete-protection/evidence/build.log new file mode 100644 index 0000000..03d80e1 --- /dev/null +++ b/docs/repair/delete-protection/evidence/build.log @@ -0,0 +1,52 @@ +Executing action: all (aliases: build) +Running ninja in directory /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection +Executing "ninja all"... +[1/7] Copying web assets to build directory +[2/7] cd /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/esptool_py && /Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python /Users/massif/esp/v5.5.2/esp-idf/components/partition_table/check_sizes.py --offset 0x8000 partition --type app /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/partition_table/partition-table.bin /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/TianShanOS.bin +TianShanOS.bin binary size 0x21a0c0 bytes. Smallest app partition is 0x300000 bytes. 0xe5f40 bytes (30%) free. +[3/7] Performing build step for 'bootloader' +[1/1] cd /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/bootloader/esp-idf/esptool_py && /Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python /Users/massif/esp/v5.5.2/esp-idf/components/partition_table/check_sizes.py --offset 0x8000 bootloader 0x0 /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/bootloader/bootloader.bin +Bootloader binary size 0x57e0 bytes. 0x2820 bytes (31%) free. +[4/7] No install step for 'bootloader' +[5/7] Completed 'bootloader' +[6/7] Minifying + gzipping web resources +Minifying web resources in /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui/web_optimized... + JS js/terminal.js: 31,110 -> 20,056 (35.5% saved) + JS js/router.js: 6,435 -> 3,860 (40.0% saved) + JS js/dragSort.js: 11,460 -> 7,603 (33.7% saved) + JS js/api.js: 38,400 -> 25,786 (32.8% saved) + JS js/i18n.js: 7,208 -> 4,320 (40.1% saved) + JS js/app.js: 940,003 -> 732,729 (22.1% saved) + JS js/lang/en-US.js: 196,195 -> 158,799 (19.1% saved) + JS js/lang/zh-CN.js: 188,643 -> 151,801 (19.5% saved) + CSS css/style.css: 42,660 -> 40,297 (5.5% saved) + + Total saved: 316,863 bytes (309.4 KB) + +Gzipping web resources in /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui/web_optimized... + GZ js/terminal.js: 20,056 -> 5,558 (72.3% smaller) + GZ js/router.js: 3,860 -> 1,346 (65.1% smaller) + GZ js/dragSort.js: 7,603 -> 1,869 (75.4% smaller) + GZ js/api.js: 25,786 -> 6,594 (74.4% smaller) + GZ js/i18n.js: 4,320 -> 1,460 (66.2% smaller) + GZ js/app.js: 732,729 -> 157,338 (78.5% smaller) + GZ js/lang/en-US.js: 158,799 -> 47,941 (69.8% smaller) + GZ js/lang/zh-CN.js: 151,801 -> 50,024 (67.0% smaller) + GZ vendor/xterm/fit.min.js: 1,789 -> 822 (54.1% smaller) + GZ vendor/xterm/xterm.min.js: 283,670 -> 65,684 (76.8% smaller) + GZ css/style.css: 40,297 -> 9,018 (77.6% smaller) + GZ vendor/xterm/xterm.css: 5,383 -> 2,037 (62.2% smaller) + GZ index.html: 62,010 -> 18,969 (69.4% smaller) + + Gzipped 13 files: 1,498,103 -> 368,660 (75.4% overall) +Done. +[7/7] cd /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui && /Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python /Users/massif/esp/v5.5.2/esp-idf/components/spiffs/spiffsgen.py 0x300000 /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/esp-idf/ts_webui/web_optimized /Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection/www.bin --page-size=256 --obj-name-len=32 --meta-len=4 --use-magic --use-magic-len + +Project build complete. To flash, run: + idf.py flash +or + idf.py -p PORT flash +or + python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m 0x0 build/delete-protection/bootloader/bootloader.bin 0x8000 build/delete-protection/partition_table/partition-table.bin 0x16000 build/delete-protection/ota_data_initial.bin 0x20000 build/delete-protection/TianShanOS.bin 0x6a0000 build/delete-protection/www.bin +or from the "/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS/build/delete-protection" directory + python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash "@flash_args" diff --git a/docs/repair/delete-protection/evidence/reference-wire.log b/docs/repair/delete-protection/evidence/reference-wire.log new file mode 100644 index 0000000..c41ad75 --- /dev/null +++ b/docs/repair/delete-protection/evidence/reference-wire.log @@ -0,0 +1,2 @@ +PASS production delete + rule commit + HTTP: H01-H14, leases, no side effects, historical multiplicity, committing cleared, 47/63-byte fields +PASS exact HTTP byte fixtures: 8 diff --git a/docs/repair/delete-protection/evidence/round3-browser.log b/docs/repair/delete-protection/evidence/round3-browser.log new file mode 100644 index 0000000..c6ebdd3 --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3-browser.log @@ -0,0 +1,46 @@ +✔ action: real REST verification gates stopped deletion (677.755291ms) +✔ action: real REST verification gates ready deletion (190.431375ms) +✔ command: real REST verification gates stopped deletion (141.920542ms) +✔ command: real REST verification gates ready deletion (219.136458ms) +✔ action: delayed verification cannot overwrite a newer real start (175.024709ms) +✔ command: delayed verification cannot overwrite a newer real start (168.640959ms) +✔ zh-CN: navigation buttons open existing pages without positioning or deletion (964.0105ms) +✔ en-US: navigation buttons open existing pages without positioning or deletion (517.2845ms) +✔ zh-CN 390: 32 production references, escaping, design, close and navigation (390.682792ms) +✔ zh-CN 390: 47-byte label and 63-byte ID remain readable (267.434875ms) +✔ zh-CN 390: service-blocked 47-byte template title does not clip (264.435041ms) +✔ zh-CN 390: C group has only Close; missing list remains blocked (363.04725ms) +✔ zh-CN 390: successive service and reference blockers have no stale sheet (414.364625ms) +✔ zh-CN 390: homepage empty/loading/recovery retains grid and entry (191.583333ms) +✔ zh-CN 390: stale selection failure names the chosen option and retains draft (266.922375ms) +✔ zh-CN 1440: 32 production references, escaping, design, close and navigation (379.798125ms) +✔ zh-CN 1440: 47-byte label and 63-byte ID remain readable (274.992625ms) +✔ zh-CN 1440: service-blocked 47-byte template title does not clip (258.833958ms) +✔ zh-CN 1440: C group has only Close; missing list remains blocked (375.246541ms) +✔ zh-CN 1440: successive service and reference blockers have no stale sheet (409.31575ms) +✔ zh-CN 1440: homepage empty/loading/recovery retains grid and entry (185.908041ms) +✔ zh-CN 1440: stale selection failure names the chosen option and retains draft (270.203458ms) +✔ en-US 390: 32 production references, escaping, design, close and navigation (414.266375ms) +✔ en-US 390: 47-byte label and 63-byte ID remain readable (318.463917ms) +✔ en-US 390: service-blocked 47-byte template title does not clip (306.5255ms) +✔ en-US 390: C group has only Close; missing list remains blocked (442.086083ms) +✔ en-US 390: successive service and reference blockers have no stale sheet (450.665083ms) +✔ en-US 390: homepage empty/loading/recovery retains grid and entry (254.423167ms) +✔ en-US 390: stale selection failure names the chosen option and retains draft (309.357459ms) +✔ en-US 1440: 32 production references, escaping, design, close and navigation (395.291ms) +✔ en-US 1440: 47-byte label and 63-byte ID remain readable (335.782666ms) +✔ en-US 1440: service-blocked 47-byte template title does not clip (298.214833ms) +✔ en-US 1440: C group has only Close; missing list remains blocked (445.234291ms) +✔ en-US 1440: successive service and reference blockers have no stale sheet (491.603334ms) +✔ en-US 1440: homepage empty/loading/recovery retains grid and entry (235.669625ms) +✔ en-US 1440: stale selection failure names the chosen option and retains draft (320.05925ms) +✔ zh-CN: non-root homepage has approved guidance and no management entry (198.403083ms) +✔ zh-CN: template lookup failure uses saveFailed and retains the actual draft (263.494208ms) +ℹ tests 38 +ℹ suites 0 +ℹ pass 38 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 11611.17775 diff --git a/docs/repair/delete-protection/evidence/round3-unit.log b/docs/repair/delete-protection/evidence/round3-unit.log new file mode 100644 index 0000000..748693f --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3-unit.log @@ -0,0 +1,42 @@ +✔ zh-CN: protected delete explains service state rather than internal failure (16.509792ms) +✔ en-US: protected delete explains service state rather than internal failure (5.778917ms) +✔ action: unregistered service is verified stopped before deletion (6.317ms) +✔ action: running never leads to delete or stop (5.484833ms) +✔ action: ready never leads to delete or stop (5.237917ms) +✔ action: unknown never leads to delete or stop (5.097834ms) +✔ action: failed verification never deletes (5.028959ms) +✔ action: ordinary command has no service verification (4.297542ms) +✔ action: repeated click submits only one verification and delete (4.405ms) +✔ action: leaving the page during verification cancels subsequent deletion (5.282917ms) +✔ command: unregistered service is verified stopped before deletion (4.273167ms) +✔ command: running never leads to delete or stop (3.944958ms) +✔ command: ready never leads to delete or stop (5.8435ms) +✔ command: unknown never leads to delete or stop (4.614542ms) +✔ command: failed verification never deletes (4.517209ms) +✔ command: ordinary command has no service verification (5.151209ms) +✔ command: repeated click submits only one verification and delete (5.937667ms) +✔ command: leaving the page during verification cancels subsequent deletion (3.846917ms) +✔ switching hosts during verification removes the original command only (3.861625ms) +✔ action: newer start prevents stale deletion and status painting (6.249666ms) +✔ action: newer stop prevents stale deletion and status painting (3.94125ms) +✔ action: newer verify prevents stale deletion and status painting (4.029584ms) +✔ command: newer start prevents stale deletion and status painting (5.167167ms) +✔ command: newer stop prevents stale deletion and status painting (4.964041ms) +✔ command: newer verify prevents stale deletion and status painting (4.144833ms) +✔ action: stale admission failure cannot replace newer start feedback (4.126417ms) +✔ command: stale admission failure cannot replace newer start feedback (5.768083ms) +✔ zh-CN: H15 nine states, busy and operation kind select an approved explanation (16.52475ms) +✔ en-US: H15 nine states, busy and operation kind select an approved explanation (6.080042ms) +✔ zh-CN: approved keys, placeholders and missing-template display label (5.785667ms) +✔ en-US: approved keys, placeholders and missing-template display label (5.5785ms) +Checked 3076 references including conditional and finite dynamic families. +✔ actual UI literal/conditional and finite dynamic keys exist in both languages with matching contracts (6.108292ms) +✔ production i18n is the inline entry; no second translator script is loaded (0.121792ms) +ℹ tests 33 +ℹ suites 0 +ℹ pass 33 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 192.045542 diff --git a/docs/repair/delete-protection/evidence/round3-verification.json b/docs/repair/delete-protection/evidence/round3-verification.json new file mode 100644 index 0000000..5f10aa1 --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3-verification.json @@ -0,0 +1,54 @@ +{ + "date": "2026-10-10", + "worktree": "/Users/massif/.codex/worktrees/automation-delete-protection/TianshanOS", + "branch": "fix/automation-delete-protection", + "base": "050757bb8046206797804ba5edc110ae3244bf17", + "unit": { + "command": "node --test tests/prompts/static.test.cjs tests/prompts/delete-reference.test.cjs tests/prompts/delete-protection.test.cjs", + "exit_code": 0, + "pass": 33, + "fail": 0 + }, + "browser": { + "command": "node --test tests/prompts/delete-reference.browser.test.cjs tests/prompts/delete-protection.browser.test.cjs", + "exit_code": 0, + "pass": 38, + "fail": 0 + }, + "diff_check": { + "command": "git diff --check", + "exit_code": 0 + }, + "v1_sha256": "9102fb703caf49e1e312f07efec7e3131bc6999009e0eb0d40243b3d53c26345", + "protected_file_sha256": { + "components/ts_api/src/ts_api_automation.c": "96d92e3faf24b4fcfb7e00a9fb84d70dfcc1980abd91aa67fe9bfa55dc2a7736", + "components/ts_automation/include/ts_action_manager.h": "b76ba33d17c219283a4b362bbe3cb755adeb044fedc9f32ff6ddc3b5c8bffc86", + "components/ts_automation/include/ts_rule_engine.h": "38d58102d4ca2714768a06bdd8abe238d2853a68d8403aef31f00162fbf01209", + "components/ts_automation/src/ts_action_manager.c": "d415d2ced03795c147608167e5dbe51160abb7e5bc850e2552bd66eb56e53bd8", + "components/ts_automation/src/ts_rule_engine.c": "8b0a1a4374226591fee02211d65c363dfb6b383ed348cdd10d35051e9c5a4aaf", + "components/ts_webui/web/js/api.js": "f635a33081cbd9e022e5674467ac3ae995c21bdb125e64439b41d8d6b53394b4", + "components/ts_webui/web/css/style.css": "5aef54b61efe7a5d9882a647cfcc6b88949a5f115897401f5293822653437b43" + }, + "round_changed_files": [ + "components/ts_webui/web/js/app.js", + "components/ts_webui/web/index.html", + "components/ts_webui/web/js/lang/zh-CN.js", + "components/ts_webui/web/js/lang/en-US.js", + "tests/prompts/static.test.cjs", + "tests/prompts/delete-reference.test.cjs", + "tests/prompts/delete-protection.test.cjs", + "tests/prompts/delete-reference.browser.test.cjs", + "tests/prompts/delete-protection.browser.test.cjs", + "docs/repair/delete-protection/PLAN.md", + "docs/repair/delete-protection/COPY.md", + "docs/repair/delete-protection/RESULT.md", + "docs/repair/delete-protection/evidence/source-hashes.json" + ], + "not_run": [ + "firmware tests", + "full tests", + "ESP-IDF build", + "hardware tests" + ], + "departures": [] +} diff --git a/docs/repair/delete-protection/evidence/round3.patch b/docs/repair/delete-protection/evidence/round3.patch new file mode 100644 index 0000000..dbc7f1f --- /dev/null +++ b/docs/repair/delete-protection/evidence/round3.patch @@ -0,0 +1,231 @@ +--- a/components/ts_webui/web/js/app.js ++++ b/components/ts_webui/web/js/app.js +@@ -8 +8 @@ +- const known = ['revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config', 'action_missing']; ++ const known = ['revision_conflict', 'source_read_only', 'recovery_required', 'commit_unknown', 'execution_busy', 'service_busy', 'busy_retired_config']; +@@ -12,0 +13 @@ ++ if (code === 'template_lookup_failed') return runtimeText('saveFailed'); +@@ -8269 +8270 @@ +- return 'operationPending'; ++ return 'unconfirmed'; +@@ -8295 +8296,2 @@ +- primary = t('deleteProtection.viewCommand'); target = '/commands'; ++ if (context.type === 'command') primary = t('common.close'); ++ else { primary = t('deleteProtection.viewCommand'); target = '/commands'; } +@@ -8315,0 +8318 @@ ++ const isRoot = api.isRoot(); +@@ -8317,2 +8320,4 @@ +- const body = loading ? runtimeText('configLoading') : recovery ? runtimeText('recovery_required') : t('automationPage.quickActionsHint'); +- const detail = loading ? t('deleteProtection.loadingDetails') : recovery ? t('deleteProtection.recoveryDetails') : ''; ++ const body = loading ? runtimeText('configLoading') : recovery ? runtimeText('recovery_required') : ++ (isRoot ? t('automationPage.quickActionsHint') : t('deleteProtection.quickEmptyNeedRoot')); ++ const detail = loading ? (isRoot ? t('deleteProtection.loadingDetails') : t('deleteProtection.quickLoadingNeedRoot')) : ++ recovery ? (isRoot ? t('deleteProtection.recoveryDetails') : t('deleteProtection.quickRecoveryNeedRoot')) : ''; +@@ -8321 +8326 @@ +- ''; ++ (isRoot ? '' : '') + ''; +--- a/components/ts_webui/web/index.html ++++ b/components/ts_webui/web/index.html +@@ -91 +91 @@ +- window.TS_ASSET_VERSION = '0.6.1-lpmu6-keygen1-delete1'; ++ window.TS_ASSET_VERSION = '0.6.1-lpmu6-keygen1-delete2'; +@@ -264 +264 @@ +- ++ +--- a/components/ts_webui/web/js/lang/zh-CN.js ++++ b/components/ts_webui/web/js/lang/zh-CN.js +@@ -39 +39,4 @@ +- "loadingDetails": "如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。" ++ "loadingDetails": "如果长时间没有变化,请在「终端」页打开「系统日志」查看原因。", ++ "quickLoadingNeedRoot": "如果长时间没有变化,请联系拥有 root 权限的用户查看原因。", ++ "quickRecoveryNeedRoot": "请联系拥有 root 权限的用户处理。", ++ "quickEmptyNeedRoot": "如需添加快捷操作,请联系拥有 root 权限的用户。" +--- a/components/ts_webui/web/js/lang/en-US.js ++++ b/components/ts_webui/web/js/lang/en-US.js +@@ -39 +39,4 @@ +- "loadingDetails": "If nothing changes for a long time, open “System Logs” on the Terminal page to see why." ++ "loadingDetails": "If nothing changes for a long time, open “System Logs” on the Terminal page to see why.", ++ "quickLoadingNeedRoot": "If nothing changes for a long time, contact a user with root access to find out why.", ++ "quickRecoveryNeedRoot": "Contact a user with root access to resolve this.", ++ "quickEmptyNeedRoot": "To add quick actions, contact a user with root access." +--- a/tests/prompts/static.test.cjs ++++ b/tests/prompts/static.test.cjs +@@ -14 +14 @@ +-for(const family of ['promptRepair','runtimeRepair'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); ++for(const family of ['promptRepair','runtimeRepair','deleteProtection'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); +--- a/tests/prompts/delete-reference.test.cjs ++++ b/tests/prompts/delete-reference.test.cjs +@@ -14 +14,2 @@ +- assert.equal(h.ctx.deleteServiceMessage({state:'future',busy:false}),'operationPending'); ++ assert.equal(h.ctx.deleteServiceMessage({state:'future',busy:false}),'unconfirmed'); ++ assert.equal(h.ctx.deleteServiceMessage({busy:false}),'unconfirmed'); +@@ -20,0 +22 @@ ++ assert.equal(h.ctx.runtimeSaveError({data:{error_code:'template_lookup_failed'}}),h.ctx.runtimeText('saveFailed')); +--- a/tests/prompts/delete-protection.test.cjs ++++ b/tests/prompts/delete-protection.test.cjs +@@ -49 +49,2 @@ +- assert(h.ctx.lastSheet.primary===h.ctx.t('deleteProtection.viewCommand')); ++ assert.equal(h.ctx.lastSheet.primary,h.ctx.t(entry==='command'?'common.close':'deleteProtection.viewCommand')); ++ assert.equal(h.ctx.lastSheet.secondary,entry==='command'?false:h.ctx.t('common.close')); +--- a/tests/prompts/delete-reference.browser.test.cjs ++++ b/tests/prompts/delete-reference.browser.test.cjs +@@ -11,2 +11,2 @@ +-async function pageFor(language,width,run){ +- const state={templateName:name,wire:'in-use',service:false,serviceState:'stopped',rules:[],loaded:true,recovery:false},calls=[],errors=[]; ++async function pageFor(language,width,run,level='root'){ ++ const state={templateName:name,wire:'in-use',service:false,serviceState:'stopped',rules:[],loaded:true,recovery:false,level,saveError:null},calls=[],errors=[]; +@@ -20 +20 @@ +- if(endpoint==='automation/rules/add')return send(fixture('action-missing')); ++ if(endpoint==='automation/rules/add')return send(state.saveError?{code:4,message:state.saveError,data:{error_code:state.saveError}}:fixture('action-missing')); +@@ -22 +22 @@ +- if(endpoint==='auth/status')data={valid:true,level:'root',username:'root'}; ++ if(endpoint==='auth/status')data={valid:true,level:state.level,username:state.level}; +@@ -40 +40 @@ +- await context.addInitScript(lang=>{localStorage.setItem('ts_language',lang);localStorage.setItem('ts_level','root');localStorage.setItem('ts_token','fixture');localStorage.setItem('ts_username','root');class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}}window.WebSocket=Socket;},language); ++ await context.addInitScript(({lang,level})=>{localStorage.setItem('ts_language',lang);localStorage.setItem('ts_level',level);localStorage.setItem('ts_token','fixture');localStorage.setItem('ts_username',level);class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}}window.WebSocket=Socket;},{lang:language,level}); +@@ -43 +43 @@ +- try{await page.goto(base);await page.waitForFunction(()=>i18n.isReady());await page.evaluate(()=>{closeLoginModal();location.hash='/automation';});await page.waitForSelector('#actions-list button[onclick]');await run({page,state,calls});assert.deepEqual(errors,[]);} ++ try{await page.goto(base);await page.waitForFunction(()=>i18n.isReady());await page.evaluate(root=>{closeLoginModal();if(root)location.hash='/automation';},level==='root');await page.waitForSelector(level==='root'?'#actions-list button[onclick]':'#quick-actions-grid');await run({page,state,calls});assert.deepEqual(errors,[]);} +@@ -111 +111,3 @@ +- if(scenario==='recovery')assert((await grid.textContent()).includes(await page.evaluate(()=>runtimeText('recovery_required')))); ++ if(scenario==='recovery'){assert((await grid.textContent()).includes(await page.evaluate(()=>runtimeText('recovery_required'))));assert((await grid.textContent()).includes(await page.evaluate(()=>t('deleteProtection.recoveryDetails'))));} ++ const bodyKey=scenario==='empty'?'automationPage.quickActionsHint':scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required'; ++ assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),bodyKey))); +@@ -121,0 +124,25 @@ ++ ++// One Chinese admin scene: both auth status and initial storage use the same non-root level. ++test('zh-CN: non-root homepage has approved guidance and no management entry',async()=>pageFor('zh-CN',390,async({page,state})=>{ ++ assert.equal(await page.evaluate(()=>api.getLevel()),'admin');assert.equal(await page.evaluate(()=>api.isRoot()),false); ++ for(const scenario of ['empty','loading','recovery']){ ++ state.loaded=scenario==='empty';state.recovery=scenario==='recovery';await page.evaluate(()=>refreshQuickActions()); ++ const grid=page.locator('#quick-actions-grid');assert.equal(await grid.count(),1);assert.equal(await grid.locator('button').count(),0); ++ const titleKey=scenario==='empty'?'automationPage.noQuickActions':scenario==='loading'?'deleteProtection.quickLoadingTitle':'deleteProtection.quickRecoveryTitle'; ++ assert.equal(await grid.locator('.t-body').textContent(),await page.evaluate(k=>t(k),titleKey)); ++ const newKey={empty:'deleteProtection.quickEmptyNeedRoot',loading:'deleteProtection.quickLoadingNeedRoot',recovery:'deleteProtection.quickRecoveryNeedRoot'}[scenario]; ++ assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),newKey))); ++ if(scenario!=='empty')assert.equal(await grid.locator('.t-note').first().textContent(),await page.evaluate(k=>t(k),scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required')); ++ for(const oldKey of ['deleteProtection.loadingDetails','deleteProtection.recoveryDetails','automationPage.quickActionsHint']) ++ assert(!(await grid.textContent()).includes(await page.evaluate(k=>t(k),oldKey))); ++ } ++},'admin')); ++test('zh-CN: template lookup failure uses saveFailed and retains the actual draft',async()=>pageFor('zh-CN',390,async({page,state,calls})=>{ ++ state.saveError='template_lookup_failed'; ++ await page.evaluate(async()=>{showAddRuleModal();await addActionTemplateRow('missing');}); ++ await page.locator('#rule-id').fill('new-rule');await page.locator('#rule-name').fill('Lookup draft remains');await page.locator('#rule-manual-only').check(); ++ await page.locator('#add-rule-modal button[onclick="submitAddRule()"] ').click(); ++ await page.waitForFunction(()=>document.getElementById('toast')?.textContent===t('runtimeRepair.saveFailed')); ++ assert.equal(await page.locator('#rule-name').inputValue(),'Lookup draft remains');assert.equal(await page.locator('#add-rule-modal').count(),1); ++ assert.equal(calls.filter(c=>c.endpoint==='automation/rules/add').length,1); ++})); +--- a/tests/prompts/delete-protection.browser.test.cjs ++++ b/tests/prompts/delete-protection.browser.test.cjs +@@ -35 +35 @@ +- await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};refreshCommandsList=()=>{};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); ++ await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};window.commandRefreshes=0;refreshCommandsList=()=>{window.commandRefreshes++;};window.deleteNavigationCalls=0;const navigate=router.navigate.bind(router);router.navigate=(...args)=>{window.deleteNavigationCalls++;return navigate(...args);};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); +@@ -37 +37,10 @@ +- if(state==='ready'){await page.locator('.confirm-sheet').waitFor();assert((await page.locator('.confirm-sheet').textContent()).includes('仍在运行'));await page.keyboard.press('Escape');} ++ if(state==='ready'){ ++ await page.locator('.confirm-sheet').waitFor();assert((await page.locator('.confirm-sheet').textContent()).includes('仍在运行')); ++ if(entry==='command'){ ++ assert.equal(await page.locator('.confirm-sheet button').count(),1); ++ assert.equal(await page.locator('.confirm-sheet button').textContent(),await page.evaluate(()=>t('common.close'))); ++ const before=await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})); ++ await page.locator('.confirm-sheet button').click();await deletion; ++ assert.deepEqual(await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})),before); ++ }else await page.keyboard.press('Escape'); ++ } +--- a/docs/repair/delete-protection/PLAN.md ++++ b/docs/repair/delete-protection/PLAN.md +@@ -1 +1 @@ +-# 删除保护与快捷操作提示:已确认的实施方案 v3 ++# 删除保护与快捷操作提示:已确认的实施方案 v4(第三轮方案 v2) +@@ -3 +3 @@ +-日期:2026-10-09。用户已明确回复“确认,按第二轮文案实施”。 ++日期:2026-10-10。用户已明确回复“确认,按第二轮文案实施”,并批准第三轮方案 v2 及三句非 root 首页说明。 +@@ -31,3 +31,3 @@ +-- runtimeSaveError 只增加 action_missing 的处理,原有原因结构保持;新文案在 deleteProtection 命名空间。 +-- 查看规则和查看指令只改变路由,不定位、不高亮、不自动编辑或删除。 +-- 首页 recovery > loading > 空状态,保留面板与前往自动化入口;不泛化提示用户检查 SD 卡。 ++- runtimeSaveError 保留 action_missing 的名称提示,将本轮新增原因 template_lookup_failed 映射到现有 saveFailed;不扩展其他原因。新文案在 deleteProtection 命名空间。 ++- 查看规则以及从模板阻断框查看指令只改变路由,不定位、不高亮、不自动编辑或删除;指令自身被阻断时只有关闭,不重载当前页。 ++- 首页 recovery > loading > 空状态,保留面板;root 原说明及前往自动化入口不变,所有非 root 无按钮,按状态使用 quickLoadingNeedRoot/quickRecoveryNeedRoot/quickEmptyNeedRoot 三句用户确认说明。不泛化提示用户检查 SD 卡。 +@@ -36 +36 @@ +-- app/api/语言包/CSS 资源查询标识含 delete1,发布版本仍为 0.6.1。 ++- app.js 查询标识及语言包使用的 TS_ASSET_VERSION 为 delete2;api.js/CSS 保持 delete1。xterm 地址随 TS_ASSET_VERSION 变化,内容未改;发布版本仍为 0.6.1。 +@@ -51 +51 @@ +-| 未来未知状态 | operationPending,保持拒绝 | ++| 未来未知或缺失状态 | unconfirmed,保持拒绝 | +--- a/docs/repair/delete-protection/COPY.md ++++ b/docs/repair/delete-protection/COPY.md +@@ -30,0 +31,3 @@ ++| deleteProtection.quickLoadingNeedRoot | 如果长时间没有变化,请联系拥有 root 权限的用户查看原因。 | If nothing changes for a long time, contact a user with root access to find out why. | ++| deleteProtection.quickRecoveryNeedRoot | 请联系拥有 root 权限的用户处理。 | Contact a user with root access to resolve this. | ++| deleteProtection.quickEmptyNeedRoot | 如需添加快捷操作,请联系拥有 root 权限的用户。 | To add quick actions, contact a user with root access. | +@@ -32,2 +35,2 @@ +-C 组不使用 goAutomation;只有首页三个状态使用该跳转入口。关闭复用 common.close。 +-空状态 quickActionsHint 已改为「在首页显示」,加载/恢复正文复用现有 runtimeRepair.configLoading/recovery_required;后端服务兜底复用 promptRepair.serviceDeleteProtected。 ++C 组不使用 goAutomation;首页三个状态仅 root 使用该跳转入口。指令自身的服务阻断框只有 common.close,模板场景保留 viewCommand。 ++root 空状态 quickActionsHint 已改为「在首页显示」,加载/恢复正文复用现有 runtimeRepair.configLoading/recovery_required;后端服务兜底复用 promptRepair.serviceDeleteProtected。非 root 加载/恢复的补充句与空状态正文使用上述三个 NeedRoot 新键,用户已确认定稿,无占位符,逐字采用。 +--- a/docs/repair/delete-protection/RESULT.md ++++ b/docs/repair/delete-protection/RESULT.md +@@ -34 +34 @@ +-## 验证 ++## 上一轮验证(第三轮实施前的源码) +@@ -50 +50 @@ +-## 资源与工件 ++## 上一轮资源与工件(不代表第三轮净增量或新构建) +@@ -60 +60 @@ +-## 文案与截图 ++## 文案与上一轮截图 +@@ -78,0 +79,26 @@ ++ ++ ++## 第三轮补记:2026-10-10,按第三轮方案 v2 完成 ++ ++用户已授权实施 R1–R5 并确认三句非 root 首页说明。完整方案在主目录 `output/review-delete-protection-copy/第三轮修复方案与交接-v2.md`,回执在同目录 `第三轮实施回执.md`。仍为原独立工作目录中的未提交修改。 ++ ++- R1:静态检查加入 deleteProtection,当前原24键加新增3键均由相同检查覆盖。 ++- R2:无未完成操作证据的未知/缺失服务状态使用 unconfirmed;真实 pending 分支及删除安全门保持。 ++- R3:template_lookup_failed 使用现有 saveFailed;action_missing 名称说明保持,保存失败草稿保留。 ++- R4:只有 root 显示自动化入口;三句已确认非 root 说明按场景使用,root 原正文/补充句保持。新 t() 使用字面量键。 ++- R5:指令自身的服务阻断框只有关闭,关闭不导航或重载;动作模板查看指令和查看规则继续只跳转。 ++- TS_ASSET_VERSION 和 app.js 查询串为 delete2;api.js/CSS 保持 delete1,xterm 地址随共享标识变化。没有改变发布版本。 ++ ++| 本轮实际命令(独立目录) | 结果 | 证据 | ++|---|---|---| ++| `node --test tests/prompts/static.test.cjs tests/prompts/delete-reference.test.cjs tests/prompts/delete-protection.test.cjs` | 33/33,通过 | [round3-unit.log](evidence/round3-unit.log) | ++| `node --test tests/prompts/delete-reference.browser.test.cjs tests/prompts/delete-protection.browser.test.cjs` | 38/38,通过 | [round3-browser.log](evidence/round3-browser.log) | ++| `git diff --check` | 通过,无空白错误 | [round3-verification.json](evidence/round3-verification.json) | ++ ++新增浏览器场景只有中文 admin 三状态和中文查询失败保留草稿;已有相关文件的语言/视口/服务覆盖照常执行,没有新增矩阵。admin 同时用于 auth/status 返回等级和初始化 localStorage,实际 api.getLevel 为 admin、isRoot 为 false。三状态无按钮、对应定稿出现、旧 root 管理操作提示不出现;加载/恢复标题与正文保持,空状态仅替换正文。 ++ ++本轮未运行固件测试、全量测试、ESP-IDF构建或真机测试;固件/HTTP未修改,用户明确限定验证范围。上方126/262结果、构建日志、截图、二进制及2807B资源统计均为上一轮历史证据,不能当作本轮固件工件或资源增量。没有重新生成 HTTP fixture、截图或二进制。 ++ ++[source-hashes.json](evidence/source-hashes.json)现对应本轮源码;[source-hashes-before-round3.json](evidence/source-hashes-before-round3.json)保留上一轮源码记录,旧工件哈希不变。复审可用[round3.patch](evidence/round3.patch)查看相对上一轮未提交实现的精确增量,而不只与 main 比较。补丁采用无上下文格式,避免空白上下文行被 Git 当作尾随空格;需要应用时使用 git apply --unidiff-zero。 ++ ++方案无实质偏差,没有需要用户另作决定的事项。固件、通用HTTP出口、锁顺序、committing及api.js/CSS等受保护文件与本轮开始时逐字相同;main源码未改。未提交、推送、开PR、合并或刷机,未修改其他分支/工作目录。 +--- a/docs/repair/delete-protection/evidence/source-hashes.json ++++ b/docs/repair/delete-protection/evidence/source-hashes.json +@@ -8 +8 @@ +- "components/ts_webui/web/index.html": "5b2413878fc9c7a7895811591233968c44acef69ddd88751520dc928c0a16097", ++ "components/ts_webui/web/index.html": "4fe61cb62fc0a0d82c8d8bc25a6989a3f83d51a07ae0b160719a51a0e8efe5c7", +@@ -10,3 +10,3 @@ +- "components/ts_webui/web/js/app.js": "fe704a6ebb90917ce154ed38504822f760de257623703ef6ba8a7c17fa6f119f", +- "components/ts_webui/web/js/lang/en-US.js": "a8ddfed891595514698b6aaf4212b39e035a644b10396f8015fd1dfdc38bd400", +- "components/ts_webui/web/js/lang/zh-CN.js": "f4872db0c2e94974d2541f5e1a04a15bea5a28ec058667a699ee98091c4c5ddf", ++ "components/ts_webui/web/js/app.js": "159dba2c7008282bbf3d291735a2547aa4546da905f1dee50870f4e85d852393", ++ "components/ts_webui/web/js/lang/en-US.js": "4efc90a760ea704ab0daf4eee7ef232e6e0c3d43bb4453f8119603d7b9e964f6", ++ "components/ts_webui/web/js/lang/zh-CN.js": "ff3881d4d344fee9109ab8ab185684289ea780e54b2696b54b2d971ffa44eb5f", +@@ -22,4 +22,4 @@ +- "tests/prompts/delete-protection.browser.test.cjs": "c0691c5d8bea3190720c2e7c6e8a83a897bb3f18ae0d8b5001148d0fc0bb10cb", +- "tests/prompts/delete-protection.test.cjs": "1ad5a34b98b7ba2ca99ac7c162e1076e8ec973131919dca601df90ec8cabfd56", +- "tests/prompts/delete-reference.browser.test.cjs": "d2eb6f4a04e93cef4ae0331f1c5d0207cc0d63905a2f88636caf52f0718c1d1b", +- "tests/prompts/delete-reference.test.cjs": "3b6b9db25ec1513afbfd443ab20fee3690176b0053337d099b8762a7514025eb", ++ "tests/prompts/delete-protection.browser.test.cjs": "2bc58639fcbeeffd1d25cf7f1785d992e6f76c56312248e3d91426b978626aec", ++ "tests/prompts/delete-protection.test.cjs": "40bb3dd4a9b08739b9f6e74e0f4a26734f21a37ff6cc30eabe9f897c9534347c", ++ "tests/prompts/delete-reference.browser.test.cjs": "230b0b27f29af1f4c2a8f84c810d81dda4086d6d5ae0953e09a6265da8bdf346", ++ "tests/prompts/delete-reference.test.cjs": "083b29086734e8195c1414ad0387fb8bbdf6f0f96d083eb4ed752a349504ce76", +@@ -31 +31,2 @@ +- "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c" ++ "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c", ++ "tests/prompts/static.test.cjs": "be2ea30d0e40ee116a19283add4973b7e4685faf65062f2ad903c6d37563ea94" diff --git a/docs/repair/delete-protection/evidence/runtime.log b/docs/repair/delete-protection/evidence/runtime.log new file mode 100644 index 0000000..7990a00 --- /dev/null +++ b/docs/repair/delete-protection/evidence/runtime.log @@ -0,0 +1,107 @@ +PASS actual log hook/parser: full 510..4096 output, bounded UTF-8 preview, ANSI, concurrent capture/drop, full own output, callback recursion, capture toggle +PASS rule codec: nine action types, conditions/repeats/explicit false, allocation failures, unsupported operator; rule=256 action=1024 +PASS actual rule persistence: NVS and SD fault injection, recovery boundaries, read-only rejection +PASS actual rule engine: revisions/no-op/allocation failures, stale-evaluation barrier, pinned old execution, retirement bound, enable/manual/allow matrix, recreate identity +PASS actual SSH client: trust gate, command deadlines, partial stdin/EAGAIN/EOF, input cancellation/errors, exact secret bytes without command/log exposure +PASS actual service registry + watcher: duplicate admission, task failure, immediate finish, stale handle, cooperative cancellation, late READY rejection, truthful remote stop/unknown +PASS service protocol: queued/active launch stop guards, retained-rule READY stop, stale launch completion, stale credentials, cancellation cleanup, deletion barrier/failure/reuse, 70 replacements, missing config protection, physical identity, page-free recovery, 3-attempt budget, zero-I/O cached reads +PASS production synchronous action completion: timeout then late result, success, queue rejection; semaphore lifetime balanced +PASS production snapshot -> sequencer -> submission -> executor: delay once, immutable binding, sync/async, fast/late completion, failures, cancellation, repeat, legacy routing; 50 cycles balanced +PASS production service API/executor/SSH/registry: zero-I/O admission, slow SSH with responsive cached API, engine-independent controls, literal vs expanded commands, overflow before I/O, retained-rule stop, 30s expiry, timeout evidence, JSON/entry/snapshot/queue/cancel cleanup, fast worker, 100 cycles +delete API: protected services report BUSY, denied deletes preserve files, missing/internal errors stay distinct +PASS production delete + rule commit + HTTP: H01-H14, leases, no side effects, historical multiplicity, committing cleared, 47/63-byte fields +PASS exact HTTP byte fixtures: 8 +PASS typed conditions: 16 rule pairs, 1792 unchanged comparison results, real codec encode/decode +PASS production expansion: 62/63 chars, exact fit, overflow rejects empty, unknown and Shell expressions preserved, long names, numeric values +PASS actual configuration wrappers: host edit vs query, immutable target, failed delete preserves registration, successful delete then same-name replacement +PASS production host NVS/import + service registry: orphan recovery, uninitialized/pinned/unknown guards, atomic admission, storage errors/full capacity, SD precedence/merge/failures, concurrent add backup, boot retains unrelated hosts, queued backup preserves SD input, list storage error propagation +PASS production copyid first-use: automatic trust/save, API defaults, known host, changed-key rejection before auth, explicit opt-out, save/auth failures, other SSH operations retain exact confirmation +PASS production ssh.copyid + HTTP serialization: actual deployment/verification/registration outcomes, default/custom IDs, busy/capacity/storage errors, partial data retained, no repeat or rollback +PASS copyid + production known hosts: full SHA-256 save/readback, repeat deployment, host/port isolation, final-byte mismatch preserved, read/write/commit failure blocks authentication +PASS copyid: API and CLI share actual builder; exact bytes, no command injection, bounded capacity, permissions +PASS actual LPMU task/API: task-owned SD initialization, lock-free file I/O, 1 KiB POSIX reads, truncation/incomplete UTF-8/read/seek/write/close failures, replaced-run rejection, cleanup-before-terminal publication, password stdin/wipe, upload, host gate, split markers, zero-exit unconfirmed, 100 KiB exact reconstruction and PSRAM-only read allocation failure +PASS input repair: real API/readback/export/import, NVS and SD reload, failed edit preservation, seven filter execution configurations, zero/units/legacy defaults, inline rule codec; filter params=28 LED action=784 action=1024 template=1232 +PASS production action store: 1264 save and 944 recovery I/O/crash boundaries, applied-rename errors, FAT refuses overwrites, JSON/pack/both/first-create, exact old-or-new recovery, multiple IDs, repeated saves, mirror preserves encrypted source, unrelated files retained +PASS actual action pack: complete export envelope -> SDK encrypted/plain priority -> real boot load entry and directory parser; identity/type/enabled/async/partial-zero parameters, flat legacy JSON, 63-byte ASCII/UTF-8 IDs, interrupted save recovery before load, unresolved recovery blocks stale fallback, malformed envelope rejection (crypto/NVS backup mocked) +PASS production template serializer: 258 serialization and 258 update allocation boundaries across six stored action types; complete JSON or failure, failed edits preserve active and stored versions +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:127:5: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 127 | sprintf(version, "%i.%i.%i", CJSON_VERSION_MAJOR, CJSON_VERSION_MINOR, CJSON_VERSION_PATCH); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:609:18: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 609 | length = sprintf((char*)number_buffer, "null"); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:613:18: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 613 | length = sprintf((char*)number_buffer, "%d", item->valueint); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:618:18: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 618 | length = sprintf((char*)number_buffer, "%1.15g", d); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:624:22: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 624 | length = sprintf((char*)number_buffer, "%1.17g", d); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +/Users/massif/esp/v5.5.2/esp-idf/components/json/cJSON/cJSON.c:1058:21: warning: 'sprintf' is deprecated: This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead. [-Wdeprecated-declarations] + 1058 | sprintf((char*)output_pointer, "u%04x", *input_pointer); + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/_stdio.h:278:1: note: 'sprintf' has been explicitly marked deprecated here + 278 | __deprecated_msg("This function is provided for compatibility reasons only. Due to security concerns inherent in the design of sprintf(3), it is highly recommended that you use snprintf(3) instead.") + | ^ +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/cdefs.h:227:48: note: expanded from macro '__deprecated_msg' + 227 | #define __deprecated_msg(_msg) __attribute__((__deprecated__(_msg))) + | ^ +6 warnings generated. +PASS actual CSR API: reject wrong types/overlong inputs before key access, organization-only/empty ID use default, exact 63-byte ID, no credential changes +metadata PASS actual keystore regression + +index PASS actual keystore regression + +prefix PASS actual keystore regression + +matrix PASS actual keystore regression + +admission PASS actual keystore regression + +concurrency PASS actual keystore regression + +compatibility PASS actual keystore regression + +api PASS actual keystore regression + +allocations PASS actual keystore regression + +lifecycle PASS actual keystore regression + +PASS actual store -> current loader: normal/dot IDs, temporary files ignored, reload retains saved rule +PASS actual automation stop + engine: blocked action returns normally, leases/executing clear, async work prevents restart, independent remote ownership retained +PASS actual probe builder: quotes, spaces, leading dash, literal shell metacharacters, fail priority, exact tokens, overflow +PASS actual UI functions: bilingual states, one revisioned update, manual conditions/types, inline fields/repeat preservation, explicit false vs omitted, conflict/draft/uncertain write +PASS production bilingual SSH UI/API helpers: rendered ID/key, verification/registration partial outcomes, cache-preserving list errors, true empty list, old-firmware tuple confirmation, stale style/page cleanup, out-of-order list replies, no repeat deployment diff --git a/docs/repair/delete-protection/evidence/source-hashes-before-round3.json b/docs/repair/delete-protection/evidence/source-hashes-before-round3.json new file mode 100644 index 0000000..60f0cdd --- /dev/null +++ b/docs/repair/delete-protection/evidence/source-hashes-before-round3.json @@ -0,0 +1,32 @@ +{ + "components/ts_api/src/ts_api_automation.c": "96d92e3faf24b4fcfb7e00a9fb84d70dfcc1980abd91aa67fe9bfa55dc2a7736", + "components/ts_automation/include/ts_action_manager.h": "b76ba33d17c219283a4b362bbe3cb755adeb044fedc9f32ff6ddc3b5c8bffc86", + "components/ts_automation/include/ts_rule_engine.h": "38d58102d4ca2714768a06bdd8abe238d2853a68d8403aef31f00162fbf01209", + "components/ts_automation/src/ts_action_manager.c": "d415d2ced03795c147608167e5dbe51160abb7e5bc850e2552bd66eb56e53bd8", + "components/ts_automation/src/ts_rule_engine.c": "8b0a1a4374226591fee02211d65c363dfb6b383ed348cdd10d35051e9c5a4aaf", + "components/ts_webui/web/css/style.css": "5aef54b61efe7a5d9882a647cfcc6b88949a5f115897401f5293822653437b43", + "components/ts_webui/web/index.html": "5b2413878fc9c7a7895811591233968c44acef69ddd88751520dc928c0a16097", + "components/ts_webui/web/js/api.js": "f635a33081cbd9e022e5674467ac3ae995c21bdb125e64439b41d8d6b53394b4", + "components/ts_webui/web/js/app.js": "fe704a6ebb90917ce154ed38504822f760de257623703ef6ba8a7c17fa6f119f", + "components/ts_webui/web/js/lang/en-US.js": "a8ddfed891595514698b6aaf4212b39e035a644b10396f8015fd1dfdc38bd400", + "components/ts_webui/web/js/lang/zh-CN.js": "f4872db0c2e94974d2541f5e1a04a15bea5a28ec058667a699ee98091c4c5ddf", + "tests/fixtures/delete-reference/README.md": "a5206e573525982b7b179230b2a9b58e64710881448bb4942d7d8cf8965b81cf", + "tests/fixtures/delete-reference/action-missing.json": "e2c366ac3d512a4f8612fac9f69ba865989c3a2789b588bd08b403a32267f176", + "tests/fixtures/delete-reference/boundary.json": "d389136890bffb57e5c36714ebb10c1123b35da68ef3e0e621a7b9fa8e1afced", + "tests/fixtures/delete-reference/check-loading.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/check-recovery.json": "d3e676e11440911a72ad7559c4eb5552e77bb65f1cbc2ece368203a70c7214e8", + "tests/fixtures/delete-reference/check-uninitialized.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/in-use-no-details.json": "09add28b1ce8ab1c3059084284bffdfac77d31eba75d48acaf52a912fd5cd7ca", + "tests/fixtures/delete-reference/in-use.json": "7fb8a4b964090928d3d41274abf9621f5328a5cc792a5a831ca477db8f0b4e8b", + "tests/fixtures/delete-reference/service-protected.json": "a8fb774e32177dd88c8f91b92f13c3174fcb40966f68ecef9f2f218519d86c8c", + "tests/prompts/delete-protection.browser.test.cjs": "c0691c5d8bea3190720c2e7c6e8a83a897bb3f18ae0d8b5001148d0fc0bb10cb", + "tests/prompts/delete-protection.test.cjs": "1ad5a34b98b7ba2ca99ac7c162e1076e8ec973131919dca601df90ec8cabfd56", + "tests/prompts/delete-reference.browser.test.cjs": "d2eb6f4a04e93cef4ae0331f1c5d0207cc0d63905a2f88636caf52f0718c1d1b", + "tests/prompts/delete-reference.test.cjs": "3b6b9db25ec1513afbfd443ab20fee3690176b0053337d099b8762a7514025eb", + "tests/prompts/package.json": "e492525ec8752651b749045346095429634de8efa84205c2d57e969053176409", + "tests/runtime/extract_engine.py": "f7917c42055ae1301792c127f3eafe775cff03f457564e474110b2bf5e4e7daf", + "tests/runtime/run_all.sh": "a227fb5b280e120ec795dd851dffe0b5725b97c9724ef48fc67f4cd65feebb90", + "tests/runtime/test_delete_api.py": "e13035af2b482b2dbd27529d340bdf759ba575f8b2cd96ad9d523474a7e5b7f4", + "tests/runtime/test_delete_reference.py": "73ff90a36df5e17dcfff25944390e8d9421583f38cf600f107c5661cff8ea1d4", + "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c" +} diff --git a/docs/repair/delete-protection/evidence/source-hashes.json b/docs/repair/delete-protection/evidence/source-hashes.json new file mode 100644 index 0000000..1a96431 --- /dev/null +++ b/docs/repair/delete-protection/evidence/source-hashes.json @@ -0,0 +1,33 @@ +{ + "components/ts_api/src/ts_api_automation.c": "96d92e3faf24b4fcfb7e00a9fb84d70dfcc1980abd91aa67fe9bfa55dc2a7736", + "components/ts_automation/include/ts_action_manager.h": "b76ba33d17c219283a4b362bbe3cb755adeb044fedc9f32ff6ddc3b5c8bffc86", + "components/ts_automation/include/ts_rule_engine.h": "38d58102d4ca2714768a06bdd8abe238d2853a68d8403aef31f00162fbf01209", + "components/ts_automation/src/ts_action_manager.c": "d415d2ced03795c147608167e5dbe51160abb7e5bc850e2552bd66eb56e53bd8", + "components/ts_automation/src/ts_rule_engine.c": "8b0a1a4374226591fee02211d65c363dfb6b383ed348cdd10d35051e9c5a4aaf", + "components/ts_webui/web/css/style.css": "5aef54b61efe7a5d9882a647cfcc6b88949a5f115897401f5293822653437b43", + "components/ts_webui/web/index.html": "4fe61cb62fc0a0d82c8d8bc25a6989a3f83d51a07ae0b160719a51a0e8efe5c7", + "components/ts_webui/web/js/api.js": "f635a33081cbd9e022e5674467ac3ae995c21bdb125e64439b41d8d6b53394b4", + "components/ts_webui/web/js/app.js": "159dba2c7008282bbf3d291735a2547aa4546da905f1dee50870f4e85d852393", + "components/ts_webui/web/js/lang/en-US.js": "4efc90a760ea704ab0daf4eee7ef232e6e0c3d43bb4453f8119603d7b9e964f6", + "components/ts_webui/web/js/lang/zh-CN.js": "ff3881d4d344fee9109ab8ab185684289ea780e54b2696b54b2d971ffa44eb5f", + "tests/fixtures/delete-reference/README.md": "a5206e573525982b7b179230b2a9b58e64710881448bb4942d7d8cf8965b81cf", + "tests/fixtures/delete-reference/action-missing.json": "e2c366ac3d512a4f8612fac9f69ba865989c3a2789b588bd08b403a32267f176", + "tests/fixtures/delete-reference/boundary.json": "d389136890bffb57e5c36714ebb10c1123b35da68ef3e0e621a7b9fa8e1afced", + "tests/fixtures/delete-reference/check-loading.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/check-recovery.json": "d3e676e11440911a72ad7559c4eb5552e77bb65f1cbc2ece368203a70c7214e8", + "tests/fixtures/delete-reference/check-uninitialized.json": "672f69c5db8931ab1114be09ec0b1b0ca9846cda104e07652d6e7b20a36f623f", + "tests/fixtures/delete-reference/in-use-no-details.json": "09add28b1ce8ab1c3059084284bffdfac77d31eba75d48acaf52a912fd5cd7ca", + "tests/fixtures/delete-reference/in-use.json": "7fb8a4b964090928d3d41274abf9621f5328a5cc792a5a831ca477db8f0b4e8b", + "tests/fixtures/delete-reference/service-protected.json": "a8fb774e32177dd88c8f91b92f13c3174fcb40966f68ecef9f2f218519d86c8c", + "tests/prompts/delete-protection.browser.test.cjs": "2bc58639fcbeeffd1d25cf7f1785d992e6f76c56312248e3d91426b978626aec", + "tests/prompts/delete-protection.test.cjs": "40bb3dd4a9b08739b9f6e74e0f4a26734f21a37ff6cc30eabe9f897c9534347c", + "tests/prompts/delete-reference.browser.test.cjs": "230b0b27f29af1f4c2a8f84c810d81dda4086d6d5ae0953e09a6265da8bdf346", + "tests/prompts/delete-reference.test.cjs": "083b29086734e8195c1414ad0387fb8bbdf6f0f96d083eb4ed752a349504ce76", + "tests/prompts/package.json": "e492525ec8752651b749045346095429634de8efa84205c2d57e969053176409", + "tests/runtime/extract_engine.py": "f7917c42055ae1301792c127f3eafe775cff03f457564e474110b2bf5e4e7daf", + "tests/runtime/run_all.sh": "a227fb5b280e120ec795dd851dffe0b5725b97c9724ef48fc67f4cd65feebb90", + "tests/runtime/test_delete_api.py": "e13035af2b482b2dbd27529d340bdf759ba575f8b2cd96ad9d523474a7e5b7f4", + "tests/runtime/test_delete_reference.py": "73ff90a36df5e17dcfff25944390e8d9421583f38cf600f107c5661cff8ea1d4", + "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c", + "tests/prompts/static.test.cjs": "be2ea30d0e40ee116a19283add4973b7e4685faf65062f2ad903c6d37563ea94" +} diff --git a/docs/repair/delete-protection/evidence/unit.log b/docs/repair/delete-protection/evidence/unit.log new file mode 100644 index 0000000..1504247 --- /dev/null +++ b/docs/repair/delete-protection/evidence/unit.log @@ -0,0 +1,139 @@ + +> test +> cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs tests/prompts/delete-reference.test.cjs + +✔ zh-CN: protected delete explains service state rather than internal failure (111.646292ms) +✔ en-US: protected delete explains service state rather than internal failure (316.784417ms) +✔ action: unregistered service is verified stopped before deletion (27.704916ms) +✔ action: running never leads to delete or stop (10.940958ms) +✔ action: ready never leads to delete or stop (10.870875ms) +✔ action: unknown never leads to delete or stop (72.463542ms) +✔ action: failed verification never deletes (17.936458ms) +✔ action: ordinary command has no service verification (9.694667ms) +✔ action: repeated click submits only one verification and delete (9.179166ms) +✔ action: leaving the page during verification cancels subsequent deletion (261.855875ms) +✔ command: unregistered service is verified stopped before deletion (10.328916ms) +✔ command: running never leads to delete or stop (11.657958ms) +✔ command: ready never leads to delete or stop (16.468833ms) +✔ command: unknown never leads to delete or stop (18.851333ms) +✔ command: failed verification never deletes (10.566958ms) +✔ command: ordinary command has no service verification (172.129833ms) +✔ command: repeated click submits only one verification and delete (124.93675ms) +✔ command: leaving the page during verification cancels subsequent deletion (71.232333ms) +✔ switching hosts during verification removes the original command only (27.376166ms) +✔ action: newer start prevents stale deletion and status painting (8.438875ms) +✔ action: newer stop prevents stale deletion and status painting (103.85775ms) +✔ action: newer verify prevents stale deletion and status painting (9.195959ms) +✔ command: newer start prevents stale deletion and status painting (10.797ms) +✔ command: newer stop prevents stale deletion and status painting (65.694375ms) +✔ command: newer verify prevents stale deletion and status painting (7.602792ms) +✔ action: stale admission failure cannot replace newer start feedback (8.858125ms) +✔ command: stale admission failure cannot replace newer start feedback (5.215209ms) +✔ zh-CN: H15 nine states, busy and operation kind select an approved explanation (19.954459ms) +✔ en-US: H15 nine states, busy and operation kind select an approved explanation (6.084708ms) +✔ zh-CN: approved keys, placeholders and missing-template display label (6.379834ms) +✔ en-US: approved keys, placeholders and missing-template display label (6.066208ms) +✔ zh-CN rsa2048: delayed real handler gives persistent status, one POST, closes and refreshes (19.209291ms) +✔ zh-CN rsa4096: delayed real handler gives persistent status, one POST, closes and refreshes (5.800208ms) +✔ zh-CN ec256: delayed real handler gives persistent status, one POST, closes and refreshes (7.176459ms) +✔ zh-CN ec384: delayed real handler gives persistent status, one POST, closes and refreshes (216.374417ms) +✔ zh-CN: business failure keeps inputs, stage and cleanup, distinguishes memory/storage (53.867ms) +✔ zh-CN: timeout is unknown, one POST and at most one record check, no automatic retry (31.494291ms) +✔ zh-CN: network is unknown, one POST and at most one record check, no automatic retry (13.086041ms) +✔ zh-CN: http is unknown, one POST and at most one record check, no automatic retry (10.037541ms) +✔ zh-CN: body is unknown, one POST and at most one record check, no automatic retry (8.424459ms) +✔ zh-CN: json is unknown, one POST and at most one record check, no automatic retry (145.229625ms) +✔ zh-CN: string-code is unknown, one POST and at most one record check, no automatic retry (14.07975ms) +✔ zh-CN: missing is unknown, one POST and at most one record check, no automatic retry (84.878958ms) +✔ zh-CN: wrong-id is unknown, one POST and at most one record check, no automatic retry (20.008208ms) +✔ zh-CN: response-allocation is unknown, one POST and at most one record check, no automatic retry (9.388792ms) +✔ zh-CN: response-no-message is unknown, one POST and at most one record check, no automatic retry (8.022ms) +✔ zh-CN: a failed record check remains unknown and refresh failure cannot become generation failure (146.688417ms) +✔ zh-CN: close/reopen and navigation prevent stale modal updates; pending guard survives closure (31.772916ms) +✔ zh-CN: invalid IDs rejected before any POST (10.867958ms) +✔ zh-CN: actual key refresh rejects business/format failures, keeps HTTPS and resists out-of-order responses (10.914541ms) +✔ en-US rsa2048: delayed real handler gives persistent status, one POST, closes and refreshes (22.451625ms) +✔ en-US rsa4096: delayed real handler gives persistent status, one POST, closes and refreshes (26.440333ms) +✔ en-US ec256: delayed real handler gives persistent status, one POST, closes and refreshes (296.11825ms) +✔ en-US ec384: delayed real handler gives persistent status, one POST, closes and refreshes (21.856292ms) +✔ en-US: business failure keeps inputs, stage and cleanup, distinguishes memory/storage (177.397666ms) +✔ en-US: timeout is unknown, one POST and at most one record check, no automatic retry (13.3025ms) +✔ en-US: network is unknown, one POST and at most one record check, no automatic retry (12.612375ms) +✔ en-US: http is unknown, one POST and at most one record check, no automatic retry (7.694625ms) +✔ en-US: body is unknown, one POST and at most one record check, no automatic retry (11.804334ms) +✔ en-US: json is unknown, one POST and at most one record check, no automatic retry (18.288458ms) +✔ en-US: string-code is unknown, one POST and at most one record check, no automatic retry (14.833833ms) +✔ en-US: missing is unknown, one POST and at most one record check, no automatic retry (11.199625ms) +✔ en-US: wrong-id is unknown, one POST and at most one record check, no automatic retry (11.860166ms) +✔ en-US: response-allocation is unknown, one POST and at most one record check, no automatic retry (10.623041ms) +✔ en-US: response-no-message is unknown, one POST and at most one record check, no automatic retry (8.04125ms) +✔ en-US: a failed record check remains unknown and refresh failure cannot become generation failure (77.481125ms) +✔ en-US: close/reopen and navigation prevent stale modal updates; pending guard survives closure (8.431625ms) +✔ en-US: invalid IDs rejected before any POST (6.000208ms) +✔ en-US: actual key refresh rejects business/format failures, keeps HTTPS and resists out-of-order responses (6.524209ms) +✔ zh-CN: rebuilt page controls are released without stale errors; explicit success replaces a prior warning (8.918042ms) +✔ en-US: rebuilt page controls are released without stale errors; explicit success replaces a prior warning (6.390917ms) +✔ zh-CN: first deployment sends automatic first trust once, with no confirmation and factual registration result (128.278208ms) +✔ zh-CN: deployment honors explicit first-trust opt-out and does not change other SSH defaults (13.60675ms) +✔ en-US: first deployment sends automatic first trust once, with no confirmation and factual registration result (13.212083ms) +✔ en-US: deployment honors explicit first-trust opt-out and does not change other SSH defaults (12.756ms) +✔ zh-CN: actual inline translator, cold language, missing/objects/parameters and confirmations (9.09ms) +✔ zh-CN: REST business failure remains returned; strict operation rejects with original metadata (11.002083ms) +✔ zh-CN: timeout covers response body; result stays unknown for POST (107.090583ms) +✔ zh-CN: toast timer replacement, priority, safe multiline text (21.024ms) +✔ zh-CN: actual mutations do not report success or close dialogs on business failures (11.29375ms) +✔ zh-CN: batch deletion all / partial / no success with business errors (49.112334ms) +✔ zh-CN: downloads propagate actual initiation failures and retain batch details (64.562334ms) +✔ zh-CN: file names are text; upload/verification/apply remain separate (21.021417ms) +✔ zh-CN: SSH actual event handler honors terminal states and diagnoses conflicts (12.428833ms) +✔ zh-CN: background command timeout is unconfirmed and never resubmitted (68.600708ms) +✔ zh-CN: terminal restoration waits for acknowledgement; timeout/failure stay distinct (44.399417ms) +✔ zh-CN: ping results are reachability only; failed probe is unknown and no pulse replay (14.937625ms) +✔ zh-CN: websocket label matches color across language changes and power warnings persist (9.385625ms) +✔ en-US: actual inline translator, cold language, missing/objects/parameters and confirmations (11.712333ms) +✔ en-US: REST business failure remains returned; strict operation rejects with original metadata (235.909125ms) +✔ en-US: timeout covers response body; result stays unknown for POST (166.557583ms) +✔ en-US: toast timer replacement, priority, safe multiline text (13.122667ms) +✔ en-US: actual mutations do not report success or close dialogs on business failures (15.560291ms) +✔ en-US: batch deletion all / partial / no success with business errors (15.960375ms) +✔ en-US: downloads propagate actual initiation failures and retain batch details (253.058958ms) +✔ en-US: file names are text; upload/verification/apply remain separate (10.768042ms) +✔ en-US: SSH actual event handler honors terminal states and diagnoses conflicts (8.635416ms) +✔ en-US: background command timeout is unconfirmed and never resubmitted (10.518541ms) +✔ en-US: terminal restoration waits for acknowledgement; timeout/failure stay distinct (8.468791ms) +✔ en-US: ping results are reachability only; failed probe is unknown and no pulse replay (8.665416ms) +✔ en-US: websocket label matches color across language changes and power warnings persist (138.89825ms) +✔ zh-CN: upload and download aggregates cover all success and all failure (10.308333ms) +✔ zh-CN: key deployment requires successful business code and distinguishes verification (6.809042ms) +✔ zh-CN: real LED/status renderers localize finite built-in identifiers (5.819834ms) +✔ en-US: upload and download aggregates cover all success and all failure (7.869583ms) +✔ en-US: key deployment requires successful business code and distinguishes verification (7.737208ms) +✔ en-US: real LED/status renderers localize finite built-in identifiers (9.644458ms) +✔ zh-CN: widget dynamic families render real labels and literal braces in names remain valid (17.066208ms) +✔ zh-CN: unknown asynchronous stop never retries or escalates (7.332125ms) +✔ en-US: widget dynamic families render real labels and literal braces in names remain valid (6.817875ms) +✔ en-US: unknown asynchronous stop never retries or escalates (8.231833ms) +✔ zh-CN: J1 power alerts preserve local line, SSH ownership, transition guards and filtering (8.2145ms) +✔ en-US: J1 power alerts preserve local line, SSH ownership, transition guards and filtering (18.915542ms) +✔ a service display rejects prior SSH output and completion (23.094541ms) +✔ fast SSH messages are replayed only for the acknowledged session (12.618542ms) +✔ late SSH admission cannot replace a newer service display (11.152625ms) +✔ late log response and tail timer cannot survive display replacement (9.099375ms) +✔ page replacement and clear do not let old messages adopt the new panel (65.607958ms) +✔ expired and replaced service operations never trigger a retry (22.77075ms) +✔ clearing during explicit verification prevents display updates (15.492959ms) +Checked 3045 references including conditional and finite dynamic families. +✔ actual UI literal/conditional and finite dynamic keys exist in both languages with matching contracts (12.258292ms) +✔ production i18n is the inline entry; no second translator script is loaded (0.139417ms) +✔ ring: value uses an HTML overlay outside the SVG and retains update IDs (223.64275ms) +✔ gauge: value uses an HTML overlay outside the SVG and retains update IDs (29.341125ms) +✔ all widget types retain rendering, layout and edit hooks in the new UI (50.723375ms) +✔ stylesheet retains migrated bounds, overlay, wrapping and responsive span contracts (0.139958ms) +ℹ tests 126 +ℹ suites 0 +ℹ pass 126 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1772.461209 diff --git a/docs/repair/delete-protection/resource-deltas.json b/docs/repair/delete-protection/resource-deltas.json new file mode 100644 index 0000000..6aee993 --- /dev/null +++ b/docs/repair/delete-protection/resource-deltas.json @@ -0,0 +1,32 @@ +[ + { + "path": "components/ts_webui/web/js/app.js", + "raw_delta": 5834, + "gzip_delta": 1580 + }, + { + "path": "components/ts_webui/web/js/api.js", + "raw_delta": 34, + "gzip_delta": 7 + }, + { + "path": "components/ts_webui/web/js/lang/zh-CN.js", + "raw_delta": 2655, + "gzip_delta": 585 + }, + { + "path": "components/ts_webui/web/js/lang/en-US.js", + "raw_delta": 2904, + "gzip_delta": 606 + }, + { + "path": "components/ts_webui/web/css/style.css", + "raw_delta": 78, + "gzip_delta": 21 + }, + { + "path": "components/ts_webui/web/index.html", + "raw_delta": 40, + "gzip_delta": 8 + } +] diff --git a/docs/repair/delete-protection/target-sizes.txt b/docs/repair/delete-protection/target-sizes.txt new file mode 100644 index 0000000..95ed668 --- /dev/null +++ b/docs/repair/delete-protection/target-sizes.txt @@ -0,0 +1,9 @@ +From final ESP32-S3 ELF DWARF using xtensa-esp32s3-elf-gdb: +ts_action_template_reference_t = 112 B +ts_action_template_delete_result_t = 20 B +ts_rule_commit_result_t = 84 B (old scalar fields 20 B, added request-local ID 64 B) +ts_action_template_t = 1224 B +ts_auto_rule_t = 240 B (persistent rule layout unchanged) +Reference details: default32 *112 =3584 B temporary PSRAM; configured max64 *112 =7168 B. +New-reference check: one template buffer1224 B temporary PSRAM; freed on all exits. +No added resident task, per-rule field, partition, or network polling. diff --git a/tests/fixtures/delete-reference/README.md b/tests/fixtures/delete-reference/README.md new file mode 100644 index 0000000..4fa4b47 --- /dev/null +++ b/tests/fixtures/delete-reference/README.md @@ -0,0 +1,7 @@ +# 生产删除/规则提交的 HTTP 字节夹具 + +由 tests/runtime/test_delete_reference.py 提取当前生产处理函数、规则提交和模板删除边界,并经过当前生产 HTTP 响应分支生成。SSH/持久化/文件删除边界是合成替身;不访问设备。夹具不是 api.call 的伪造对象。 + +正常回归比较实际字节,不改夹具。只有合同经过审查改变时,用 UPDATE_FIXTURES=1 python3 tests/runtime/test_delete_reference.py 更新。 + +in-use:包含停用、重复引用去重和默认容量32条规则;in-use-no-details:引用已确认但细节分配失败;check-loading/uninitialized/recovery:配置状态;action-missing:新引用被拒绝且未保存;boundary:名称47字节和ID63字节;service-protected:后端最终服务保护。浏览器测试直接读取这些文件原样作为 HTTP 响应。 diff --git a/tests/fixtures/delete-reference/action-missing.json b/tests/fixtures/delete-reference/action-missing.json new file mode 100644 index 0000000..cfa1d92 --- /dev/null +++ b/tests/fixtures/delete-reference/action-missing.json @@ -0,0 +1 @@ +{"code":1,"message":"action_missing","data":{"applied":false,"durable":false,"mirror_synced":false,"revision":0,"error_code":"action_missing","missing_template_id":"missing"}} diff --git a/tests/fixtures/delete-reference/boundary.json b/tests/fixtures/delete-reference/boundary.json new file mode 100644 index 0000000..8395854 --- /dev/null +++ b/tests/fixtures/delete-reference/boundary.json @@ -0,0 +1 @@ +{"code":4,"message":"action_in_use","data":{"action_id":"template","references_confirmed":true,"rules":[{"id":"IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"}],"details_available":true}} diff --git a/tests/fixtures/delete-reference/check-loading.json b/tests/fixtures/delete-reference/check-loading.json new file mode 100644 index 0000000..3905d6f --- /dev/null +++ b/tests/fixtures/delete-reference/check-loading.json @@ -0,0 +1 @@ +{"code":4,"message":"action_reference_check_unavailable","data":{"action_id":"template","check_reason":"loading"}} diff --git a/tests/fixtures/delete-reference/check-recovery.json b/tests/fixtures/delete-reference/check-recovery.json new file mode 100644 index 0000000..2a3bcaf --- /dev/null +++ b/tests/fixtures/delete-reference/check-recovery.json @@ -0,0 +1 @@ +{"code":4,"message":"action_reference_check_unavailable","data":{"action_id":"template","check_reason":"recovery"}} diff --git a/tests/fixtures/delete-reference/check-uninitialized.json b/tests/fixtures/delete-reference/check-uninitialized.json new file mode 100644 index 0000000..3905d6f --- /dev/null +++ b/tests/fixtures/delete-reference/check-uninitialized.json @@ -0,0 +1 @@ +{"code":4,"message":"action_reference_check_unavailable","data":{"action_id":"template","check_reason":"loading"}} diff --git a/tests/fixtures/delete-reference/in-use-no-details.json b/tests/fixtures/delete-reference/in-use-no-details.json new file mode 100644 index 0000000..ba017c2 --- /dev/null +++ b/tests/fixtures/delete-reference/in-use-no-details.json @@ -0,0 +1 @@ +{"code":4,"message":"action_in_use","data":{"action_id":"template","references_confirmed":true,"details_available":false}} diff --git a/tests/fixtures/delete-reference/in-use.json b/tests/fixtures/delete-reference/in-use.json new file mode 100644 index 0000000..fd19886 --- /dev/null +++ b/tests/fixtures/delete-reference/in-use.json @@ -0,0 +1 @@ +{"code":4,"message":"action_in_use","data":{"action_id":"template","references_confirmed":true,"rules":[{"id":"rule-0","name":"Model \" < & > 中文"},{"id":"rule-1","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-2","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-3","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-4","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-5","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-6","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-7","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-8","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-9","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-10","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-11","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-12","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-13","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-14","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-15","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-16","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-17","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-18","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-19","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-20","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-21","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-22","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-23","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-24","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-25","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-26","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-27","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-28","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-29","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-30","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},{"id":"rule-31","name":"NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"}],"details_available":true}} diff --git a/tests/fixtures/delete-reference/service-protected.json b/tests/fixtures/delete-reference/service-protected.json new file mode 100644 index 0000000..076fa1f --- /dev/null +++ b/tests/fixtures/delete-reference/service-protected.json @@ -0,0 +1 @@ +{"code":4,"message":"service_delete_protected","data":{"action_id":"template"}} diff --git a/tests/prompts/delete-protection.browser.test.cjs b/tests/prompts/delete-protection.browser.test.cjs index 3c62911..de838d1 100644 --- a/tests/prompts/delete-protection.browser.test.cjs +++ b/tests/prompts/delete-protection.browser.test.cjs @@ -32,13 +32,24 @@ for(const entry of ['action','command'])for(const state of ['stopped','ready'])t }); try{ await page.goto(base);await page.waitForFunction(()=>i18n.isReady()&&typeof deleteAction==='function'); - await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};refreshCommandsList=()=>{};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); - await page.evaluate(entry=>entry==='action'?deleteAction('template'):deleteCommand(0),entry); + await page.evaluate(()=>{confirmAction=async()=>true;refreshActions=async()=>{};window.commandRefreshes=0;refreshCommandsList=()=>{window.commandRefreshes++;};window.deleteNavigationCalls=0;const navigate=router.navigate.bind(router);router.navigate=(...args)=>{window.deleteNavigationCalls++;return navigate(...args);};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model'}]};}); + const deletion=page.evaluate(entry=>entry==='action'?deleteAction('template'):deleteCommand(0),entry); + if(state==='ready'){ + await page.locator('.confirm-sheet').waitFor();assert((await page.locator('.confirm-sheet').textContent()).includes('仍在运行')); + if(entry==='command'){ + assert.equal(await page.locator('.confirm-sheet button').count(),1); + assert.equal(await page.locator('.confirm-sheet button').textContent(),await page.evaluate(()=>t('common.close'))); + const before=await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})); + await page.locator('.confirm-sheet button').click();await deletion; + assert.deepEqual(await page.evaluate(()=>({nav:window.deleteNavigationCalls,refresh:window.commandRefreshes,hash:location.hash})),before); + }else await page.keyboard.press('Escape'); + } + await deletion; const deletes=calls.filter(c=>c.endpoint===('action'===entry?'automation/actions/delete':'ssh/commands/remove')); assert.equal(deletes.length,state==='stopped'?1:0); const verify=calls.find(c=>c.endpoint==='automation/services/status'&&c.method==='POST');assert.equal(verify.params.verify,true);assert.equal(verify.params.command_id,'model'); assert(!calls.some(c=>c.endpoint==='automation/services/stop'||c.endpoint==='ssh/services/start')); - if(state==='ready')assert((await page.locator('#toast').textContent()).includes('尚未确认停止')); + if(state==='ready')assert.equal(await page.locator('.confirm-sheet').count(),0); }finally{await context.close();} }); for(const entry of ['action','command'])test(`${entry}: delayed verification cannot overwrite a newer real start`,async()=>{ diff --git a/tests/prompts/delete-protection.test.cjs b/tests/prompts/delete-protection.test.cjs index b335b01..46e158f 100644 --- a/tests/prompts/delete-protection.test.cjs +++ b/tests/prompts/delete-protection.test.cjs @@ -14,7 +14,8 @@ for(const lang of ['zh-CN','en-US'])test(`${lang}: protected delete explains ser }); async function deleteFixture(){ const h=harness();await h.ready();h.load();h.el('page-content'); - h.ctx.confirmSheet=async()=>true; + h.ctx.confirmSheet=async options=>{h.ctx.lastSheet=options;return false;}; + h.ctx.confirmAction=async()=>true; h.run(`refreshActions=async()=>{};refreshCommandsList=()=>{};selectedHostId='host';sshCommands={host:[{id:'model',name:'Model',nohup:true,serviceMode:true}]};`); return h; } @@ -44,7 +45,9 @@ for(const entry of ['action','command']){ for(const state of ['running','ready','unknown'])test(`${entry}: ${state} never leads to delete or stop`,async()=>{ const h=await deleteFixture(),calls=responses(h,{state});await invoke(h); assert(!calls.some(c=>c.name.endsWith('.delete')||c.name.endsWith('.remove')||c.name.endsWith('.stop'))); - assert(h.el('toast').textContent.includes(h.ctx.t('promptRepair.serviceDeleteProtected'))); + assert(h.ctx.lastSheet.bodyHtml.includes(h.ctx.t('deleteProtection.'+(state==='unknown'?'unconfirmed':'running'),{command:'model'}))); + assert.equal(h.ctx.lastSheet.primary,h.ctx.t(entry==='command'?'common.close':'deleteProtection.viewCommand')); + assert.equal(h.ctx.lastSheet.secondary,entry==='command'?false:h.ctx.t('common.close')); }); test(`${entry}: failed verification never deletes`,async()=>{ const h=await deleteFixture(),calls=responses(h,{phase:'failed'});await invoke(h); diff --git a/tests/prompts/delete-reference.browser.test.cjs b/tests/prompts/delete-reference.browser.test.cjs new file mode 100644 index 0000000..fb5256a --- /dev/null +++ b/tests/prompts/delete-reference.browser.test.cjs @@ -0,0 +1,148 @@ +// Replay exact production HTTP bytes; do not replace api.call or the deletion handlers. +const {test,before,after}=require('node:test'); +const assert=require('node:assert/strict'); +const http=require('node:http'),fs=require('node:fs'),path=require('node:path'); +const {chromium}=require('playwright');const {root}=require('./harness.cjs'); +const fixture=key=>fs.readFileSync(path.resolve(__dirname,'../fixtures/delete-reference',key+'.json'),'utf8'); +let browser; +before(async()=>{browser=await chromium.launch({channel:'chrome',headless:true});}); +after(async()=>{await browser?.close();}); +const name='Model “quoted” < & > 中文'; +async function pageFor(language,width,run,level='root'){ + const state={templateName:name,wire:'in-use',service:false,serviceState:'stopped',rules:[],loaded:true,recovery:false,level,saveError:null},calls=[],errors=[]; + const server=http.createServer((req,res)=>{ + const url=new URL(req.url,'http://local'); + const send=body=>{res.setHeader('Content-Type','application/json');res.end(typeof body==='string'?body:JSON.stringify(body));}; + if(url.pathname.startsWith('/api/')){ + let text='';req.on('data',c=>text+=c);req.on('end',()=>{ + const endpoint=url.pathname.slice('/api/v1/'.length);calls.push({endpoint,method:req.method,body:text}); + if(endpoint==='automation/actions/delete')return send(fixture(state.wire)); + if(endpoint==='automation/rules/add')return send(state.saveError?{code:4,message:state.saveError,data:{error_code:state.saveError}}:fixture('action-missing')); + let data={}; + if(endpoint==='auth/status')data={valid:true,level:state.level,username:state.level}; + if(endpoint==='automation/actions/list')data={templates:[{id:'template',name:state.templateName,type:'log'},{id:'missing',name:'Selected human-readable model',type:'log'}]}; + if(endpoint==='automation/actions/get')data={id:'template',name:state.templateName,type:state.service?'ssh_cmd_ref':'log',ssh_ref:{cmd_id:'model'}}; + if(endpoint==='ssh/commands/get')data={id:'model',name:'Command < & >',nohup:true,serviceMode:true}; + if(endpoint==='automation/services/status')data=req.method==='POST'?{operation_id:31}:{operation_id:31,operation_phase:'succeeded',state:state.serviceState,busy:false}; + if(endpoint==='automation/rules/list')data={loaded:state.loaded,recovery_required:state.recovery,rules:state.rules}; + if(endpoint==='automation/sources/list')data={sources:[]}; + if(endpoint==='ssh/hosts/list')data={hosts:[]}; + if(endpoint==='ssh/commands/list')data={commands:[]}; + return send({code:0,data}); + });return; + } + const file=path.resolve(root,'.'+(url.pathname==='/'?'/index.html':url.pathname)); + if(!file.startsWith(root+'/'))return res.writeHead(403).end(); + fs.readFile(file,(error,body)=>{if(error)return res.writeHead(404).end();res.setHeader('Content-Type',({'.html':'text/html','.js':'application/javascript','.css':'text/css','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');res.end(body);}); + }); + await new Promise(r=>server.listen(0,'127.0.0.1',r));const base=`http://127.0.0.1:${server.address().port}`; + const context=await browser.newContext({viewport:{width,height:900},locale:language}); + await context.addInitScript(({lang,level})=>{localStorage.setItem('ts_language',lang);localStorage.setItem('ts_level',level);localStorage.setItem('ts_token','fixture');localStorage.setItem('ts_username',level);class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}}window.WebSocket=Socket;},{lang:language,level}); + const page=await context.newPage();page.setDefaultTimeout(5000);page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/*',async route=>{if(new URL(route.request().url()).origin!==base)return route.abort();return route.continue();}); + try{await page.goto(base);await page.waitForFunction(()=>i18n.isReady());await page.evaluate(root=>{closeLoginModal();if(root)location.hash='/automation';},level==='root');await page.waitForSelector(level==='root'?'#actions-list button[onclick]':'#quick-actions-grid');await run({page,state,calls});assert.deepEqual(errors,[]);} + finally{await context.close();await new Promise(r=>server.close(r));} +} +const confirm=async page=>{await page.locator('.confirm-sheet').waitFor();await page.locator('.confirm-sheet button[data-r="1"]').click();}; +const attempt=async page=>{await page.locator('button[onclick="deleteAction(\'template\')"]').click();await confirm(page);}; +const waitTitle=(page,key)=>page.waitForFunction(key=>document.querySelector('.confirm-sheet .st')?.textContent===t(key),key); +for(const language of ['zh-CN','en-US'])test(`${language}: navigation buttons open existing pages without positioning or deletion`,async()=>pageFor(language,390,async({page,state,calls})=>{ + const before=calls.filter(c=>c.endpoint==='automation/actions/list').length; + await attempt(page);await waitTitle(page,'deleteProtection.actionTitle');await page.keyboard.press('Enter'); + await page.waitForFunction(()=>!document.querySelector('.confirm-sheet'));await page.waitForSelector('#actions-list button[onclick]'); + assert(calls.filter(c=>c.endpoint==='automation/actions/list').length>before);assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1); + state.service=true;state.serviceState='ready';await attempt(page);await page.waitForFunction(()=>document.querySelector('.confirm-sheet .st')?.textContent.includes('quoted')); + await page.keyboard.press('Enter');await page.waitForFunction(()=>location.hash==='#/commands');await page.waitForFunction(()=>document.querySelector('.nav a[href="#/commands"]')?.classList.contains('active')); + assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1);assert(!calls.some(c=>c.endpoint==='automation/services/stop')); +})); +for(const language of ['zh-CN','en-US'])for(const width of [390,1440]){ + test(`${language} ${width}: 32 production references, escaping, design, close and navigation`,async()=>pageFor(language,width,async({page,calls})=>{ + await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + const sheet=page.locator('.confirm-sheet');assert.equal(await sheet.locator('.grp .row').count(),32); + assert.equal(await sheet.locator('.grp img,.grp script').count(),0);assert((await sheet.textContent()).includes('中文')); + const style=await sheet.locator('.sheet').evaluate(e=>{const s=getComputedStyle(e),title=getComputedStyle(e.querySelector('.st')),body=getComputedStyle(e.querySelector('.t-body')),r=e.getBoundingClientRect();return {radius:s.borderRadius,padding:s.padding,weight:title.fontWeight,title:title.fontSize,body:body.fontSize,width:r.width,right:r.right,inner:e.scrollWidth,client:e.clientWidth};}); + const children=await sheet.locator('.grp .row,.grp .rl,.grp .rc').evaluateAll(es=>es.map(e=>({scroll:e.scrollWidth,client:e.clientWidth,right:e.getBoundingClientRect().right,parent:e.parentElement.getBoundingClientRect().right}))); + assert(children.every(e=>e.scroll<=e.client+1&&e.right<=e.parent+1),JSON.stringify(children)); + assert.equal(await page.locator('#toast.show.toast-info').count(),0); + assert.equal(await page.locator('#toast').isVisible(),false); + assert.equal(style.title,'20px');assert.equal(style.body,'14px');assert.equal(style.weight,'600');assert(style.right<=width+1);assert(style.inner<=style.client+1); + assert.equal(style.radius,width===390?'32px 32px 0px 0px':'32px'); + if(process.env.DELETE_SCREENSHOTS==='1'){const out=path.resolve(__dirname,'../../output/playwright/delete-protection');fs.mkdirSync(out,{recursive:true});await page.screenshot({path:path.join(out,language+'-'+width+'.png')});fs.writeFileSync(path.join(out,language+'-'+width+'-style.json'),JSON.stringify(style,null,2));} + await page.keyboard.press('Escape');assert.equal(await sheet.count(),0);assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1); + await attempt(page);await waitTitle(page,'deleteProtection.actionTitle');await page.keyboard.press('Enter');await page.waitForFunction(()=>location.hash==='#/automation'); + assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,2); + })); + test(`${language} ${width}: 47-byte label and 63-byte ID remain readable`,async()=>pageFor(language,width,async({page,state})=>{ + state.wire='boundary';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + assert.equal((await page.locator('.confirm-sheet .grp .rc').textContent()).length,63);assert.equal((await page.locator('.confirm-sheet .grp .rl').textContent()).length,47); + const fields=await page.locator('.confirm-sheet .grp .rl,.confirm-sheet .grp .rc').evaluateAll(es=>es.map(e=>[e.scrollWidth,e.clientWidth]));assert(fields.every(([scroll,width])=>scroll<=width+1)); + const bounds=await page.locator('.confirm-sheet .sheet').evaluate(e=>[e.scrollWidth,e.clientWidth]);assert(bounds[0]<=bounds[1]+1);await page.keyboard.press('Escape'); + })); + test(`${language} ${width}: service-blocked 47-byte template title does not clip`,async()=>pageFor(language,width,async({page,state})=>{ + state.service=true;state.serviceState='ready';state.templateName='N'.repeat(47);await page.evaluate(()=>refreshActions());await attempt(page); + await page.waitForFunction(()=>document.querySelector('.confirm-sheet .st')?.textContent.includes('NNNNNNNN')); + const boxes=await page.locator('.confirm-sheet .st,.confirm-sheet .t-body').evaluateAll(es=>es.map(e=>({scroll:e.scrollWidth,width:e.clientWidth,right:e.getBoundingClientRect().right,parent:e.parentElement.getBoundingClientRect().right}))); + assert(boxes.every(e=>e.scroll<=e.width+1&&e.right<=e.parent+1),JSON.stringify(boxes));await page.keyboard.press('Escape'); + })); + test(`${language} ${width}: C group has only Close; missing list remains blocked`,async()=>pageFor(language,width,async({page,state,calls})=>{ + state.wire='check-loading';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle');assert.equal(await page.locator('.confirm-sheet button').count(),1); + const text=await page.locator('.confirm-sheet').textContent();assert(text.includes(await page.evaluate(()=>t('deleteProtection.checkLoading')))); + await page.keyboard.press('Enter');assert.equal(await page.locator('.confirm-sheet').count(),0); + state.wire='in-use-no-details';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + assert((await page.locator('.confirm-sheet').textContent()).includes(await page.evaluate(name=>t('deleteProtection.actionInUseWithoutDetails',{name}),name))); + await page.locator('.confirm-sheet button[data-r="0"]').click();assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,2); + })); + test(`${language} ${width}: successive service and reference blockers have no stale sheet`,async()=>pageFor(language,width,async({page,state,calls})=>{ + state.service=true;state.serviceState='ready';await attempt(page); + await page.waitForFunction(()=>document.querySelector('.confirm-sheet .st')?.textContent.startsWith(t('deleteProtection.templateTitle',{name:'Model'}).split('“')[0])); + assert((await page.locator('.confirm-sheet').textContent()).includes('Command < & >'));assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,0); + await page.locator('.confirm-sheet button[data-r="0"]').click();state.serviceState='stopped';await attempt(page);await waitTitle(page,'deleteProtection.actionTitle'); + assert.equal(await page.locator('.confirm-sheet').count(),1);assert.equal(calls.filter(c=>c.endpoint==='automation/actions/delete').length,1); + await page.keyboard.press('Escape'); + })); + test(`${language} ${width}: homepage empty/loading/recovery retains grid and entry`,async()=>pageFor(language,width,async({page,state})=>{ + await page.evaluate(()=>{document.getElementById('page-content').innerHTML='
';}); + for(const scenario of ['empty','loading','recovery']){ + state.loaded=scenario==='empty';state.recovery=scenario==='recovery';await page.evaluate(()=>refreshQuickActions()); + const grid=page.locator('#quick-actions-grid');assert.equal(await grid.count(),1);assert.equal(await grid.locator('button').count(),1); + const key=scenario==='empty'?'automationPage.noQuickActions':scenario==='loading'?'deleteProtection.quickLoadingTitle':'deleteProtection.quickRecoveryTitle'; + assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),key))); + if(scenario==='loading')assert((await grid.textContent()).includes(await page.evaluate(()=>t('deleteProtection.loadingDetails')))); + if(scenario==='recovery'){assert((await grid.textContent()).includes(await page.evaluate(()=>runtimeText('recovery_required'))));assert((await grid.textContent()).includes(await page.evaluate(()=>t('deleteProtection.recoveryDetails'))));} + const bodyKey=scenario==='empty'?'automationPage.quickActionsHint':scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required'; + assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),bodyKey))); + } + })); + test(`${language} ${width}: stale selection failure names the chosen option and retains draft`,async()=>pageFor(language,width,async({page,calls})=>{ + await page.evaluate(async()=>{showAddRuleModal();await addActionTemplateRow('missing');}); + await page.locator('#rule-id').fill('new-rule');await page.locator('#rule-name').fill('Draft remains');await page.locator('#rule-manual-only').check(); + await page.locator('#add-rule-modal button[onclick="submitAddRule()"] ').click(); + await page.waitForFunction(()=>document.getElementById('toast')?.textContent.includes('Selected human-readable model')); + assert.equal(await page.locator('#rule-name').inputValue(),'Draft remains');assert.equal(await page.locator('#add-rule-modal').count(),1);assert.equal(calls.filter(c=>c.endpoint==='automation/rules/add').length,1); + })); +} + +// One Chinese admin scene: both auth status and initial storage use the same non-root level. +test('zh-CN: non-root homepage has approved guidance and no management entry',async()=>pageFor('zh-CN',390,async({page,state})=>{ + assert.equal(await page.evaluate(()=>api.getLevel()),'admin');assert.equal(await page.evaluate(()=>api.isRoot()),false); + for(const scenario of ['empty','loading','recovery']){ + state.loaded=scenario==='empty';state.recovery=scenario==='recovery';await page.evaluate(()=>refreshQuickActions()); + const grid=page.locator('#quick-actions-grid');assert.equal(await grid.count(),1);assert.equal(await grid.locator('button').count(),0); + const titleKey=scenario==='empty'?'automationPage.noQuickActions':scenario==='loading'?'deleteProtection.quickLoadingTitle':'deleteProtection.quickRecoveryTitle'; + assert.equal(await grid.locator('.t-body').textContent(),await page.evaluate(k=>t(k),titleKey)); + const newKey={empty:'deleteProtection.quickEmptyNeedRoot',loading:'deleteProtection.quickLoadingNeedRoot',recovery:'deleteProtection.quickRecoveryNeedRoot'}[scenario]; + assert((await grid.textContent()).includes(await page.evaluate(k=>t(k),newKey))); + if(scenario!=='empty')assert.equal(await grid.locator('.t-note').first().textContent(),await page.evaluate(k=>t(k),scenario==='loading'?'runtimeRepair.configLoading':'runtimeRepair.recovery_required')); + for(const oldKey of ['deleteProtection.loadingDetails','deleteProtection.recoveryDetails','automationPage.quickActionsHint']) + assert(!(await grid.textContent()).includes(await page.evaluate(k=>t(k),oldKey))); + } +},'admin')); +test('zh-CN: template lookup failure uses saveFailed and retains the actual draft',async()=>pageFor('zh-CN',390,async({page,state,calls})=>{ + state.saveError='template_lookup_failed'; + await page.evaluate(async()=>{showAddRuleModal();await addActionTemplateRow('missing');}); + await page.locator('#rule-id').fill('new-rule');await page.locator('#rule-name').fill('Lookup draft remains');await page.locator('#rule-manual-only').check(); + await page.locator('#add-rule-modal button[onclick="submitAddRule()"] ').click(); + await page.waitForFunction(()=>document.getElementById('toast')?.textContent===t('runtimeRepair.saveFailed')); + assert.equal(await page.locator('#rule-name').inputValue(),'Lookup draft remains');assert.equal(await page.locator('#add-rule-modal').count(),1); + assert.equal(calls.filter(c=>c.endpoint==='automation/rules/add').length,1); +})); diff --git a/tests/prompts/delete-reference.test.cjs b/tests/prompts/delete-reference.test.cjs new file mode 100644 index 0000000..cc08b1b --- /dev/null +++ b/tests/prompts/delete-reference.test.cjs @@ -0,0 +1,28 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {harness}=require('./harness.cjs'); +for(const language of ['zh-CN','en-US'])test(`${language}: H15 nine states, busy and operation kind select an approved explanation`,async()=>{ + const h=harness(language);await h.ready();h.load(); + const states={unknown:'unconfirmed',running:'running',ready:'running',checking:'running',starting:'starting',stopping:'stopping',stopped:null,failed:'unconfirmed',timeout:'unconfirmed'}; + for(const [state,expected] of Object.entries(states))for(const busy of [false,true])for(const kind of ['start','stop','verify','other'])for(const phase of ['succeeded','queued','executing']){ + const pending=busy||phase!=='succeeded';const operation={start:'starting',stop:'stopping',verify:'verifying'}; + const want=pending?(operation[kind]||'operationPending'):expected; + const got=h.ctx.deleteServiceMessage({state,busy,operation_kind:kind,operation_phase:phase}); + assert.equal(got,want,JSON.stringify({state,busy,kind,phase})); + if(got){const text=h.ctx.t('deleteProtection.'+got,{command:'Model'});assert(text&&!text.includes('deleteProtection.'));} + } + assert.equal(h.ctx.deleteServiceMessage({state:'future',busy:false}),'unconfirmed'); + assert.equal(h.ctx.deleteServiceMessage({busy:false}),'unconfirmed'); +}); +for(const language of ['zh-CN','en-US'])test(`${language}: approved keys, placeholders and missing-template display label`,async()=>{ + const h=harness(language);await h.ready();h.load(); + const missing=h.ctx.runtimeSaveError?h.ctx.runtimeSaveError({data:{error_code:'action_missing',missing_template_id:'missing'},missingTemplateName:'Human label'}):h.run("runtimeSaveError({data:{error_code:'action_missing',missing_template_id:'missing'},missingTemplateName:'Human label'})"); + assert(missing.includes('Human label')&&!missing.includes('action_missing')); + assert.equal(h.ctx.runtimeSaveError({rawMessage:'action_missing'}),h.ctx.runtimeText('saveFailed')); + assert.equal(h.ctx.runtimeSaveError({data:{error_code:'template_lookup_failed'}}),h.ctx.runtimeText('saveFailed')); + let sheet;h.ctx.confirmSheet=async options=>{sheet=options;return false;}; + await h.ctx.showDeleteProtection({code:4,message:'action_in_use'}, {type:'action',name:'Model'},()=>true); + assert(sheet.bodyHtml.includes(h.ctx.escapeHtml(h.ctx.t('deleteProtection.actionInUseWithoutDetails',{name:'Model'})))); + await h.ctx.showDeleteProtection({code:4,message:'action_reference_check_unavailable',data:{check_reason:'loading'}}, {type:'action',name:'Model'},()=>true); + assert.equal(sheet.secondary,false);assert.equal(sheet.primary,h.ctx.t('common.close')); +}); diff --git a/tests/prompts/package.json b/tests/prompts/package.json index 1f5604a..79f4a4a 100644 --- a/tests/prompts/package.json +++ b/tests/prompts/package.json @@ -5,8 +5,8 @@ "node": ">=22" }, "scripts": { - "test": "cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs", - "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs", + "test": "cd ../.. && node --test tests/prompts/static.test.cjs tests/prompts/regression.test.cjs tests/prompts/service-ownership.test.cjs tests/prompts/widgets-structure.test.cjs tests/prompts/delete-protection.test.cjs tests/prompts/key-generation.test.cjs tests/prompts/delete-reference.test.cjs", + "test:browser": "cd ../.. && node --test tests/prompts/browser.test.cjs tests/prompts/ownership.browser.test.cjs tests/prompts/widgets-layout.test.cjs tests/prompts/delete-protection.browser.test.cjs tests/prompts/condition-editor.browser.test.cjs tests/prompts/input-repair.browser.test.cjs tests/prompts/ssh-copyid-cache.browser.test.cjs tests/prompts/key-generation.browser.test.cjs tests/prompts/key-generation-cache.browser.test.cjs tests/prompts/lpmu-lifecycle.browser.test.cjs tests/prompts/delete-reference.browser.test.cjs", "test:xterm": "cd ../.. && node --test tests/prompts/xterm-vendor.test.cjs tests/prompts/xterm-offline.test.cjs" }, "devDependencies": { diff --git a/tests/prompts/static.test.cjs b/tests/prompts/static.test.cjs index 3a29c34..193b82a 100644 --- a/tests/prompts/static.test.cjs +++ b/tests/prompts/static.test.cjs @@ -11,7 +11,7 @@ const html=read('index.html'); for(const f of ['index.html','js/app.js','js/terminal.js'])for(const m of read(f).matchAll(/data-i18n(?:-title|-placeholder)?="([^"]+)"/g))if(!m[1].includes('${'))calls.push({f,key:m[1]}); for(const m of read('js/app.js').matchAll(/(?:labelKey|descKey): '([^']+)'/g))calls.push({f:'dynamic widget option',key:m[1]}); // Explicit finite dynamic families; unknown names use neutral runtime fallbacks. -for(const family of ['promptRepair','runtimeRepair'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); +for(const family of ['promptRepair','runtimeRepair','deleteProtection'])for(const key of Object.keys(langs['zh-CN'][family]))calls.push({f:'dynamic '+family,key:family+'.'+key,dynamic:true}); test('actual UI literal/conditional and finite dynamic keys exist in both languages with matching contracts',()=>{ let failures=[]; for(const c of calls){const a=lookup('zh-CN',c.key),b=lookup('en-US',c.key); diff --git a/tests/runtime/extract_engine.py b/tests/runtime/extract_engine.py index 5cf8426..ec46fd2 100644 --- a/tests/runtime/extract_engine.py +++ b/tests/runtime/extract_engine.py @@ -3,7 +3,7 @@ s=Path('components/ts_automation/src/ts_rule_engine.c').read_text() a=s.index('typedef struct {\n ts_auto_rule_t *rules;');b=s.index('static void payload_free',a) parts=[s[a:b]] -for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','validate_new_template_refs','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name end=s.index('\n}',m.start())+2;parts.append(s[m.start():end]) Path('/tmp/tianshan-runtime-tests/engine.inc').write_text('\n'.join(parts)) diff --git a/tests/runtime/run_all.sh b/tests/runtime/run_all.sh index cba25f4..3da2949 100755 --- a/tests/runtime/run_all.sh +++ b/tests/runtime/run_all.sh @@ -9,6 +9,7 @@ python3 tests/runtime/test_completion.py python3 tests/runtime/test_action_submission.py python3 tests/runtime/test_service_control.py python3 tests/runtime/test_delete_api.py +python3 tests/runtime/test_delete_reference.py python3 tests/runtime/test_condition_roundtrip.py python3 tests/runtime/test_expansion.py python3 tests/runtime/test_configuration_protocol.py diff --git a/tests/runtime/test_delete_api.py b/tests/runtime/test_delete_api.py index 8ab768c..9b1a6a1 100644 --- a/tests/runtime/test_delete_api.py +++ b/tests/runtime/test_delete_api.py @@ -21,6 +21,7 @@ def handler(path, name): #include #include #include "ts_api.h" +#include "ts_action_manager.h" #define ACTIONS_SDCARD_DIR "/sdcard/config/actions" #define TAG "test" #define TS_LOGI(...) ((void)0) @@ -29,6 +30,10 @@ def handler(path, name): static int test_unlink(const char *path) { assert(strstr(path, "/fixture.")); ++unlinks; return 0; } #define unlink test_unlink esp_err_t ts_action_template_remove(const char *id) { assert(!strcmp(id,"fixture")); ++remove_calls; return outcome; } +esp_err_t ts_action_template_remove_checked(const char *id,ts_action_template_delete_result_t *detail){ + *detail=(ts_action_template_delete_result_t){0};if(outcome==ESP_ERR_INVALID_STATE)detail->reason="service_delete_protected"; + return ts_action_template_remove(id); +} esp_err_t ts_ssh_commands_config_remove(const char *id) { return ts_action_template_remove(id); } void ts_api_result_ok(ts_api_result_t *result, cJSON *data) { result->code=TS_API_OK; result->data=data; } void ts_api_result_error(ts_api_result_t *result, ts_api_result_code_t code, const char *message) { result->code=code; result->message=strdup(message); } @@ -56,5 +61,5 @@ def handler(path, name): source=Path(tmp)/'delete.c';binary=Path(tmp)/'delete' source.write_text(code) env=dict(os.environ,DEVELOPER_DIR=os.environ.get('DEVELOPER_DIR','/Library/Developer/CommandLineTools')) - subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations','-Itests/runtime/stubs','-Itests/certificate/stubs','-Icomponents/ts_api/include','-I'+str(idf/'components/json/cJSON'),str(source),str(idf/'components/json/cJSON/cJSON.c'),'-lm','-o',str(binary)],check=True,env=env) + subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations','-Itests/runtime/stubs','-Itests/certificate/stubs','-Icomponents/ts_api/include','-Icomponents/ts_automation/include','-Icomponents/ts_security/include','-I'+str(idf/'components/json/cJSON'),str(source),str(idf/'components/json/cJSON/cJSON.c'),'-lm','-o',str(binary)],check=True,env=env) subprocess.run([str(binary)],check=True,env=env) diff --git a/tests/runtime/test_delete_reference.py b/tests/runtime/test_delete_reference.py new file mode 100644 index 0000000..c575130 --- /dev/null +++ b/tests/runtime/test_delete_reference.py @@ -0,0 +1,156 @@ +"""Production deletion/commit gates and HTTP bytes; synthetic storage, no device access.""" +from pathlib import Path +import json, os, re, subprocess, tempfile +root=Path(__file__).resolve().parents[2] +os.chdir(root) +idf=Path(os.environ.get('IDF_PATH','/Users/massif/esp/v5.5.2/esp-idf')) +def extract(file,name): + s=Path(file).read_text();m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name + return s[m.start():s.index('\n}',m.start())+2]+'\n' +engine=Path('components/ts_automation/src/ts_rule_engine.c').read_text() +a=engine.index('typedef struct {\n ts_auto_rule_t *rules;');b=engine.index('static void payload_free',a) +parts=[engine[a:b]] +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','validate_new_template_refs','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index','ts_rule_count','ts_rule_config_status']: + parts.append(extract('components/ts_automation/src/ts_rule_engine.c',name)) +prefix=Path('tests/runtime/test_engine.c').read_text().split('int main(void)')[0] +prefix=prefix.replace('#include "../../components/ts_automation/src/ts_rule_store.h"', '#include "'+str(root/'components/ts_automation/src/ts_rule_store.h')+'"') +prefix=prefix.replace('#define CONFIG_TS_AUTOMATION_MAX_RULES 4','#define CONFIG_TS_AUTOMATION_MAX_RULES 32') +prefix=prefix.replace('#include "engine.inc"','\n'.join(parts)) +prefix=prefix.replace('++commits;*result=', '++commits;barrier(3);*result=') +prefix=prefix.replace('if(template_missing)return ESP_ERR_NOT_FOUND;', 'if(template_missing || !strcmp(id,"missing"))return ESP_ERR_NOT_FOUND;') +code=prefix+r''' +#include "ts_api.h" +#define TS_STRDUP_PSRAM strdup +#define TS_LOGI(...) ((void)0) +#define ESP_LOGE(...) ((void)0) +static const char *TAG="audit"; +static int removals,unlinks; +static bool service_protected; +bool ts_rule_edit_begin(void){return s_rule_ctx.initialized&&pthread_mutex_trylock(s_rule_ctx.transaction)==0;} +void ts_rule_edit_end(void){pthread_mutex_unlock(s_rule_ctx.transaction);} +static bool template_binding_protected(const char *id,const ts_action_template_t *next){return service_protected;} +static esp_err_t template_remove_impl(const char *id){++removals;template_missing=true;barrier(4);return ESP_OK;} +''' +for name in ['template_references','ts_action_template_remove_checked','ts_action_template_remove']: + code+=extract('components/ts_automation/src/ts_action_manager.c',name) +code+=r''' +#define ACTIONS_SDCARD_DIR "/sdcard/config/actions" +static int fake_unlink(const char *path){assert(strstr(path,ACTIONS_SDCARD_DIR));++unlinks;return 0;} +#define unlink fake_unlink +''' +code+=extract('components/ts_api/src/ts_api_automation.c','api_automation_actions_delete') +code+=extract('components/ts_api/src/ts_api_automation.c','rule_commit_reply') +for name in ['ts_api_result_free','ts_api_result_ok','ts_api_result_error']: + code+=extract('components/ts_api/src/ts_api.c',name) +web=Path('components/ts_webui/src/ts_webui_api.c').read_text();a=web.index(' if (ret == ESP_OK || result.code == TS_API_OK) {');b=web.index('\n}\n',a) +code+=r''' +static char *http_body; +static esp_err_t ts_http_send_json(void *req,int status,const char *body){assert(status==200);free(http_body);http_body=strdup(body);return ESP_OK;} +static esp_err_t ts_http_send_error(void *req,int status,const char *message){return ESP_FAIL;} +static esp_err_t send_result(esp_err_t ret,ts_api_result_t result){void *req=NULL; +'''+web[a:b]+'\n}\n' +code+=r''' +static const char *out_dir; +static void capture(const char *name,ts_api_result_t result){ + assert(send_result(ESP_OK,result)==ESP_OK);cJSON *wire=cJSON_Parse(http_body);assert(wire);cJSON_Delete(wire); + char path[512];snprintf(path,sizeof(path),"%s/%s.json",out_dir,name);FILE*f=fopen(path,"w");assert(f);fputs(http_body,f);fputc('\n',f);fclose(f); +} +static void add(const char *id,const char *name,bool enabled,bool duplicate){ + ts_auto_rule_t r=candidate("fixture");strcpy(r.id,id);strcpy(r.name,name);r.enabled=enabled; + strcpy(r.actions[0].template_id,"template"); + if(duplicate){r.actions=realloc(r.actions,2*sizeof(*r.actions));r.actions[1]=r.actions[0];r.action_count=2;} + ts_rule_commit_result_t result;assert(ts_rule_commit(&r,id,0,&result)==ESP_OK);ts_rule_dispose(&r); +} +static void clear_rules(void){for(int i=0;i 中文";add("rule-0",label,false,true); + char id[64],name[48];memset(name,'N',47);name[47]=0; + for(int i=1;i<32;i++){snprintf(id,sizeof(id),"rule-%d",i);add(id,name,i%2,false);} + ts_action_template_delete_result_t d;int before=commits; + assert(ts_action_template_remove_checked("template",&d)==ESP_ERR_INVALID_STATE&&d.references_confirmed&&d.rule_count==32&&d.rules); + assert(!removals&&!unlinks&&commits==before);for(int i=0;i<32;i++){assert(!strcmp(d.rules[i].id,s_rule_ctx.rules[i].id));assert(!((rule_payload_t*)s_rule_ctx.rules[i].lease)->refs);}free(d.rules); + delete_api("in-use");assert(!removals&&!unlinks); + fail_alloc=0;delete_api("in-use-no-details");fail_alloc=-1;assert(!removals&&!unlinks); + // H14: no waiting, actual transaction contention and distinct loaded flag. + pthread_t worker;pthread_mutex_lock(&txn);s_rule_ctx.loaded=false;pthread_create(&worker,NULL,try_delete,NULL);pthread_join(worker,NULL);pthread_mutex_unlock(&txn); + delete_api("check-loading");s_rule_ctx.loaded=true; + pthread_mutex_lock(&txn);pthread_create(&worker,NULL,try_delete,NULL);pthread_join(worker,NULL);pthread_mutex_unlock(&txn); + s_rule_ctx.initialized=false;delete_api("check-uninitialized");s_rule_ctx.initialized=true; + s_rule_ctx.recovery_error=true;delete_api("check-recovery");s_rule_ctx.recovery_error=false; + // H03/H04 deletion first -> a new write cannot use the deleted template. + clear_rules();delete_api(NULL);assert(removals==1&&unlinks==2); + ts_auto_rule_t r=candidate("valid");strcpy(r.actions[0].template_id,"template");ts_rule_commit_result_t result; + before=commits;assert(ts_rule_commit(&r,r.id,0,&result)==ESP_ERR_NOT_FOUND&&!strcmp(result.error_code,"action_missing"));assert(!commits||commits==before);assert(s_rule_ctx.count==0);ts_rule_dispose(&r); + // H13: denied update clears committing; the previous valid rule still triggers and saves. + template_missing=false;r=candidate("valid");assert(ts_rule_commit(&r,r.id,0,&result)==ESP_OK);uint32_t revision=s_rule_ctx.rules[0].revision; + strcpy(r.actions[0].template_id,"missing");before=commits; + assert(ts_rule_commit(&r,r.id,revision,&result)==ESP_ERR_NOT_FOUND&&commits==before&&!s_rule_ctx.meta[0].committing); + assert(!strcmp(result.missing_template_id,"missing")&&s_rule_ctx.rules[0].revision==revision); + ts_api_result_t reply={0};rule_commit_reply(&reply,ESP_ERR_NOT_FOUND,&result);capture("action-missing",reply); + bool triggered=false;assert(execute_rule(r.id,true,&triggered)==ESP_OK&&triggered); + r.actions[0].template_id[0]=0;strcpy(r.name,"after failure");assert(ts_rule_commit(&r,r.id,revision,&result)==ESP_OK); + r.enabled=false;assert(ts_rule_commit(&r,r.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK);r.enabled=true;assert(ts_rule_commit(&r,r.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); + ts_rule_dispose(&r);clear_rules(); + // H09: old/offline missing binding may remain; multiplicity cannot increase. + template_missing=false;add("historic","Historic",true,false);template_missing=true; + assert(ts_rule_acquire("historic",&r)==ESP_OK);strcpy(r.name,"Rename historic");revision=r.revision;ts_rule_release(&r); + // Re-acquire a valid owned copy through codec, rather than retaining a released lease. + cJSON*j=ts_rule_encode(&s_rule_ctx.rules[0]);assert(ts_rule_decode(j,&r)==ESP_OK);cJSON_Delete(j);strcpy(r.name,"Rename historic"); + assert(ts_rule_commit(&r,r.id,revision,&result)==ESP_OK); + r.actions=realloc(r.actions,2*sizeof(*r.actions));r.actions[1]=r.actions[0];r.action_count=2; + assert(ts_rule_commit(&r,r.id,s_rule_ctx.rules[0].revision,&result)==ESP_ERR_NOT_FOUND&&!s_rule_ctx.meta[0].committing); + ts_rule_dispose(&r);clear_rules();template_missing=false; + // H02 byte boundaries are distinct: ID 63, name 47. + memset(id,'I',63);id[63]=0;add(id,name,false,true);delete_api("boundary");clear_rules(); + service_protected=true;delete_api("service-protected");assert(removals==1);service_protected=false; + + // Actual production gates: force both concurrent orders with persistent-operation barriers. + pthread_t first,second;template_missing=false;atomic_store(&writer_started,0); + wait_at(3,&first,save_concurrent); + assert(ts_action_template_remove_checked("template",&d)==ESP_ERR_INVALID_STATE&&!strcmp(d.check_reason,"busy"));free(d.rules); + release_at();pthread_join(first,NULL);block_phase=0;assert(concurrent_ret==ESP_OK); + assert(ts_action_template_remove_checked("template",&d)==ESP_ERR_INVALID_STATE&&d.references_confirmed);free(d.rules);clear_rules(); + wait_at(4,&first,delete_concurrent);atomic_store(&writer_started,0);pthread_create(&second,NULL,save_concurrent,NULL); + while(!atomic_load(&writer_started)){struct timespec delay={0,100000};nanosleep(&delay,NULL);} + release_at();pthread_join(first,NULL);pthread_join(second,NULL);block_phase=0; + assert(concurrent_ret==ESP_ERR_NOT_FOUND&&s_rule_ctx.count==0); + free(http_body);free(s_rule_ctx.rules);pthread_mutex_destroy(&txn);pthread_mutex_destroy(&lock); + puts("PASS production delete + rule commit + HTTP: H01-H14, leases, no side effects, historical multiplicity, committing cleared, 47/63-byte fields"); +} +''' +with tempfile.TemporaryDirectory(prefix='tianshan-delete-ref-') as tmp: + d=Path(tmp);f=d/'test.c';f.write_text(code);wire=d/'wire';wire.mkdir() + env={**os.environ,'DEVELOPER_DIR':os.environ.get('DEVELOPER_DIR','/Library/Developer/CommandLineTools')} + includes=['tests/runtime/stubs','tests/certificate/stubs','components/ts_automation/include','components/ts_security/include','components/ts_api/include',str(idf/'components/json/cJSON')] + subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations',*[f'-I{x}' for x in includes],str(f),'components/ts_automation/src/ts_rule_codec.c','components/ts_automation/src/ts_action_filter.c',str(idf/'components/json/cJSON/cJSON.c'),'-lpthread','-lm','-o',str(d/'test')],check=True,env=env) + subprocess.run([str(d/'test'),str(wire)],check=True,env=env) + fixtures=Path('tests/fixtures/delete-reference') + if os.environ.get('UPDATE_FIXTURES')=='1':fixtures.mkdir(parents=True,exist_ok=True) + for f in wire.glob('*.json'): + target=fixtures/f.name + if os.environ.get('UPDATE_FIXTURES')=='1':target.write_bytes(f.read_bytes()) + assert target.read_bytes()==f.read_bytes(),f'wire bytes drift: {f.name}' + assert json.loads(f.read_text())['code']!=0 + print('PASS exact HTTP byte fixtures:',len(list(wire.glob('*.json')))) diff --git a/tests/runtime/test_engine.c b/tests/runtime/test_engine.c index 13dcea9..4eee149 100644 --- a/tests/runtime/test_engine.c +++ b/tests/runtime/test_engine.c @@ -15,7 +15,7 @@ #define xSemaphoreTakeRecursive xSemaphoreTake #define xSemaphoreGiveRecursive xSemaphoreGive static int fail_alloc=-1, commits, action_calls; -static bool independent_service; +static bool independent_service, template_missing; static pthread_mutex_t binding=PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t control=PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t changed=PTHREAD_COND_INITIALIZER; @@ -32,7 +32,7 @@ void ts_action_snapshot_owner(const ts_auto_action_t *action,const char *rule){} bool ts_ssh_service_any_in_use(void){return independent_service;} bool ts_ssh_service_start_admissible(const char *id){return true;} bool ts_ssh_service_command_protected(const char *id){return false;} -esp_err_t ts_action_template_get(const char *id,ts_action_template_t *out){return ESP_ERR_NOT_FOUND;} +esp_err_t ts_action_template_get(const char *id,ts_action_template_t *out){if(template_missing)return ESP_ERR_NOT_FOUND;memset(out,0,sizeof(*out));out->action.type=TS_AUTO_ACT_LOG;return ESP_OK;} esp_err_t ts_ssh_commands_config_get(const char *id,ts_ssh_command_config_t *out){return ESP_ERR_NOT_FOUND;} static void barrier(int phase){pthread_mutex_lock(&control);if(block_phase==phase){reached=1;pthread_cond_broadcast(&changed);while(!proceed)pthread_cond_wait(&changed,&control);}pthread_mutex_unlock(&control);} esp_err_t ts_variable_get(const char *id,ts_auto_value_t *out){barrier(1);out->type=TS_AUTO_VAL_BOOL;out->bool_val=true;return ESP_OK;} @@ -62,6 +62,7 @@ int main(void){ uint32_t old_instance=s_rule_ctx.rules[0].instance;assert(ts_rule_commit(NULL,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK);assert(ts_rule_commit(&a,a.id,0,&result)==ESP_OK);assert(s_rule_ctx.rules[0].instance!=old_instance&&s_rule_ctx.rules[0].trigger_count==0); strcpy(a.actions[0].template_id,"missing"); assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); + template_missing=true; /* Emulate an imported/offline missing reference after a valid save. */ independent_service=true; /* action can be finished while remote run is still owned */ assert(!s_rule_ctx.meta[0].executing); assert(ts_rule_commit(NULL,a.id,s_rule_ctx.rules[0].revision,&result)!=ESP_OK); @@ -69,8 +70,8 @@ int main(void){ assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)!=ESP_OK); independent_service=false; assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); - strcpy(a.actions[0].template_id,"missing"); - assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); + strcpy(a.actions[0].template_id,"missing");template_missing=false; + assert(ts_rule_commit(&a,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK);template_missing=true; assert(ts_rule_commit(NULL,a.id,s_rule_ctx.rules[0].revision,&result)==ESP_OK); ts_rule_dispose(&a);free(s_rule_ctx.rules); puts("PASS actual rule engine: revisions/no-op/allocation failures, stale-evaluation barrier, pinned old execution, retirement bound, enable/manual/allow matrix, recreate identity"); From 15962f35d304b5690951c68bd66c5974aadb35a1 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 05:18:01 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E6=96=87=20README=20=E7=9A=84=20TianshanOS=20?= =?UTF-8?q?=E5=A4=B4=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- README_EN.md | 2 +- assets/tianshanos-banner-en.png | Bin 0 -> 96060 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 assets/tianshanos-banner-en.png diff --git a/README.md b/README.md index 2614d6b..b1376bc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

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

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

${escapeHtml(message)}

`; + return; + } // 过滤出启用且标记为可手动触发的规则 if (!result.data.loaded || result.data.recovery_required) { container.textContent = runtimeText(result.data.recovery_required ? 'recovery_required' : 'configLoading'); return; @@ -15246,7 +15253,8 @@ function formatUptimeSec(seconds) { async function automationControl(action) { try { const result = await api.call(`automation.${action}`); - showToast(typeof t === 'function' ? t('toast.actionResult', { action, msg: result.message || 'OK' }) : `${action}: ${result.message || 'OK'}`, result.code === 0 ? 'success' : 'error'); + const message = (result.rawMessage || result.error || result.message) === 'restart_pending' ? runtimeText('restart_pending') : result.message || 'OK'; + showToast(typeof t === 'function' ? t('toast.actionResult', { action, msg: message }) : `${action}: ${message}`, result.code === 0 ? 'success' : 'error'); if (result.code === 0) { await refreshAutomationStatus(); } @@ -15280,15 +15288,18 @@ async function refreshRules() { ${rules.map(r => { const label = r.enabled ? t('common.disabled') : t('common.enabled'); + const locked = r.restart_required || r.readonly; + const active = r.runtime_active !== false; + const handler = (name, ...args) => escapeHtml(name + '(' + args.map(x => JSON.stringify(x)).join(',') + ')'); return `
-
${r.id}
-
${r.name || r.id}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
-
+
${escapeHtml(r.id)}
+
${escapeHtml(r.name || r.id)}${r.restart_required ? ` ${t('rulePack.pending')}` : ''}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
+
${r.conditions_count || 0}
${r.actions_count || 0}
${r.trigger_count || 0}
-
${icoBtn('ri-play-line', t('automation.manualTrigger'), `triggerRule('${r.id}')`)}${icoBtn('ri-edit-line', t('common.edit'), `editRule('${r.id}')`)}${icoBtn('ri-download-line', t('securityPage.exportConfigPack'), `showExportRuleModal('${r.id}')`)}${icoBtn('ri-delete-bin-line', t('common.delete'), `deleteRule('${r.id}')`, 'dg')}
+
${icoBtn('ri-play-line', t(r.restart_required ? 'rulePack.runCurrent' : 'automation.manualTrigger'), handler('triggerRule', r.id), '', !active)}${icoBtn('ri-edit-line', t('common.edit'), handler('editRule', r.id), '', locked)}${icoBtn('ri-download-line', t('rulePack.exportCurrent'), handler('showExportRuleModal', r.id), '', !active)}${icoBtn('ri-delete-bin-line', t('common.delete'), handler('deleteRule', r.id), 'dg', locked)}
`; }).join('')} `; @@ -16870,7 +16881,7 @@ async function deleteAction(id) { async function toggleSource(id, enable) { try { const action = enable ? 'automation.sources.enable' : 'automation.sources.disable'; - const result = await ruleWriteWithRevision(action, id); + const result = await api.call(action, { id }); showToast(typeof t === 'function' ? t('toast.sourceToggled', { id, state: enable ? t('status.enabled') : t('status.disabled') }) + ': ' + (result.message || 'OK') : `数据源 ${id} ${enable ? '启用' : '禁用'}: ${result.message || 'OK'}`, result.code === 0 ? 'success' : 'error'); if (result.code === 0) { await refreshSources(); @@ -18982,6 +18993,7 @@ async function doExportRule(ruleId) { * 显示导入规则配置模态框 */ function showImportRuleModal() { + ++rulePackPreviewAttempt; rulePackPreview = null; let modal = document.getElementById('import-rule-modal'); if (!modal) { modal = document.createElement('div'); @@ -18999,103 +19011,114 @@ function showImportRuleModal() { } function hideImportRuleModal() { + ++rulePackPreviewAttempt; rulePackPreview = null; const modal = document.getElementById('import-rule-modal'); if (modal) modal.classList.add('hidden'); window._importRuleTscfg = null; } +let rulePackPreviewAttempt = 0; +let rulePackPreview = null; +function rulePackFailure(result) { + const token = result?.data?.error_code || result?.rawMessage || result?.error || result?.message; + const key = 'rulePack.errors.' + token; + const text = t(key); + return text !== key ? text : t('rulePack.errors.invalid_pack'); +} async function previewRuleImport() { - const fileInput = document.getElementById('import-rule-file'); - const resultBox = document.getElementById('import-rule-result'); - const step2 = document.getElementById('import-rule-step2'); - const previewDiv = document.getElementById('import-rule-preview'); - const importBtn = document.getElementById('import-rule-btn'); - const statusEl = document.getElementById('import-rule-file-status'); - - if (!fileInput.files || !fileInput.files[0]) { - if (statusEl) statusEl.textContent = typeof t === 'function' ? t('common.noFileSelected') : '未选择任何文件'; - return; - } - - const file = fileInput.files[0]; - if (statusEl) statusEl.textContent = file.name; - - resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = (typeof t === 'function' ? t('ssh.verifyingPack') : '正在验证配置包...'); - importBtn.disabled = true; - previewDiv.innerHTML = importPlaceholder('rule'); - + const attempt = ++rulePackPreviewAttempt; + rulePackPreview = null; + window._importRuleTscfg = null; + const input = document.getElementById('import-rule-file'); + const box = document.getElementById('import-rule-result'); + const button = document.getElementById('import-rule-btn'); + const preview = document.getElementById('import-rule-preview'); + button.disabled = true; + if (!input.files?.[0]) return; + const file = input.files[0]; + document.getElementById('import-rule-file-status').textContent = file.name; + box.className = 'result-box'; box.textContent = t('ssh.verifyingPack'); + preview.innerHTML = importPlaceholder('rule'); try { - const content = await file.text(); - window._importRuleTscfg = content; - window._importRuleFilename = file.name; - - const result = await api.call('automation.rules.import', { - tscfg: content, - filename: file.name, - preview: true - }); - - if (result.code === 0 && result.data?.valid) { - const data = result.data; - renderImportPreview('rule', data, t('automation.packTypeRule')); - resultBox.className = 'result-box success'; - resultBox.textContent = typeof t === 'function' ? t('ssh.signatureVerified') : '签名验证通过'; - importBtn.disabled = false; - } else { - resultBox.className = 'result-box error'; - resultBox.textContent = (result.message || (typeof t === 'function' ? t('ssh.cannotVerifyPack') : '无法验证配置包')); - } - } catch (e) { - resultBox.className = 'result-box error'; - resultBox.textContent = e.message; + const bytes = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(await file.arrayBuffer()); + if (attempt !== rulePackPreviewAttempt) return; + const result = await api.call('automation.rules.import', {tscfg: bytes, filename: file.name, preview: true}); + if (attempt !== rulePackPreviewAttempt) return; + const data = result.data; + if (result.code !== 0 || data?.valid !== true || data?.trusted !== true || data?.target_matches !== true || + !data.rule || data.rule.id !== data.id || !Array.isArray(data.rule.actions) || !data.rule.actions.length || + !Number.isInteger(data.expected_revision) || !Number.isInteger(data.expected_generation) || + !Number.isInteger(data.credential_generation) || !/^[0-9a-f]{64}$/.test(data.package_digest || '')) { + box.className = 'result-box error'; box.textContent = rulePackFailure(result); return; + } + rulePackPreview = data; + window._importRuleTscfg = bytes; window._importRuleFilename = file.name; + renderImportPreview('rule', data, t('automation.packTypeRule')); + const conditions = Array.isArray(data.rule.conditions) ? data.rule.conditions.length : data.rule.conditions?.items?.length || 0; + preview.insertAdjacentHTML('afterbegin', row(t('common.name'), escapeHtml(data.rule.name || data.id))); + preview.insertAdjacentHTML('beforeend', row(t('rulePack.summaryLabel'), escapeHtml(t('rulePack.summary', {conditions, actions: data.rule.actions?.length || 0})))); + preview.insertAdjacentHTML('beforeend', `
${escapeHtml(t('rulePack.content'))}
${escapeHtml(JSON.stringify(data.rule, null, 2))}
`); + if (data.exists) preview.insertAdjacentHTML('beforeend', row(t('rulePack.overwriteLabel'), escapeHtml(t('rulePack.overwriteImpact', {revision: data.expected_revision})))); + for (const warning of data.warnings || []) { + const key = warning === 'dynamic_inputs' ? 'rulePack.dynamicInputs' + : warning === 'dependency_disabled' ? 'rulePack.disabledDependency' : null; + if (key) preview.insertAdjacentHTML('beforeend', row(t('securityPage.noteLabel'), escapeHtml(t(key)))); + } + box.className = 'result-box success'; box.textContent = t('rulePack.verified'); + button.disabled = false; + } catch (error) { + if (attempt !== rulePackPreviewAttempt) return; + box.className = 'result-box error'; box.textContent = t('rulePack.verifyFailed'); } } - async function confirmRuleImport() { - const overwrite = document.getElementById('import-rule-overwrite').checked; - const resultBox = document.getElementById('import-rule-result'); - const importBtn = document.getElementById('import-rule-btn'); - - if (!window._importRuleTscfg) { - showToast((typeof t === 'function' ? t('toast.selectFileFirst') : '请先选择文件'), 'error'); - return; - } - - resultBox.classList.remove('hidden', 'success', 'error'); - resultBox.textContent = typeof t === 'function' ? t('ssh.savingConfig') : '正在保存配置...'; - importBtn.disabled = true; - + const attempt = rulePackPreviewAttempt; + const preview = rulePackPreview; + const box = document.getElementById('import-rule-result'); + const button = document.getElementById('import-rule-btn'); + if (!preview || !window._importRuleTscfg) { box.textContent = t('rulePack.errors.preview_required'); return; } + const bytes = window._importRuleTscfg; + button.disabled = true; + box.className = 'result-box'; box.textContent = t('ssh.savingConfig'); + let uncertain = false; try { - const params = { - tscfg: window._importRuleTscfg, - filename: window._importRuleFilename, - overwrite: overwrite - }; - - const result = await api.call('automation.rules.import', params); - - if (result.code === 0) { - const data = result.data; - if (data?.exists && !data?.imported) { - resultBox.className = 'result-box warning'; - resultBox.textContent = typeof t === 'function' ? t('securityPage.configExistsCheckOverwrite', { id: data.id }) : `配置 ${data.id} 已存在,请勾选「覆盖」选项`; - importBtn.disabled = false; - } else { - resultBox.className = 'result-box success'; - resultBox.innerHTML = `${typeof t === 'function' ? t('securityPage.savedConfig') : 'Saved config'}: ${escapeHtml(data?.id)}
${typeof t === 'function' ? t('securityPage.restartToApply') : 'Restart to apply'}`; - showToast(typeof t === 'function' ? t('toast.configImported') : '已导入配置,重启后生效', 'success'); - setTimeout(() => hideImportRuleModal(), 2000); - } - } else { - resultBox.className = 'result-box error'; - resultBox.textContent = (result.message || (typeof t === 'function' ? t('toast.importFailed') : '导入失败')); - importBtn.disabled = false; + const result = await api.call('automation.rules.import', { + tscfg: bytes, filename: window._importRuleFilename, + overwrite: document.getElementById('import-rule-overwrite').checked, + expected_revision: preview.expected_revision, expected_generation: preview.expected_generation, + credential_generation: preview.credential_generation, package_digest: preview.package_digest + }); + if (attempt !== rulePackPreviewAttempt) return; + const data = result.data; + if (result.code === 0 && data?.saved === true && data?.durable === true && data?.runtime_applied === false) { + box.className = data.cleanup_pending ? 'result-box warning' : 'result-box success'; + box.textContent = t(data.restart_required ? 'rulePack.saved' : 'rulePack.alreadyActive') + (data.cleanup_pending ? ' ' + t('rulePack.cleanupPending') : ''); + rulePackPreview = null; + await refreshRules(); + return; } - } catch (e) { - resultBox.className = 'result-box error'; - resultBox.textContent = e.message; - importBtn.disabled = false; + uncertain = result.code === 0 || data?.result_unknown === true || ['commit_unknown', 'recovery_required'].includes(result.rawMessage || result.error || result.message); + if (!uncertain) { + box.className = 'result-box error'; box.textContent = rulePackFailure(result); + if ((result.rawMessage || result.error || result.message) === 'overwrite_required') button.disabled = false; + else rulePackPreview = null; + return; + } + } catch (error) { uncertain = true; } + if (attempt !== rulePackPreviewAttempt) return; + if (uncertain) { + // Observe once; never replay an uncertain write. + box.className = 'result-box warning'; box.textContent = t('rulePack.unknown'); + rulePackPreview = null; + try { + const state = await api.call('automation.rules.list'); + if (attempt !== rulePackPreviewAttempt) return; + const saved = state.code === 0 && state.data?.loaded !== false && state.data?.recovery_required !== true && state.data?.rules?.find(r => r.id === preview.id && r.package_digest === preview.package_digest); + if (saved && Number.isInteger(saved.saved_revision) && saved.saved_generation >= preview.expected_generation) { + box.className = 'result-box success'; box.textContent = t(saved.restart_required ? 'rulePack.saved' : 'rulePack.alreadyActive'); + await refreshRules(); + } + } catch (error) { /* Keep the explicit unknown result. */ } } } diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index f8e20e8..fae56e6 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -2,6 +2,52 @@ * TianShanOS WebUI - English Language Pack */ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { + rulePack: { + content: "View rule content", + overwriteLabel: "Overwrite impact", + overwriteImpact: "Replaces saved revision {revision}; the running version continues until restart.", + "disabledDependency": "This rule and some dependencies are disabled. Check dependencies before enabling it.", + "pending": "Saved; restart required", + "runCurrent": "Run the current, older version", + "exportCurrent": "Export the current running version", + "verified": "Signing trust, target device and rule content verified.", + "saved": "Saved; takes effect after restart. The running version has not changed.", + "alreadyActive": "This configuration is already saved and active.", + "unknown": "Save outcome is unconfirmed. Read the rule list before importing again.", + "cleanupPending": "Old storage objects still need cleanup. Keep the SD card installed and check after restart.", + "dynamicInputs": "Condition variables can be created at runtime; missing samples do not prove missing configuration.", + "summaryLabel": "Rule summary", + "summary": "{conditions} conditions, {actions} actions", + "verifyFailed": "Verification did not complete. Check the connection and select the file again.", + "errors": { + "config_loading": "Rule configuration is still loading. Check again shortly.", + "invalid_pack": "The package format or content is invalid. Export it again.", + "invalid_rule_pack": "This is not a valid rule configuration package.", + "wrong_device": "This package targets another device. Export it using the target device certificate.", + "signature_invalid": "Signature verification failed. Obtain the original package again.", + "time_unverified": "Device time is unverified. Complete time synchronization first.", + "signing_trust_unconfigured": "A configuration signing root has not been selected. Ask the administrator to complete deployment configuration.", + "signer_untrusted": "The signer is not trusted by the device configuration signing policy.", + "signer_unauthorized": "The signer certificate is not authorized to sign configurations.", + "credential_changed": "Credentials or the selected file changed after preview. Verify again.", + "dependency_loading": "Dependency configuration is still loading. Verify again shortly.", + "dependency_unavailable": "Dependency configuration failed to load. Check and recover it first.", + "dependency_missing": "Required configuration is missing. Install the referenced template, command or host first.", + "dependency_disabled": "This enabled rule depends on disabled configuration. Check its dependencies.", + "preview_required": "Select the file again and complete preview first.", + "overwrite_required": "Configuration with this ID exists. Review it and select overwrite.", + "revision_conflict": "Saved configuration changed after preview. Verify again.", + "restart_pending": "This rule has a saved version awaiting restart. Ordinary edits, toggles and deletion are unavailable.", + "recovery_required": "Saved configuration requires recovery. Import is unavailable.", + "source_unavailable": "The required SD card is unavailable. Check its mount state.", + "source_read_only": "The current configuration source is read-only. Restore its writable source before importing.", + "service_busy": "A bound service is active or unconfirmed. Verify and stop it first.", + "capacity": "The saved rule limit has been reached, including pending rules.", + "no_memory": "The device has insufficient memory. Retry shortly.", + "storage_failed": "Configuration could not be saved reliably. Check storage and verify again.", + "commit_unknown": "Save outcome is unconfirmed. Read state before importing again." + } +}, conditionEditor: { variableRequired: "Select a condition variable.", variableInvalid: "The condition variable is invalid or exceeds 63 UTF-8 bytes.", @@ -235,6 +281,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { }, runtimeRepair: { + restart_pending: "This rule has a saved version awaiting restart. Ordinary edits, toggles and deletion are unavailable.", configLoading: 'Configuration is loading. Refresh shortly.', confirmFingerprint: 'Verify this server fingerprint through a trusted channel. Trust this exact fingerprint and connect?', saved: 'Saved', diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index 6663157..c7c9adb 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -2,6 +2,52 @@ * TianShanOS WebUI - 简体中文语言包 */ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { + rulePack: { + content: "查看规则内容", + overwriteLabel: "覆盖影响", + overwriteImpact: "替换已保存的第 {revision} 版;重启前仍运行当前版本。", + "disabledDependency": "此规则已禁用,部分依赖也已禁用;请在启用前检查依赖状态。", + "pending": "已保存,待重启", + "runCurrent": "执行正在使用的旧版本", + "exportCurrent": "导出正在使用的版本", + "verified": "来源、目标设备和规则内容已验证。", + "saved": "已保存,重启后生效;当前运行版本未改变。", + "alreadyActive": "此配置已保存,当前运行版本已是这一版。", + "unknown": "保存结果尚未确认,请读取规则列表核对,不要重复导入。", + "cleanupPending": "旧存储对象尚待清理,请保留 SD 卡并在重启后核对。", + "dynamicInputs": "条件变量可能由运行时产生;没有采样值不代表配置缺失。", + "summaryLabel": "规则摘要", + "summary": "{conditions} 项条件,{actions} 项动作", + "verifyFailed": "未能完成验证,请检查连接后重新选择文件。", + "errors": { + "config_loading": "规则配置仍在加载,请稍后刷新。", + "invalid_pack": "配置包格式或内容无效,请重新导出。", + "invalid_rule_pack": "此文件不是有效的规则配置包。", + "wrong_device": "此包属于另一台设备,请用目标设备证书重新导出。", + "signature_invalid": "签名验证失败,请重新取得原始配置包。", + "time_unverified": "设备时间尚未确认,请先完成时间同步。", + "signing_trust_unconfigured": "设备尚未指定配置签发信任根,请由管理员完成部署配置。", + "signer_untrusted": "签名者不受设备配置签发策略信任。", + "signer_unauthorized": "签名者证书没有配置签发权限。", + "credential_changed": "凭证或所选文件在预览后发生变化,请重新验证。", + "dependency_loading": "依赖配置仍在加载,请稍后重新验证。", + "dependency_unavailable": "依赖配置加载失败,请先检查并恢复相关配置。", + "dependency_missing": "规则引用的必要配置不存在,请先补齐动作模板、指令或主机。", + "dependency_disabled": "启用的规则依赖已禁用的配置,请先检查依赖状态。", + "preview_required": "请先重新选择文件并完成预览。", + "overwrite_required": "已有同 ID 配置,请核对内容后勾选覆盖。", + "revision_conflict": "已保存的配置在预览后发生变化,请重新验证。", + "restart_pending": "此规则已有待重启版本,普通编辑、启停和删除暂不可用。", + "recovery_required": "已保存配置需要恢复核对,当前不能导入。", + "source_unavailable": "配置所需的 SD 卡不可用,请检查挂载状态。", + "source_read_only": "当前配置来源为只读,请恢复可写来源后再导入。", + "service_busy": "规则绑定的服务仍在运行或状态未确认,请先核验并停止服务。", + "capacity": "已保存规则数量已达上限,包括待重启规则。", + "no_memory": "设备内存不足,请稍后再试。", + "storage_failed": "配置未能可靠保存,请检查存储后重新验证。", + "commit_unknown": "保存结果尚未确认,请读取状态核对,不要重复导入。" + } +}, conditionEditor: { variableRequired: "请选择条件变量。", variableInvalid: "条件变量名称无效或过长(最多 63 个 UTF-8 字节)。", @@ -235,6 +281,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { }, runtimeRepair: { + restart_pending: "此规则已有待重启版本,普通编辑、启停和删除暂不可用。", configLoading: '配置正在加载,请稍后刷新。', confirmFingerprint: '请通过可信渠道核对以下服务器指纹。确认信任此指纹并继续连接?', saved: '已保存', diff --git a/tests/certificate/test_material.c b/tests/certificate/test_material.c index 8483f21..bf3cc0a 100644 --- a/tests/certificate/test_material.c +++ b/tests/certificate/test_material.c @@ -111,10 +111,17 @@ int main(int argc,char **argv) { clock_now=from;assert(status().cert_info.is_valid);clock_now=to;assert(status().cert_info.is_valid); clock_now=to+1;assert(status().cert_info.validity==TS_CERT_VALIDITY_EXPIRED&&status().cert_info.days_until_expiry==-1); clock_now=0;assert(status().status==TS_CERT_STATUS_TIME_UNVERIFIED&&!status().cert_info.is_valid); + ts_cert_snapshot_t offline; + assert(ts_cert_get_snapshot(true,&offline)!=ESP_OK); + assert(ts_cert_get_pack_snapshot(&offline)==ESP_OK&&offline.generation==status().generation); + ts_cert_free_snapshot(&offline); + fail_alloc=true;assert(ts_cert_get_pack_snapshot(&offline)==ESP_ERR_NO_MEM&&!offline.key&&!offline.certificate&&!offline.ca);fail_alloc=false; clock_now=-1;assert(!status().time_ready);clock_now=1790121600; install(future);assert(status().status==TS_CERT_STATUS_NOT_YET_VALID);install(expired);assert(status().status==TS_CERT_STATUS_EXPIRED);install(a); drop_event=true;install(b);assert(status().generation>generation);drop_event=false; - ts_cert_snapshot_t snap;assert(ts_cert_get_snapshot(true,&snap)==0);install(a);assert(!strcmp(snap.certificate,b));assert(snap.generation!=status().generation);ts_cert_free_snapshot(&snap); + ts_cert_snapshot_t snap;assert(ts_cert_get_snapshot(true,&snap)==0);uint32_t old_generation=snap.generation;install(a);assert(!strcmp(snap.certificate,b));assert(snap.generation!=status().generation);ts_cert_free_snapshot(&snap); + assert(!ts_cert_material_begin(old_generation)); + assert(ts_cert_material_begin(status().generation));ts_cert_material_end(); pthread_t t1,t2;pthread_create(&t1,NULL,writer,NULL);pthread_create(&t2,NULL,reader,NULL);pthread_join(t1,NULL);pthread_join(t2,NULL); assert(ts_cert_get_snapshot(true,&snap)==0);generation=status().generation; assert(ts_cert_generate_keypair()==0);assert(status().generation!=generation&&!status().has_certificate);assert(snap.certificate!=NULL);ts_cert_free_snapshot(&snap); diff --git a/tests/prompts/rule-pack.browser.test.cjs b/tests/prompts/rule-pack.browser.test.cjs new file mode 100644 index 0000000..f84be2e --- /dev/null +++ b/tests/prompts/rule-pack.browser.test.cjs @@ -0,0 +1,174 @@ +// Real page and file picker, local simulated device. Cryptography/storage are verified separately by production C tests. +const {test,before,after}=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'),path=require('node:path'),http=require('node:http'); +const {chromium}=require('playwright'); +const root=path.resolve(__dirname,'../../components/ts_webui/web'); +let browser; +before(async()=>{browser=await chromium.launch({channel:'chrome',headless:true});}); +after(async()=>{await browser?.close();}); +const hash='a'.repeat(64); +const id='night\'"<&>'; +const preview={valid:true,trusted:true,target_matches:true,type:'automation_rule',id,name:'Night test', + signer:'Test Developer',official:true,exists:false,expected_revision:0,expected_generation:7,credential_generation:4, + package_digest:hash,rule:{id,name:'Night test',conditions:[{variable:'future.sample'}],actions:[{type:'log',message:'Test'}]}, + warnings:['dynamic_inputs']}; +async function pageFor(language,mode,run){ + const calls=[];let saved=false,writes=0,sourceEnabled=false; + const server=http.createServer((req,res)=>{ + const url=new URL(req.url,'http://local'); + if(url.pathname.startsWith('/api/v1/')){ + let raw='';req.on('data',part=>raw+=part); + req.on('end',()=>{ + const endpoint=url.pathname.slice(8); + const params=req.method==='GET'?Object.fromEntries(url.searchParams):(raw?JSON.parse(raw):{}); + calls.push({endpoint,params}); + let reply={code:0,data:{}}; + if(endpoint==='automation/sources/list')reply={code:0,data:{sources:[{id:'source-only',label:'Source',enabled:sourceEnabled,type:'ssh'}]}}; + if(endpoint==='automation/sources/enable'||endpoint==='automation/sources/disable'){ + assert.deepEqual(params,{id:'source-only'});sourceEnabled=endpoint.endsWith('/enable'); + } + if(endpoint==='automation/rules/get')reply={code:1,message:'not_found'}; + if(endpoint==='automation/rules/import'){ + if(params.preview){ + const data={...preview,exists:mode==='old',expected_revision:mode==='old'?4:0}; + if(params.tscfg==='untrusted')data.trusted=false; + if(params.tscfg==='slow'||params.tscfg==='fast')data.rule={...preview.rule,name:params.tscfg}; + reply={code:0,data}; + if(params.tscfg==='slow'){ + setTimeout(()=>{res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify(reply));},150);return; + } + } + else{ + ++writes; + assert.equal(params.expected_revision,mode==='old'?4:0); + assert.equal(params.expected_generation,7); + assert.equal(params.credential_generation,4); + assert.equal(params.package_digest,hash); + if(mode==='conflict')reply={code:1,message:'revision_conflict'}; + else{ + saved=true; + if(mode==='lost'||mode==='unknown'){ + res.writeHead(200,{'Content-Type':'application/json'});res.flushHeaders(); + res.write('{"code":0,');setImmediate(()=>res.destroy());return; + } + reply={code:0,data:{id,saved:true,durable:true,runtime_applied:false,restart_required:true,saved_revision:1,saved_generation:8}}; + } + } + } + if(endpoint==='automation/rules/list')reply={code:0,data:{loaded:true,recovery_required:mode==='unknown',rules:saved?[{id,name:'Night test',enabled:false, + readonly:true,restart_required:true,runtime_active:mode==='old',saved_revision:mode==='old'?5:1,active_revision:mode==='old'?4:0,saved_generation:8, + package_digest:hash,conditions_count:1,actions_count:1}]:[]}}; + res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify(reply)); + });return; + } + const file=path.resolve(root,'.'+(url.pathname==='/'?'/index.html':url.pathname)); + if(!file.startsWith(root+'/')){res.writeHead(403).end();return;} + fs.readFile(file,(error,data)=>{if(error){res.writeHead(404).end();return;} + res.setHeader('Content-Type',({'.js':'application/javascript','.css':'text/css','.html':'text/html','.svg':'image/svg+xml','.woff2':'font/woff2'})[path.extname(file)]||'application/octet-stream'); + res.end(data); + }); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const context=await browser.newContext({locale:language}); + await context.addInitScript(language=>{ + localStorage.setItem('ts_language',language); + class Socket{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;}send(){}close(){this.readyState=3;}} + window.WebSocket=Socket; + },language); + const page=await context.newPage(),errors=[]; + page.on('pageerror',e=>errors.push(e.message)); + try{ + await page.goto('http://127.0.0.1:'+server.address().port); + await page.waitForFunction(()=>i18n.isReady()); + await page.evaluate(()=>{closeLoginModal();document.body.insertAdjacentHTML('beforeend','
');showImportRuleModal();}); + await page.locator('#import-rule-file').setInputFiles({name:'renamed-package.tscfg',mimeType:'application/json',buffer:Buffer.from('local page fixture')}); + await page.waitForFunction(()=>!document.getElementById('import-rule-btn').disabled); + assert.equal(await page.locator('#import-rule-preview b').count(),0,'the signed name must remain text'); + assert.match(await page.locator('#import-rule-preview').innerText(),/Night test<\/b>/); + await run({page,calls,writes:()=>writes,saved:()=>saved}); + assert.deepEqual(errors,[]); + }finally{await context.close();await new Promise(resolve=>server.close(resolve));} +} +for(const language of ['zh-CN','en-US']){ + test(language+': preview -> one save -> pending row and escaped ID',async()=>{ + await pageFor(language,'ok',async({page,calls,writes,saved})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.saved')); + await page.waitForFunction(()=>document.querySelector('#rules-list .tr:not(.th)')); + assert(saved());assert.equal(writes(),1); + const buttons=page.locator('#rules-list .tr:not(.th) button'); + for(let i=0;ic.endpoint==='automation/rules/list')); + }); + }); + test(language+': a lost save reply is observed without repeating the write',async()=>{ + await pageFor(language,'lost',async({page,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.saved')); + assert.equal(writes(),1); + }); + }); + test(language+': saved generation conflict requires a fresh preview',async()=>{ + await pageFor(language,'conflict',async({page,writes,saved})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.errors.revision_conflict')); + assert.equal(writes(),1);assert.equal(saved(),false); + assert(await page.locator('#import-rule-btn').isDisabled()); + }); + }); + test(language+': pending replacement retains only current trigger and export controls',async()=>{ + await pageFor(language,'old',async({page,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.querySelector('#rules-list .tr:not(.th)')); + const buttons=page.locator('#rules-list .tr:not(.th) button'); + for(const [index,disabled] of [[0,true],[1,false],[2,true],[3,false],[4,true]])assert.equal(await buttons.nth(index).isDisabled(),disabled); + assert.match(await buttons.nth(1).getAttribute('title'),language==='zh-CN'?/当前|旧/:/older/); + assert.equal(await buttons.nth(3).getAttribute('title'),await page.evaluate(()=>t('rulePack.exportCurrent'))); + assert.equal(writes(),1); + }); + }); + test(language+': recovery failure cannot confirm an uncertain save',async()=>{ + await pageFor(language,'unknown',async({page,calls,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.unknown')); + await page.waitForTimeout(100); + assert(calls.some(c=>c.endpoint==='automation/rules/list'));assert.equal(writes(),1); + assert.equal(await page.locator('#import-rule-result').innerText(),await page.evaluate(()=>t('rulePack.unknown'))); + assert(await page.locator('#import-rule-btn').isDisabled()); + }); + }); + test(language+': out-of-order previews cannot replace the newest selected bytes',async()=>{ + await pageFor(language,'ok',async({page,calls,writes})=>{ + await page.locator('#import-rule-file').setInputFiles({name:'slow.tscfg',buffer:Buffer.from('slow')}); + await page.waitForTimeout(20); + await page.locator('#import-rule-file').setInputFiles({name:'fast.tscfg',buffer:Buffer.from('fast')}); + await page.waitForFunction(()=>!document.getElementById('import-rule-btn').disabled); + await page.waitForTimeout(200); + assert.equal(await page.evaluate(()=>rulePackPreview.rule.name),'fast'); + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.saved')); + assert.equal(calls.find(c=>c.endpoint==='automation/rules/import'&&!c.params.preview).params.tscfg,'fast');assert.equal(writes(),1); + }); + }); + test(language+': an untrusted preview cannot enable import',async()=>{ + await pageFor(language,'ok',async({page,writes})=>{ + await page.locator('#import-rule-file').setInputFiles({name:'untrusted.tscfg',buffer:Buffer.from('untrusted')}); + await page.waitForFunction(()=>document.getElementById('import-rule-result').classList.contains('error')); + assert(await page.locator('#import-rule-btn').isDisabled());assert.equal(writes(),0); + }); + }); + test(language+': datasource switch works without a same-name rule',async()=>{ + await pageFor(language,'ok',async({page,calls})=>{ + await page.evaluate(async()=>{hideImportRuleModal();document.body.insertAdjacentHTML('beforeend','
');await refreshSources();}); + const button=page.locator('#sources-list button[role="switch"]'); + await button.click();await page.waitForFunction(()=>document.querySelector('#sources-list button[role="switch"]').getAttribute('aria-checked')==='true'); + await button.click();await page.waitForFunction(()=>document.querySelector('#sources-list button[role="switch"]').getAttribute('aria-checked')==='false'); + assert.equal(calls.filter(c=>c.endpoint==='automation/sources/enable').length,1); + assert.equal(calls.filter(c=>c.endpoint==='automation/sources/disable').length,1); + assert.equal(calls.filter(c=>c.endpoint==='automation/rules/get').length,0); + }); + }); +} diff --git a/tests/runtime/extract_engine.py b/tests/runtime/extract_engine.py index 5cf8426..861f003 100644 --- a/tests/runtime/extract_engine.py +++ b/tests/runtime/extract_engine.py @@ -2,8 +2,22 @@ import re s=Path('components/ts_automation/src/ts_rule_engine.c').read_text() a=s.index('typedef struct {\n ts_auto_rule_t *rules;');b=s.index('static void payload_free',a) -parts=[s[a:b]] -for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: +header=Path('components/ts_automation/src/ts_rule_store_set.h').read_text().replace('#pragma once','') +header=header.replace('#include "ts_rule_store.h"', '#include "'+str(Path('components/ts_automation/src/ts_rule_store.h').resolve())+'"') +header=header.replace('#include "ts_config_pack.h"', '#include "'+str(Path('components/ts_config_pack/include/ts_config_pack.h').resolve())+'"') +# These legacy engine tests deliberately have no saved-set instance. Full set/pack integration is tested separately. +stubs = r""" +#ifndef TS_RULE_SET_INTEGRATION_TEST +bool ts_rule_store_set_available(void){return false;} +bool ts_rule_store_set_present(void){return false;} +int ts_rule_store_set_count(void){return 0;} +uint32_t ts_rule_store_set_generation(void){return 0;} +esp_err_t ts_rule_store_set_info(const char *id,ts_rule_saved_info_t *out){return ESP_ERR_NOT_FOUND;} +esp_err_t ts_rule_store_set_commit(const ts_auto_rule_t*r,const char*id,uint32_t v,uint32_t g,const char*p,size_t n,const ts_config_pack_acceptance_t*a,ts_rule_commit_t*out){assert(!"unexpected saved-set commit in legacy engine test");return ESP_FAIL;} +#endif +""" +parts=[header,stubs,s[a:b]] +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','pending_id','rule_commit_impl','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name end=s.index('\n}',m.start())+2;parts.append(s[m.start():end]) Path('/tmp/tianshan-runtime-tests/engine.inc').write_text('\n'.join(parts)) diff --git a/tests/runtime/rule_pack/stubs/esp_err.h b/tests/runtime/rule_pack/stubs/esp_err.h new file mode 100644 index 0000000..f732eef --- /dev/null +++ b/tests/runtime/rule_pack/stubs/esp_err.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include +#include +typedef int esp_err_t; +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_NO_MEM 0x101 +#define ESP_ERR_INVALID_ARG 0x102 +#define ESP_ERR_INVALID_STATE 0x103 +#define ESP_ERR_INVALID_SIZE 0x104 +#define ESP_ERR_NOT_FOUND 0x105 +#define ESP_ERR_TIMEOUT 0x106 +#define ESP_ERR_NOT_SUPPORTED 0x107 +#define ESP_ERR_NVS_NOT_FOUND 0x1102 diff --git a/tests/runtime/rule_pack/stubs/esp_heap_caps.h b/tests/runtime/rule_pack/stubs/esp_heap_caps.h new file mode 100644 index 0000000..ab2dc38 --- /dev/null +++ b/tests/runtime/rule_pack/stubs/esp_heap_caps.h @@ -0,0 +1,6 @@ +#pragma once +#include +#define MALLOC_CAP_SPIRAM 1 +#define MALLOC_CAP_8BIT 2 +#define heap_caps_malloc(n,c) malloc(n) +#define heap_caps_calloc(n,s,c) calloc(n,s) diff --git a/tests/runtime/rule_pack/stubs/esp_log.h b/tests/runtime/rule_pack/stubs/esp_log.h new file mode 100644 index 0000000..d56bc66 --- /dev/null +++ b/tests/runtime/rule_pack/stubs/esp_log.h @@ -0,0 +1,5 @@ +#pragma once +#define ESP_LOGI(...) ((void)0) +#define ESP_LOGW(...) ((void)0) +#define ESP_LOGD(...) ((void)0) +#define ESP_LOGE(...) ((void)0) diff --git a/tests/runtime/rule_pack/stubs/nvs.h b/tests/runtime/rule_pack/stubs/nvs.h new file mode 100644 index 0000000..276729c --- /dev/null +++ b/tests/runtime/rule_pack/stubs/nvs.h @@ -0,0 +1,10 @@ +#pragma once +#include "esp_err.h" +typedef int nvs_handle_t; +#define NVS_READONLY 0 +#define NVS_READWRITE 1 +esp_err_t nvs_open(const char *,int,nvs_handle_t *); +void nvs_close(nvs_handle_t); +esp_err_t nvs_get_blob(nvs_handle_t,const char *,void *,size_t *); +esp_err_t nvs_set_blob(nvs_handle_t,const char *,const void *,size_t); +esp_err_t nvs_commit(nvs_handle_t); diff --git a/tests/runtime/rule_pack/stubs/ts_action_manager.h b/tests/runtime/rule_pack/stubs/ts_action_manager.h new file mode 100644 index 0000000..bbe2f38 --- /dev/null +++ b/tests/runtime/rule_pack/stubs/ts_action_manager.h @@ -0,0 +1,14 @@ +#pragma once +#include "ts_automation_types.h" +typedef struct { + char id[TS_AUTO_NAME_MAX_LEN], name[TS_AUTO_LABEL_MAX_LEN], description[64]; + ts_auto_action_t action; bool enabled,async; + int64_t created_at,last_used_at; uint32_t use_count; +} ts_action_template_t; +esp_err_t ts_action_template_get(const char *, ts_action_template_t *); +bool ts_action_templates_ready(void); +typedef struct { + char id[TS_AUTO_NAME_MAX_LEN],host[64];uint16_t port;char username[32],password[64],key_path[96];bool use_key_auth; +} ts_action_ssh_host_t; +esp_err_t ts_action_get_ssh_host(const char *,ts_action_ssh_host_t *); +esp_err_t ts_action_get_ssh_host_ex(const char *,ts_action_ssh_host_t *,bool *); diff --git a/tests/runtime/rule_pack/stubs/ts_core.h b/tests/runtime/rule_pack/stubs/ts_core.h new file mode 100644 index 0000000..fa22baf --- /dev/null +++ b/tests/runtime/rule_pack/stubs/ts_core.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include +#include +#define TS_MALLOC_PSRAM malloc +#define TS_CALLOC_PSRAM calloc +#define TS_STRDUP_PSRAM strdup +#define MALLOC_CAP_SPIRAM 1 +#define MALLOC_CAP_8BIT 2 +#define heap_caps_malloc(n,c) malloc(n) +#define heap_caps_calloc(n,s,c) calloc(n,s) diff --git a/tests/runtime/rule_pack/test_crypto.c b/tests/runtime/rule_pack/test_crypto.c new file mode 100644 index 0000000..5e367a1 --- /dev/null +++ b/tests/runtime/rule_pack/test_crypto.c @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include "ts_config_pack.h" +#include "ts_crypto.h" +#include "ts_cert.h" +#include "mbedtls/x509_crt.h" +#include "mbedtls/oid.h" +#include +static time_t fixture_time; +time_t time(time_t *out){time_t now=fixture_time?fixture_time:TEST_TIME;if(out)*out=now;return now;} +static char *device_key, *device_cert, *ca; +static uint32_t generation=1; +static bool trusted_time=true; +static char *read_file(const char *dir,const char *name) { + char path[512];snprintf(path,sizeof path,"%s/%s",dir,name); + FILE *f=fopen(path,"rb");assert(f);fseek(f,0,SEEK_END);long n=ftell(f);rewind(f); + char *s=malloc(n+1);assert(fread(s,1,n,f)==(size_t)n);s[n]=0;fclose(f);return s; +} +static esp_err_t copy(const char *s,char *out,size_t *n) { + size_t len=strlen(s)+1;if(*nnext){ + char *out=NULL;size_t cap=0; + if(!MBEDTLS_OID_CMP(MBEDTLS_OID_AT_ORG_UNIT,&p->oid)){out=info->subject_ou;cap=sizeof info->subject_ou;} + if(!MBEDTLS_OID_CMP(MBEDTLS_OID_AT_CN,&p->oid)){out=info->subject_cn;cap=sizeof info->subject_cn;} + if(out){size_t len=p->val.lenval.len:cap-1;memcpy(out,p->val.p,len);out[len]=0;} + } + mbedtls_x509_crt_free(&c);return ESP_OK; +} +esp_err_t ts_cert_get_info(ts_cert_info_t *i){return ts_cert_parse_certificate(device_cert,strlen(device_cert)+1,i);} +esp_err_t ts_cert_get_status(ts_cert_pki_status_t *s){memset(s,0,sizeof *s);s->time_ready=trusted_time;s->generation=generation;return ESP_OK;} +esp_err_t ts_cert_get_pack_snapshot(ts_cert_snapshot_t *s){ + memset(s,0,sizeof *s);s->key=strdup(device_key);s->certificate=strdup(device_cert);s->ca=strdup(ca);s->generation=generation;return ESP_OK; +} +void ts_cert_free_snapshot(ts_cert_snapshot_t *s){if(s->key)memset(s->key,0,strlen(s->key));free(s->key);free(s->certificate);free(s->ca);memset(s,0,sizeof *s);} +int main(int argc,char **argv) { + assert(argc==2);const char *dir=argv[1]; + device_key=read_file(dir,"developer.key");device_cert=read_file(dir,"developer.pem");ca=read_file(dir,"root.pem"); + assert(ts_config_pack_init()==ESP_OK); + char *recipient=read_file(dir,"recipient.pem"); + const char *content="{\"type\":\"automation_rule\",\"rule\":{\"id\":\"night\",\"name\":\"Night\",\"enabled\":true,\"actions\":[{\"type\":\"log\",\"message\":\"test\"}]}}"; + ts_config_pack_export_opts_t opts={.recipient_cert_pem=recipient,.recipient_cert_len=strlen(recipient)}; +#ifndef PACK_BASELINE + char *exact=malloc(opts.recipient_cert_len);memcpy(exact,recipient,opts.recipient_cert_len);opts.recipient_cert_pem=exact; +#endif + char *bytes=NULL;size_t length=0; + ts_config_pack_result_t made=ts_config_pack_create("ignored-name",content,strlen(content),&opts,&bytes,&length); +#ifndef PACK_BASELINE + free(exact); +#endif +#ifdef PACK_BASELINE + printf("baseline explicit recipient PEM length: create=%d (expected OK)\n",made); + fflush(stdout); + free(bytes);free(recipient);free(device_key);free(device_cert);free(ca); + assert(made==TS_CONFIG_PACK_OK); +#elif defined(PACK_UNCONFIGURED) + assert(made==TS_CONFIG_PACK_OK); + free(device_key);free(device_cert);device_key=read_file(dir,"recipient.key");device_cert=strdup(recipient); + ts_config_pack_t *pack=NULL; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_TRUST_NOT_CONFIGURED&&!pack); + free(bytes);free(recipient);free(device_key);free(device_cert);free(ca); + puts("PASS unconfigured signing root refuses a cryptographically valid package"); +#else + assert(made==TS_CONFIG_PACK_OK&&bytes&&length==strlen(bytes)); + ts_config_pack_export_opts_t self_opts={.recipient_cert_pem=device_cert,.recipient_cert_len=strlen(device_cert)+1}; + char *self=NULL;size_t self_len=0;ts_config_pack_t *self_pack=NULL; + assert(ts_config_pack_create("self",content,strlen(content),&self_opts,&self,&self_len)==TS_CONFIG_PACK_OK); + assert(ts_config_pack_load_verified_mem(self,self_len,NULL,&self_pack,NULL,NULL)==TS_CONFIG_PACK_OK&&!strcmp(self_pack->content,content)); + ts_config_pack_free(self_pack);free(self); + free(device_key);free(device_cert);device_key=read_file(dir,"recipient.key");device_cert=strdup(recipient); + ts_config_pack_t *pack=NULL;ts_config_pack_acceptance_t accepted={0};uint32_t gen=0; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,&accepted,&gen)==TS_CONFIG_PACK_OK); + assert(gen==generation&&!strcmp(pack->content,content));ts_config_pack_free(pack); + /* Expiry is checked when first accepting; accepted exact bytes may reload. */ + fixture_time=TEST_TIME+4000LL*86400; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_CERT_CHAIN); + assert(ts_config_pack_load_verified_mem(bytes,length,&accepted,&pack,NULL,NULL)==TS_CONFIG_PACK_OK);ts_config_pack_free(pack); + fixture_time=0; + ts_config_pack_acceptance_t changed=accepted;changed.policy++; + assert(ts_config_pack_load_verified_mem(bytes,length,&changed,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_CERT_CHAIN); + changed=accepted;changed.root_sha256[0]^=1; + assert(ts_config_pack_load_verified_mem(bytes,length,&changed,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_CERT_CHAIN); + trusted_time=false; + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_TIME_UNVERIFIED); + assert(ts_config_pack_load_verified_mem(bytes,length,&accepted,&pack,NULL,NULL)==TS_CONFIG_PACK_OK);ts_config_pack_free(pack); + trusted_time=true; + char *save=device_cert;device_cert=read_file(dir,"other.pem"); + assert(ts_config_pack_load_verified_mem(bytes,length,NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_RECIPIENT); + free(device_cert);device_cert=save; + const char *unauthorized[]={"not-developer.pem","tls-developer.pem","no-sign.pem","p384.pem","duplicate-role.pem"}; + for(unsigned i=0;ivaluestring;tag[0]=tag[0]=='A'?'B':'A';bad=cJSON_PrintUnformatted(j); + assert(ts_config_pack_load_verified_mem(bad,strlen(bad),NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_DECRYPT); + free(bad);cJSON_Delete(j); + j=cJSON_Parse(bytes);enc=cJSON_GetObjectItem(j,"encryption"); + cJSON_ReplaceItemInObject(enc,"iv",cJSON_CreateString("AA=="));bad=cJSON_PrintUnformatted(j); + assert(ts_config_pack_load_verified_mem(bad,strlen(bad),NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_PARSE); + free(bad);cJSON_Delete(j); + assert(!ts_config_pack_parse_json("{\"x\":1,\"x\":2}",13)); + assert(!ts_config_pack_parse_json("{} {}",5)); + assert(!ts_config_pack_parse_json("{\"x\":\"\\u0000\"}",14)); + assert(!ts_config_pack_parse_import_request("{\"tscfg\":\"ok\\u0000tail\"}",24)); + assert(!ts_config_pack_parse_import_request("{\"preview\":true,\"preview\":false}",32)); + cJSON *empty=ts_config_pack_parse_json("{}",2);assert(empty);cJSON_Delete(empty); + j=cJSON_Parse(bytes);char *cipher=cJSON_GetObjectItem(j,"payload")->valuestring; + cipher[0]=cipher[0]=='A'?'B':'A';bad=cJSON_PrintUnformatted(j); + assert(ts_config_pack_load_verified_mem(bad,strlen(bad),NULL,&pack,NULL,NULL)==TS_CONFIG_PACK_ERR_SIGNATURE); + free(bad);cJSON_Delete(j); + free(bytes);free(recipient);free(device_key);free(device_cert);free(ca); + puts("PASS real production create/trust/signature/ECDH/HKDF/GCM, cross-device PEM, offline accepted reload, malformed inputs"); +#endif + return 0; +} diff --git a/tests/runtime/rule_pack/test_engine_pack.c b/tests/runtime/rule_pack/test_engine_pack.c new file mode 100644 index 0000000..0d5c155 --- /dev/null +++ b/tests/runtime/rule_pack/test_engine_pack.c @@ -0,0 +1,137 @@ +/* Production engine functions + real codec, crypto and complete-set storage. + * Only hardware/RTOS, credentials and NVS are fixture adapters. */ +#define RULE_ENGINE_INTEGRATION +#include "test_store_set.c" +#include +#include "ts_rule_engine.h" +#include "ts_rule_pack.h" +#define pdTRUE 1 +typedef struct {int code;char *message;cJSON *data;} ts_api_result_t; +#define TS_API_OK 0 +#define TS_API_ERR_INTERNAL 500 +static void ts_api_result_error(ts_api_result_t *r,int code,const char *why){r->code=code;r->message=strdup(why);} +typedef pthread_mutex_t *SemaphoreHandle_t; +#define portMAX_DELAY 0xffffffff +static int xSemaphoreTake(SemaphoreHandle_t m,unsigned timeout){return !pthread_mutex_lock(m);} +static int xSemaphoreGive(SemaphoreHandle_t m){return !pthread_mutex_unlock(m);} +#define xSemaphoreTakeRecursive xSemaphoreTake +#define xSemaphoreGiveRecursive xSemaphoreGive +static pthread_mutex_t binding,lock,transaction; +static bool sd_mounted=true; +void ts_ssh_binding_lock(void){assert(!pthread_mutex_lock(&binding));} +void ts_ssh_binding_unlock(void){assert(!pthread_mutex_unlock(&binding));} +bool ts_storage_sd_mounted(void){return sd_mounted;} +bool ts_ssh_service_rule_protected(const char *id){return false;} +bool ts_ssh_service_command_protected(const char *id){return false;} +bool ts_ssh_service_any_in_use(void){return false;} +bool ts_cert_material_begin(uint32_t wanted){return wanted==generation;} +void ts_cert_material_end(void){} +esp_err_t ts_rule_store_commit(const ts_auto_rule_t *r,int n,const ts_auto_rule_t *c,const char *id,int source,ts_rule_commit_t *out){assert(!"legacy writer must not run");return ESP_FAIL;} +esp_err_t ts_rule_store_recover(bool sd,bool *stage,bool *required){assert(!"selected-set load must not fall back to legacy");return ESP_FAIL;} +esp_err_t ts_rule_store_load_bank(ts_auto_rule_t *r,int cap,int *count){assert(0);return ESP_FAIL;} +static esp_err_t load_legacy_file(const char *p,ts_auto_rule_t *r,int *n){assert(0);return ESP_FAIL;} +static esp_err_t load_legacy_nvs(ts_auto_rule_t *r,int *n){assert(0);return ESP_FAIL;} +static esp_err_t load_directory(ts_auto_rule_t *r,int *n,bool *ro){assert(0);return ESP_FAIL;} +#define RULES_SDCARD_DIR DIR_RULES +static int find_rule_index(const char *id); +#include "pack_engine.inc" +static void boot(void){ + for(int i=0;ivalueint,cJSON_GetObjectItem(pre,"expected_generation")->valueint, + cJSON_GetObjectItem(pre,"credential_generation")->valueint,cJSON_GetObjectItem(pre,"package_digest")->valuestring,data,reason); +} +int main(int argc,char **argv){ + assert(argc==2);const char *dir=argv[1];pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE); + pthread_mutex_init(&binding,&attr);pthread_mutex_init(&transaction,&attr);pthread_mutex_init(&lock,NULL);pthread_mutexattr_destroy(&attr); + ca=read_file(dir,"root.pem");device_key=read_file(dir,"recipient.key");device_cert=read_file(dir,"recipient.pem");assert(ts_config_pack_init()==ESP_OK); + reset_disk();ts_auto_rule_t a=ordinary("a","Old A"),b=ordinary("b","B");ts_rule_commit_t stored; + assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); + assert(ts_rule_store_set_commit(&a,"a",0,0,NULL,0,NULL,&stored)==ESP_OK); + assert(ts_rule_store_set_commit(&b,"b",0,1,NULL,0,NULL,&stored)==ESP_OK);boot(); + char *raw=make_pack(dir,"a","Imported A",true);cJSON *pre=preview(raw),*data=NULL;const char *reason; + templates_ready=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_loading"));cJSON_Delete(data);data=NULL;templates_ready=true; + template_enabled=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_disabled"));cJSON_Delete(data);data=NULL;template_enabled=true; + ts_auto_rule_t held;assert(ts_rule_acquire("a",&held)==ESP_OK); + fail_selector_read=true; + assert(save(raw,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"commit_unknown")); + assert(s_rule_ctx.recovery_error&&!strcmp(s_rule_ctx.rules[0].name,"Old A")); + cJSON_Delete(data);data=NULL;fail_selector_read=false; + assert(ts_rule_refresh_saved()==ESP_OK&&!s_rule_ctx.recovery_error&&ts_rule_restart_pending()); + ts_api_result_t view={0};assert(api_automation_rules_list(NULL,&view)==ESP_OK&&view.code==TS_API_OK); + cJSON *rows=cJSON_GetObjectItem(view.data,"rules");assert(cJSON_GetArraySize(rows)==2); + cJSON *first=cJSON_GetArrayItem(rows,0);assert(cJSON_GetObjectItem(first,"revision")->valueint==1&&cJSON_GetObjectItem(first,"saved_revision")->valueint==2&&cJSON_IsTrue(cJSON_GetObjectItem(first,"restart_required"))); + assert(!strcmp(cJSON_GetObjectItem(first,"package_digest")->valuestring,cJSON_GetObjectItem(pre,"package_digest")->valuestring)); + cJSON_Delete(view.data);free(view.message); + assert(save(raw,pre,&reason,&data)==ESP_OK);assert(cJSON_IsFalse(cJSON_GetObjectItem(data,"runtime_applied")));cJSON_Delete(data);data=NULL; + assert(!strcmp(s_rule_ctx.rules[0].name,"Old A")&&s_rule_ctx.rules[0].revision==1&&!strcmp(held.name,"Old A")); + assert(ts_rule_restart_pending());ts_rule_commit_result_t result; + assert(ts_rule_commit(&a,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rules_load()!=ESP_OK&&ts_rules_load_from_file("anything")!=ESP_OK&&ts_rule_engine_deinit()!=ESP_OK); + ts_rule_release(&held);strcpy(b.name,"Edited B");assert(ts_rule_commit(&b,"b",1,&result)==ESP_OK&&s_rule_ctx.rules[1].revision==2); + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",NULL)!=ESP_OK); + ts_action_template_t tpl={.enabled=false,.action={.type=TS_AUTO_ACT_LOG}};assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&tpl)!=ESP_OK);tpl.enabled=true;assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&tpl)==ESP_OK); + trusted_time=false;assert(save(raw,pre,&reason,&data)==ESP_OK&&!strcmp(reason,"no_change"));cJSON_Delete(data);data=NULL;trusted_time=true;cJSON_Delete(pre); + command_exists=host_exists=true; + char *newraw=make_pack(dir,"new","New",2);pre=preview(newraw); + ++generation;assert(save(newraw,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"credential_changed"));cJSON_Delete(data);data=NULL;--generation; + strcpy(b.name,"B changed after preview");assert(ts_rule_commit(&b,"b",2,&result)==ESP_OK); + assert(save(newraw,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"revision_conflict"));cJSON_Delete(data);data=NULL;cJSON_Delete(pre);pre=preview(newraw); + assert(save(newraw,pre,&reason,&data)==ESP_OK);cJSON_Delete(data);data=NULL;cJSON_Delete(pre); + assert(s_rule_ctx.count==2&&ts_rule_store_set_count()==3); + assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",NULL)!=ESP_OK); + ts_ssh_command_config_t cmd;assert(ts_ssh_commands_config_get("command",&cmd)==ESP_OK);cmd.enabled=false; + assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",&cmd)!=ESP_OK);cmd.enabled=true; + strcpy(cmd.host_id,"missing");assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",&cmd)!=ESP_OK);strcpy(cmd.host_id,"host"); + assert(ts_rule_dependency_change(TS_RULE_DEP_COMMAND,"command",&cmd)==ESP_OK); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)!=ESP_OK); + internal_host=true;assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)==ESP_OK);internal_host=false; + /* Dependency preflight follows template priority and explicit readiness. */ + ts_auto_action_t ref={.type=TS_AUTO_ACT_SSH_CMD_REF};strcpy(ref.ssh_ref.cmd_id,"command"); + ts_auto_rule_t check=ordinary("check","Check");check.actions=&ref;cJSON *warnings=NULL; + command_state=ESP_ERR_INVALID_STATE;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_loading")); + command_state=ESP_FAIL;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_unavailable"));command_state=ESP_OK; + host_state=ESP_ERR_INVALID_STATE;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_loading")); + internal_host=true;assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK);internal_host=false;host_state=ESP_OK; + command_enabled=false;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_disabled")); + check.enabled=false;assert(ts_rule_pack_dependencies(&check,&reason,&warnings)==ESP_OK&&cJSON_GetArraySize(warnings)==1);cJSON_Delete(warnings);command_enabled=true; + check.enabled=true;strcpy(ref.template_id,"template");command_exists=false;assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK);command_exists=true; + cJSON *pending=ts_rule_pending_list();assert(cJSON_GetArraySize(pending)==1&&!strcmp(cJSON_GetObjectItem(cJSON_GetArrayItem(pending,0),"id")->valuestring,"new"));cJSON_Delete(pending); + ts_auto_rule_t c=ordinary("c","C");assert(ts_rule_commit(&c,"c",0,&result)==ESP_OK); + char *aba=make_pack(dir,"c","ABA C",0);pre=preview(aba); + assert(ts_rule_commit(NULL,"c",1,&result)==ESP_OK&&ts_rule_commit(&c,"c",0,&result)==ESP_OK); + assert(save(aba,pre,&reason,&data)!=ESP_OK&&!strcmp(reason,"revision_conflict"));cJSON_Delete(data);data=NULL;cJSON_Delete(pre);free(aba); + ts_auto_rule_t d=ordinary("d","D");assert(ts_rule_commit(&d,"d",0,&result)!=ESP_OK&&!strcmp(result.error_code,"capacity")); + trusted_time=false;boot();assert(!ts_rule_restart_pending()&&s_rule_ctx.count==4&&!strcmp(s_rule_ctx.rules[0].name,"Imported A")); + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",NULL)!=ESP_OK); + sd_mounted=false;assert(ts_rules_load()!=ESP_OK&&!s_rule_ctx.loaded&&s_rule_ctx.recovery_error);sd_mounted=true;assert(ts_rules_load()==ESP_OK&&s_rule_ctx.count==4); + assert(ts_rule_engine_deinit()==ESP_OK);ts_rule_store_set_reset();reset_disk(); + free(raw);free(newraw);free(device_key);free(device_cert);free(ca); + pthread_mutex_destroy(&binding);pthread_mutex_destroy(&transaction);pthread_mutex_destroy(&lock); + puts("PASS production import + engine + crypto + store: saved/active isolation, held old lease, pending edit/reload/deinit guards, unrelated merge, CAS/credential conflicts, offline idempotence/boot, dependency union, capacity and missing SD recovery"); +} diff --git a/tests/runtime/rule_pack/test_store_set.c b/tests/runtime/rule_pack/test_store_set.c new file mode 100644 index 0000000..9fe65e6 --- /dev/null +++ b/tests/runtime/rule_pack/test_store_set.c @@ -0,0 +1,242 @@ +#define main crypto_checks +#include "test_crypto.c" +#undef main +#include +#include +#include +#include +#include +#include "ts_rule_codec.h" +#include "ts_action_manager.h" +#include "ts_ssh_commands_config.h" +#include "ts_ssh_hosts_config.h" +#include "nvs.h" +static bool templates_ready=true, template_exists=true, template_enabled=true; +static bool command_exists,command_enabled=true,host_exists,internal_host; +static esp_err_t command_state=ESP_OK,host_state=ESP_OK; +static ts_auto_action_t template_action={.type=TS_AUTO_ACT_LOG}; +esp_err_t ts_action_template_get(const char *id,ts_action_template_t *t){ + if(!template_exists||strcmp(id,"template"))return ESP_ERR_NOT_FOUND; + memset(t,0,sizeof *t);strcpy(t->id,id);t->enabled=template_enabled;t->action=template_action;return ESP_OK; +} +bool ts_action_templates_ready(void){return templates_ready;} +bool ts_ssh_commands_config_is_initialized(void){return true;} +bool ts_ssh_hosts_config_is_initialized(void){return true;} +esp_err_t ts_ssh_commands_config_load_state(void){return command_state;} +esp_err_t ts_ssh_hosts_config_load_state(void){return host_state;} +esp_err_t ts_ssh_commands_config_get(const char *id,ts_ssh_command_config_t *c){if(!command_exists||strcmp(id,"command"))return ESP_ERR_NOT_FOUND;memset(c,0,sizeof *c);strcpy(c->id,id);strcpy(c->host_id,"host");c->enabled=command_enabled;return ESP_OK;} +esp_err_t ts_ssh_hosts_config_get(const char *id,ts_ssh_host_config_t *h){if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;h->enabled=false;return ESP_OK;} +esp_err_t ts_action_get_ssh_host_ex(const char *id,ts_action_ssh_host_t *h,bool *internal){*internal=internal_host;if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;return ESP_OK;} +esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *h){bool internal;return ts_action_get_ssh_host_ex(id,h,&internal);} +typedef struct {char key[24];void *data;size_t size;} value_t; +static value_t values[160]; +static unsigned operation, fault, mode; +static bool fail_selector_read; +static jmp_buf crash; +static bool before(void){ + ++operation; + if(operation!=fault)return false; + if(mode==3)longjmp(crash,1); + return mode==1; +} +static bool after(void){ + if(operation!=fault)return false; + if(mode==4)longjmp(crash,1); + return mode==2; +} +esp_err_t nvs_open(const char *ns,int access,nvs_handle_t *h){*h=1;return before()||after()?ESP_FAIL:ESP_OK;} +void nvs_close(nvs_handle_t h){} +esp_err_t nvs_commit(nvs_handle_t h){return before()||after()?ESP_FAIL:ESP_OK;} +static value_t *value(const char *key,bool add){ + for(unsigned i=0;i<160;++i)if(!strcmp(values[i].key,key))return &values[i]; + if(add)for(unsigned i=0;i<160;++i)if(!values[i].key[0]){strcpy(values[i].key,key);return &values[i];} + return NULL; +} +esp_err_t nvs_get_blob(nvs_handle_t h,const char *key,void *out,size_t *n){ + if(fail_selector_read&&!strcmp(key,"set_select"))return ESP_FAIL; + if(before())return ESP_FAIL;value_t *v=value(key,false);if(!v)return ESP_ERR_NVS_NOT_FOUND; + if(out){if(*nsize)return ESP_ERR_INVALID_SIZE;memcpy(out,v->data,v->size);}*n=v->size; + return after()?ESP_FAIL:ESP_OK; +} +esp_err_t nvs_set_blob(nvs_handle_t h,const char *key,const void *bytes,size_t n){ + if(before())return ESP_FAIL;value_t *v=value(key,true);if(!v)return ESP_ERR_NO_MEM; + free(v->data);v->data=malloc(n);assert(v->data);memcpy(v->data,bytes,n);v->size=n; + return after()?ESP_FAIL:ESP_OK; +} +static size_t write_fault(const void *p,size_t s,size_t n,FILE *f){ + if(before())return 0; + if(operation==fault&&mode==5){size_t part=n/2;fwrite(p,s,part,f);return part;} + size_t result=fwrite(p,s,n,f);return after()?0:result; +} +static FILE *open_fault(const char *path,const char *access){ + if(before())return NULL;FILE *f=fopen(path,access);if(after()){if(f)fclose(f);return NULL;}return f; +} +static size_t read_fault(void *p,size_t s,size_t n,FILE *f){ + if(before())return 0;if(operation==fault&&mode==5)return fread(p,s,n/2,f); + size_t r=fread(p,s,n,f);return after()?0:r; +} +static int seek_fault(FILE *f,long offset,int origin){if(before())return -1;int r=fseek(f,offset,origin);return after()?-1:r;} +static long tell_fault(FILE *f){if(before())return -1;long r=ftell(f);return after()?-1:r;} +static int flush_fault(FILE *f){if(before())return -1;int r=fflush(f);return after()?-1:r;} +static int sync_fault(int fd){if(before())return -1;int r=fsync(fd);return after()?-1:r;} +static int close_fault(FILE *f){bool fail=before();int r=fclose(f);return fail||after()?-1:r;} +static int unlink_fault(const char *p){if(before()){errno=EIO;return -1;}int r=unlink(p);if(after()){errno=EIO;return -1;}return r;} +#define fwrite write_fault +#define fopen open_fault +#define fread read_fault +#define fseek seek_fault +#define ftell tell_fault +#define fflush flush_fault +#define fsync sync_fault +#define fclose close_fault +#define unlink unlink_fault +#define DIR_RULES "/tmp/tianshan-rule-pack-tests/rules" +#include "../../../components/ts_automation/src/ts_rule_store_set.c" +#undef fwrite +#undef fopen +#undef fread +#undef fseek +#undef ftell +#undef fflush +#undef fsync +#undef fclose +#undef unlink +static void reset_disk(void){ + fail_selector_read=false; + fault=mode=operation=0;ts_rule_store_set_reset(); + for(unsigned i=0;i<160;++i){free(values[i].data);memset(&values[i],0,sizeof values[i]);} + DIR *d=opendir(OBJECT_DIR);struct dirent *e; + if(d){while((e=readdir(d)))if(e->d_name[0]!='.'){char p[512];snprintf(p,sizeof p,OBJECT_DIR "/%s",e->d_name);assert(!unlink(p));}closedir(d);} +} +static ts_auto_rule_t ordinary(const char *id,const char *name){ + static ts_auto_action_t action={.type=TS_AUTO_ACT_LOG}; + ts_auto_rule_t r={.actions=&action,.action_count=1,.enabled=true,.revision=1}; + strcpy(r.id,id);strcpy(r.name,name);return r; +} +static void boot_expect(bool has_pack){ + fault=mode=operation=0;ts_rule_store_set_reset(); + ts_auto_rule_t rules[4]={0};bool ro[4]={0};int count=0; + assert(ts_rule_store_set_load(true,rules,ro,4,&count)==ESP_OK); + assert(count==(has_pack?3:2)); + bool a=false,b=false,c=false; + for(int i=0;icontent,decoded->content_len,&actual)==ESP_OK); + ts_config_pack_free(decoded); + /* Legacy seeding re-reads the original file after verification. A changed + * file must not receive an acceptance record for different bytes. */ + char *legacy_changed=malloc(length+2);memcpy(legacy_changed,raw,length);legacy_changed[length]=' ';legacy_changed[length+1]=0; + assert(!new_record(&actual,TS_RULE_SET_SD_PACK,true,"legacy.tscfg",legacy_changed,length+1,&accepted)); + free(legacy_changed); + ts_auto_rule_t initial[]={ordinary("a","A"),ordinary("b","B")};bool readonly[]={false,false}; + unsigned cases=0; + for(unsigned m=1;m<=5;++m)for(unsigned step=1;step<160;++step){ + reset_disk();assert(ts_rule_store_set_adopt(initial,2,TS_RULE_SOURCE_NVS,readonly)==ESP_OK); + ts_rule_commit_t result;ts_auto_rule_t a=initial[0]; + /* Establish complete old set before injecting the import. */ + assert(ts_rule_store_set_commit(&a,"a",1,0,NULL,0,NULL,&result)==ESP_OK); + initial[0].revision=2; + /* Use a fresh A/B at revision 1 for assertions; establish via seed without mutation increment. */ + reset_disk();initial[0].revision=1; + assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); + assert(ts_rule_store_set_commit(&initial[0],"a",0,0,NULL,0,NULL,&result)==ESP_OK); + assert(ts_rule_store_set_commit(&initial[1],"b",0,1,NULL,0,NULL,&result)==ESP_OK); + operation=0;fault=step;mode=m; + esp_err_t ret=ESP_FAIL; + if(!setjmp(crash))ret=ts_rule_store_set_commit(&actual,actual.id,0,2,raw,length,&accepted,&result); + fault=mode=0; + value_t *selector=value("set_select",false);assert(selector&&selector->size==sizeof(set_selector_t)); + set_selector_t durable;memcpy(&durable,selector->data,sizeof durable); + assert(selector_valid(&durable));bool committed=durable.generation==3;assert(committed||durable.generation==2); + if(m!=3&&m!=4&&ret==ESP_OK)assert(committed&&result.durable==1); + trusted_time=false;boot_expect(committed);boot_expect(committed);trusted_time=true; + ++cases; + } + reset_disk(); + assert(ts_rule_store_set_adopt(initial,2,TS_RULE_SOURCE_NVS,readonly)==ESP_OK); + ts_rule_commit_t result;assert(ts_rule_store_set_commit(&actual,actual.id,0,0,raw,length,&accepted,&result)==ESP_OK); + assert(ts_rule_store_set_count()==3); + uint32_t gen=ts_rule_store_set_generation(); + assert(ts_rule_store_set_commit(&actual,actual.id,1,gen,raw,length,&accepted,&result)==ESP_OK); + assert(ts_rule_store_set_generation()==gen&&!strcmp(result.error_code,"no_change")); + ts_auto_rule_t edited=ordinary("a","Changed A"); + assert(ts_rule_store_set_commit(&edited,"a",1,gen,NULL,0,NULL,&result)==ESP_OK); + ts_auto_rule_t restored[4]={0};bool ro[4];int n; + ts_rule_store_set_reset();assert(ts_rule_store_set_load(true,restored,ro,4,&n)==ESP_OK&&n==3); + for(int i=0;i real pack -> complete-set store -> offline boot: %u save, %u migration and %u recovery fault cases; full sources/revisions/readonly, idempotence, unrelated edit, SD absence and same-generation JSON mirror\n",cases,migrations,recovery_cases); +} +#endif diff --git a/tests/runtime/rule_pack_contract.txt b/tests/runtime/rule_pack_contract.txt new file mode 100644 index 0000000..77df358 --- /dev/null +++ b/tests/runtime/rule_pack_contract.txt @@ -0,0 +1,42 @@ +Implementation base: main at bdc0354b; independent branch fix/rule-pack-import-standalone. +Claude prompt fixes remain separate; only his one-line datasource toggle routing fix is included with independent click tests. +The existing dirty source-switch test and main's untracked guides remain in their original worktrees. + +Save-only contract: imported packs alter the durable set; the active set and leased payloads remain unchanged until boot. +Authority: one versioned selector chooses one bounded complete bank. Each record has a local revision, source, +readonly reason, exact bytes hash/length, managed object name, and (for packs) a local acceptance record. +Existing ordinary records stay ordinary. Imported pack plaintext is never added to a new NVS mirror. +Legacy transactions are recovered before migration. Legacy authority is retained until the new selector is confirmed. +Boot migration commits the fully validated legacy set without incrementing individual revisions. Legacy encrypted +packages receive a durable acceptance record and immutable byte copy before being published, so a later offline +boot does not lose the acceptance evidence. Custom read-only single-file sources retain their original policy. +Ordinary SD JSON records retain their same-generation inline NVS mirror and SD source on edits; new COW object +paths are internal. Legacy visible filenames remain untouched and are no longer authoritative after selection. + +Commit states: +1. Prepare immutable managed objects outside the legacy scanner, sync/close and verify exact bytes. +2. Write and verify every inactive-bank record and its aggregate metadata. +3. Write the sole selector, commit, and read it back. A confirmed new selector commits the complete new set. +4. Confirmed old selector means not committed; unreadable/ambiguous selector means commit_unknown and closes writes. +5. Post-commit object cleanup cannot roll back authority; cleanup is bounded and repeatable. +Boot chooses precisely the selected set. No fallback to older banks, directory shadows or decrypted pack mirrors. +Healthy storage after an injected failure must recover the selected old/new set; permanent loss reports failure. + +Trust: explicitly configured signing-root digest must match a root in the installed CA material; exact Developer OU +and applicable certificate usage must authorize signing. New imports require trusted time. Accepted package bytes, +root/signer/device identity and policy are stored with the record so unchanged packages can reload offline after expiry. +The v1 recipient fingerprint remains the trimmed-PEM hash. Root/recipient/policy change invalidates acceptance. +An unset signing-root configuration is a deployment prerequisite, never a reason to bypass verification. +Deployment facts not established here: the real device's installed signing root, Developer certificate usage, +trusted clock and available NVS/SD space. No CA, credential, device setting or firmware was changed by testing. +There is no promised downgrade after committing v3 sets: older releases do not understand set_select and may scan +stale legacy files/NVS. Retain this firmware or a future v3-aware build; explicit backup/restore is needed before +using an older release. Historical plaintext banks are preserved, not securely erased. + +Fault matrix: fail-before/effect-then-error/crash/short-write/sync-close failure at each persistent operation; FAT +rename refuses occupied targets; bank preparation, selector uncertainty and cleanup/recovery interrupted repeatedly. +Assertions compare the full set, source, revision and readonly state, reset all production memory between boots, +and never exempt healthy-storage failures because recover returned an error. +Run: IDF_PATH=/path/to/esp-idf bash tests/runtime/run_rule_pack.sh (also included by run_all.sh). +The fixture builds the SDK's native Mbed TLS and generates disposable OpenSSL PKI; no installed device credential +is read. Browser tests: node --test tests/prompts/rule-pack.browser.test.cjs with the existing Playwright dependency. diff --git a/tests/runtime/run_all.sh b/tests/runtime/run_all.sh index cba25f4..e67d273 100755 --- a/tests/runtime/run_all.sh +++ b/tests/runtime/run_all.sh @@ -5,6 +5,7 @@ export DEVELOPER_DIR="${DEVELOPER_DIR:-/Library/Developer/CommandLineTools}" for suite in log codec store engine ssh service_watch; do bash "tests/runtime/run_${suite}.sh" done +bash tests/runtime/run_rule_pack.sh python3 tests/runtime/test_completion.py python3 tests/runtime/test_action_submission.py python3 tests/runtime/test_service_control.py diff --git a/tests/runtime/run_rule_pack.sh b/tests/runtime/run_rule_pack.sh new file mode 100755 index 0000000..bd2e222 --- /dev/null +++ b/tests/runtime/run_rule_pack.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Library/Developer/CommandLineTools}" +# Sequential: these production tests intentionally share one disposable NVS/SD fixture. +python3 tests/runtime/test_rule_pack_crypto.py +python3 tests/runtime/test_rule_pack_crypto.py --unconfigured +python3 tests/runtime/test_rule_pack_crypto.py --store +python3 tests/runtime/test_rule_pack_crypto.py --engine diff --git a/tests/runtime/test_action_pack.py b/tests/runtime/test_action_pack.py index de59b67..722c6ca 100644 --- a/tests/runtime/test_action_pack.py +++ b/tests/runtime/test_action_pack.py @@ -9,7 +9,12 @@ def extract(path,name): source=Path(path).read_text();m=re.search(r'^(?:static )?(?:\w+[ \t]+)+[ \t*]*'+name+r'\([^;]+?\)\s*\{',source,re.M);assert m,name return source[m.start():source.index('\n}',m.start())+2]+'\n' +code=code.replace('int template_count;', 'int template_count; bool templates_ready;') code+=r''' +#include "ts_rule_engine.h" +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t k,const char *id,const void *next){return ESP_OK;} +void ts_ssh_binding_lock(void){} +void ts_ssh_binding_unlock(void){} #include #define TS_CONFIG_PACK_OK 0 typedef int ts_config_pack_result_t; @@ -38,6 +43,7 @@ def extract(path,name): esp_err_t nvs_get_u8(nvs_handle_t h,const char *k,uint8_t *count){++fallbacks;*count=0;return ESP_ERR_NOT_FOUND;} esp_err_t nvs_get_str(nvs_handle_t h,const char *k,char *s,size_t *n){++fallbacks;return ESP_ERR_NOT_FOUND;} ''' +code+=extract('components/ts_automation/src/ts_action_manager.c','templates_load_impl') code+=extract('components/ts_automation/src/ts_action_manager.c','ts_action_templates_load') code+=r''' static void put(const char *p,const char *s){FILE*f=fopen(p,"wb");assert(f);assert(fwrite(s,1,strlen(s),f)==strlen(s));fclose(f);} diff --git a/tests/runtime/test_action_submission.py b/tests/runtime/test_action_submission.py index 212a50d..ebc18b1 100644 --- a/tests/runtime/test_action_submission.py +++ b/tests/runtime/test_action_submission.py @@ -39,7 +39,7 @@ def typedef(name): typedef(name) for name in ['action_manager_ctx_t', 'action_binding_t', 'action_completion_t'])) definitions = [function(manager, name) for name in [ 'completion_release', 'ts_action_snapshot_retain', 'ts_action_snapshot_release', - 'ts_action_get_ssh_host', 'snapshot_command', 'ts_action_snapshot', 'action_admit', + 'ts_action_get_ssh_host_ex', 'ts_action_get_ssh_host', 'snapshot_command', 'ts_action_snapshot', 'action_admit', 'action_finished', 'direct_service_finished', 'entry_finished', 'ts_action_manager_accepting', 'ts_action_manager_quiesce', 'ts_action_manager_resume', 'prepare_action_entry', 'action_manager_execute', 'ts_action_manager_execute', diff --git a/tests/runtime/test_configuration_protocol.py b/tests/runtime/test_configuration_protocol.py index a190fba..ee97f7f 100644 --- a/tests/runtime/test_configuration_protocol.py +++ b/tests/runtime/test_configuration_protocol.py @@ -15,6 +15,8 @@ def extract(path,name): s=s.replace('out->port=22;return ESP_OK;}', 'out->port=22;if(interleave)host_interleave();return ESP_OK;}') s=s.replace('(*out)->config=*cfg;++ssh_live;', '(*out)->config=*cfg;strcpy(connected_host,cfg->host);++ssh_live;') s+=''' +#include "ts_rule_engine.h" +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next){return ESP_OK;} static esp_err_t host_add_impl(const ts_ssh_host_config_t *cfg,bool sync_sdcard) {strcpy(configured_host,cfg->host);return ESP_OK;} static esp_err_t command_add_impl(const ts_ssh_command_config_t *cfg,char*out,size_t len) {command=*cfg;return ESP_OK;} static int storage_fail; diff --git a/tests/runtime/test_keystore.py b/tests/runtime/test_keystore.py index 8562560..8f90dd4 100644 --- a/tests/runtime/test_keystore.py +++ b/tests/runtime/test_keystore.py @@ -10,6 +10,9 @@ web_source=(root/'components/ts_webui/src/ts_webui_api.c').read_text() a=web_source.index('static esp_err_t send_key_generate_result(');web_handler=web_source[a:web_source.index('\nstatic esp_err_t',a+1)] a=web_source.index('static esp_err_t api_handler(');web_handler+=web_source[a:web_source.index('\nstatic esp_err_t login_handler',a)] +a=web_source.index('static bool is_binary_upload_complete(');body_complete=web_source[a:web_source.index('\n}',a)+2] +pack_source=(root/'components/ts_config_pack/src/ts_config_pack.c').read_text() +a=pack_source.index('static bool unambiguous_json(');strict_json=pack_source[a:pack_source.index('\nstatic const char *TAG',a)] source=re.sub(r'^#include .*$', '',source,flags=re.M) platform=(root/'tests/certificate/stubs/platform.h').read_text() platform=platform[:platform.index('typedef void *ts_keypair_t;')]+'\n#endif\n' @@ -21,17 +24,21 @@ #include "ts_crypto.h" #include "ts_api.h" #include +#include +#define CONFIG_TS_CONFIG_PACK_MAX_SIZE 65536 #define TS_LOGI(...) ((void)0) #define TS_LOGE(...) ((void)0) #define TS_LOGD(...) ((void)0) #define API_PREFIX "/api/v1" #define TS_API_MALLOC malloc -typedef struct {const char *uri;const char *body;size_t body_len;int method;void *req;} ts_http_request_t; -static size_t httpd_req_get_url_query_len(void *p){return 0;} +typedef struct {size_t content_len,query_len;} test_http_req_t; +typedef struct {const char *uri;const char *body;size_t body_len;int method;test_http_req_t *req;} ts_http_request_t; +static size_t httpd_req_get_url_query_len(void *p){return p?((test_http_req_t *)p)->query_len:0;} static esp_err_t httpd_req_get_url_query_str(void *p,char *b,size_t n){return ESP_FAIL;} esp_err_t ts_http_send_json(ts_http_request_t *,int,const char *); esp_err_t ts_http_send_error(ts_http_request_t *,int,const char *); static int64_t esp_timer_get_time(void){return 0;} +static unsigned rule_dispatch_calls; #include "cJSON.h" static size_t allocation_size_fail; static void *key_alloc(size_t n){return n==allocation_size_fail?NULL:malloc(n);} @@ -92,6 +99,7 @@ esp_err_t ts_crypto_keypair_export_openssh(ts_keypair_t p,char *b,size_t *n,const char *c){if(public_error)return public_error;strcpy(b,"SYNTHETIC");*n=10;return ESP_OK;} esp_err_t ts_api_call(const char *name,const cJSON *p,ts_api_result_t *r){ if(!strcmp(name,"key.generate"))return api_key_generate(p,r); + if(!strcmp(name,"automation.rules.import")){++rule_dispatch_calls;r->code=0;return ESP_OK;} r->code=TS_API_ERR_INTERNAL;r->message=strdup("fixture");r->data=cJSON_CreateObject();cJSON_AddBoolToObject(r->data,"should_omit",true);return ESP_FAIL; } void ts_api_result_error(ts_api_result_t *r,ts_api_result_code_t c,const char *s){r->code=c;r->message=strdup(s);} @@ -179,13 +187,20 @@ req.uri="/api/v1/other/endpoint";assert(api_handler(&req,NULL)==ESP_OK);response=cJSON_Parse(sent_json);assert(!cJSON_GetObjectItem(response,"data"));cJSON_Delete(response); req.uri="/api/v1/key/generate";cJSON_Hooks fail_request={always_fail,free};cJSON_InitHooks(&fail_request);assert(api_handler(&req,NULL)==ESP_OK);cJSON_InitHooks(NULL);assert(send_status==500&&find("new_priv")); r=(ts_api_result_t){.code=0,.data=cJSON_CreateObject()};cJSON_AddBoolToObject(r.data,"generated",true);cJSON_Hooks hooks={always_fail,free};cJSON_InitHooks(&hooks);send_key_generate_result(NULL,&r,"test",0);cJSON_InitHooks(NULL);assert(send_status==500&&find("new_priv")); + const char *invalid[]={"{\"tscfg\":\"{}\\u0000tail\"}","{\"preview\":true,\"preview\":false}","{} {}","[]"}; + test_http_req_t native={0};req=(ts_http_request_t){.uri="/api/v1/automation/rules/import",.req=&native}; + for(unsigned i=0;i\n#include \n#include \n' + entry + '\n' + state code += r''' +#include "ts_rule_engine.h" +esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next){return ESP_OK;} #define NVS_KEY_PREFIX "h_" static char sd_dir[256]; #undef TS_SSH_HOSTS_SDCARD_DIR @@ -40,6 +42,7 @@ def extract(name): static int import_result; static ts_ssh_host_config_t concurrent_host; static TaskHandle_t initial_loader; +static atomic_int initial_load_result=ESP_ERR_INVALID_STATE; static bool s_hosts_pending_export; static int slot(const char *key) { int n=-1; assert(sscanf(key,"h_%d",&n)==1 && n>=0 && n Date: Sat, 10 Oct 2026 09:44:28 +0800 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=E5=A3=B0=E6=98=8E=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=BC=95=E7=94=A8=E4=B8=BB=E6=9C=BA=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=89=80=E9=9C=80=E7=9A=84=20POSIX=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/repair/delete-protection/RESULT.md | 11 +++++++++++ .../delete-protection/evidence/ci-posix-local.log | 2 ++ .../delete-protection/evidence/source-hashes.json | 2 +- tests/runtime/test_delete_reference.py | 3 ++- 4 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 docs/repair/delete-protection/evidence/ci-posix-local.log diff --git a/docs/repair/delete-protection/RESULT.md b/docs/repair/delete-protection/RESULT.md index 2763114..d80d405 100644 --- a/docs/repair/delete-protection/RESULT.md +++ b/docs/repair/delete-protection/RESULT.md @@ -102,3 +102,14 @@ [source-hashes.json](evidence/source-hashes.json)现对应本轮源码;[source-hashes-before-round3.json](evidence/source-hashes-before-round3.json)保留上一轮源码记录,旧工件哈希不变。复审可用[round3.patch](evidence/round3.patch)查看相对上一轮未提交实现的精确增量,而不只与 main 比较。补丁采用无上下文格式,避免空白上下文行被 Git 当作尾随空格;需要应用时使用 git apply --unidiff-zero。 方案无实质偏差,没有需要用户另作决定的事项。固件、通用HTTP出口、锁顺序、committing及api.js/CSS等受保护文件与本轮开始时逐字相同;main源码未改。未提交、推送、开PR、合并或刷机,未修改其他分支/工作目录。 + + +## PR CI 补记:Linux 严格 C11 的 POSIX 接口声明 + +PR #47 的运行 [37992607181](https://github.com/RMinte-AI/TianshanOS/actions/runs/37992607181) 中,WebUI 与固件构建通过,Runtime and fan regression tests 在新增主机测试的编译阶段失败,Release 跳过。 + +失败原因:`test_delete_reference.py` 使用 `-std=c11`,但没有声明所需 POSIX 接口;Ubuntu/glibc 隐藏了 PTHREAD_MUTEX_RECURSIVE 以及 strdup、nanosleep 的声明。macOS 的头文件行为不同,先前本地通过未证明 Linux 兼容。 + +修复仅在该主机测试编译参数中加入 `-D_POSIX_C_SOURCE=200809L`,覆盖它实际使用的 POSIX 接口,不修改固件、测试断言、响应夹具或发布工作流。 + +本地执行 `IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf python3 tests/runtime/test_delete_reference.py` 通过,原生产删除/提交场景和8份实际HTTP字节均通过;[ci-posix-local.log](evidence/ci-posix-local.log)。这里只证明本地主机回归,Linux结果以修复后的远端CI为准。当前源码哈希已同步;round3.patch仍是第三轮修订当时的历史增量,不包含本次CI修复。 diff --git a/docs/repair/delete-protection/evidence/ci-posix-local.log b/docs/repair/delete-protection/evidence/ci-posix-local.log new file mode 100644 index 0000000..c41ad75 --- /dev/null +++ b/docs/repair/delete-protection/evidence/ci-posix-local.log @@ -0,0 +1,2 @@ +PASS production delete + rule commit + HTTP: H01-H14, leases, no side effects, historical multiplicity, committing cleared, 47/63-byte fields +PASS exact HTTP byte fixtures: 8 diff --git a/docs/repair/delete-protection/evidence/source-hashes.json b/docs/repair/delete-protection/evidence/source-hashes.json index 1a96431..b3e8511 100644 --- a/docs/repair/delete-protection/evidence/source-hashes.json +++ b/docs/repair/delete-protection/evidence/source-hashes.json @@ -27,7 +27,7 @@ "tests/runtime/extract_engine.py": "f7917c42055ae1301792c127f3eafe775cff03f457564e474110b2bf5e4e7daf", "tests/runtime/run_all.sh": "a227fb5b280e120ec795dd851dffe0b5725b97c9724ef48fc67f4cd65feebb90", "tests/runtime/test_delete_api.py": "e13035af2b482b2dbd27529d340bdf759ba575f8b2cd96ad9d523474a7e5b7f4", - "tests/runtime/test_delete_reference.py": "73ff90a36df5e17dcfff25944390e8d9421583f38cf600f107c5661cff8ea1d4", + "tests/runtime/test_delete_reference.py": "7aad0c8571393bd1b5e4709cc1da15a626612cc4a3f29caddfed7794a2ea6ad5", "tests/runtime/test_engine.c": "aea606273a6fc0442f07974afae4d25e5ee34fa07ede6f66e8fc4b35c0539b7c", "tests/prompts/static.test.cjs": "be2ea30d0e40ee116a19283add4973b7e4685faf65062f2ad903c6d37563ea94" } diff --git a/tests/runtime/test_delete_reference.py b/tests/runtime/test_delete_reference.py index c575130..1c4c591 100644 --- a/tests/runtime/test_delete_reference.py +++ b/tests/runtime/test_delete_reference.py @@ -144,7 +144,8 @@ def extract(file,name): d=Path(tmp);f=d/'test.c';f.write_text(code);wire=d/'wire';wire.mkdir() env={**os.environ,'DEVELOPER_DIR':os.environ.get('DEVELOPER_DIR','/Library/Developer/CommandLineTools')} includes=['tests/runtime/stubs','tests/certificate/stubs','components/ts_automation/include','components/ts_security/include','components/ts_api/include',str(idf/'components/json/cJSON')] - subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations',*[f'-I{x}' for x in includes],str(f),'components/ts_automation/src/ts_rule_codec.c','components/ts_automation/src/ts_action_filter.c',str(idf/'components/json/cJSON/cJSON.c'),'-lpthread','-lm','-o',str(d/'test')],check=True,env=env) + # The harness uses POSIX mutexes, strdup and nanosleep under strict C11 on Linux. + subprocess.run(['cc','-std=c11','-D_POSIX_C_SOURCE=200809L','-g','-fsanitize=address,undefined','-Wno-deprecated-declarations',*[f'-I{x}' for x in includes],str(f),'components/ts_automation/src/ts_rule_codec.c','components/ts_automation/src/ts_action_filter.c',str(idf/'components/json/cJSON/cJSON.c'),'-lpthread','-lm','-o',str(d/'test')],check=True,env=env) subprocess.run([str(d/'test'),str(wire)],check=True,env=env) fixtures=Path('tests/fixtures/delete-reference') if os.environ.get('UPDATE_FIXTURES')=='1':fixtures.mkdir(parents=True,exist_ok=True) From df540286a671840e03260e995a2a32da4927537c Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 10:19:07 +0800 Subject: [PATCH 6/7] fix(automation): close rule-pack deletion and dependency review gaps Classify active/saved union states and block new admission after confirmed pending deletion while preserving existing runtime objects and leases. Keep ordinary rule loading compatible, retain published source identity, and scope strict dependencies to active and saved package versions. Share effective-action/proposed-object validation for direct/reference SSH, internal-first host fallbacks and unsupported Webhook actions. Add the directed A1-A3, B1-B3 and C1-C3 production-path regressions and bilingual pending-delete/error UI checks. Preserve v3 storage, encrypted trust and save-only/reboot activation. Record red/green evidence and unverified device/deployment prerequisites in R1-R3-review.md. --- components/ts_api/src/ts_api_automation.c | 11 +- .../ts_automation/include/ts_action_manager.h | 1 + .../ts_automation/include/ts_rule_pack.h | 3 + .../ts_automation/src/ts_action_manager.c | 9 +- components/ts_automation/src/ts_rule_engine.c | 211 +++++++++------ components/ts_automation/src/ts_rule_pack.c | 191 ++++++++++---- components/ts_webui/web/js/app.js | 15 +- components/ts_webui/web/js/lang/en-US.js | 2 + components/ts_webui/web/js/lang/zh-CN.js | 2 + tests/prompts/rule-pack.browser.test.cjs | 34 ++- tests/runtime/extract_engine.py | 3 +- tests/runtime/rule_pack/R1-R3-review.md | 125 +++++++++ .../rule_pack/stubs/ts_action_manager.h | 6 + tests/runtime/rule_pack/test_engine_pack.c | 243 +++++++++++++++++- tests/runtime/rule_pack/test_store_set.c | 12 +- tests/runtime/rule_pack_contract.txt | 23 ++ tests/runtime/run_rule_pack.sh | 4 + tests/runtime/test_action_pack.py | 2 +- tests/runtime/test_rule_pack_crypto.py | 22 +- 19 files changed, 766 insertions(+), 153 deletions(-) create mode 100644 tests/runtime/rule_pack/R1-R3-review.md diff --git a/components/ts_api/src/ts_api_automation.c b/components/ts_api/src/ts_api_automation.c index 583184d..a1aa6b1 100644 --- a/components/ts_api/src/ts_api_automation.c +++ b/components/ts_api/src/ts_api_automation.c @@ -716,7 +716,11 @@ static esp_err_t api_automation_rules_trigger(const cJSON *params, ts_api_result result->message = strdup("Rule not found"); } else { result->code = TS_API_ERR_INTERNAL; - result->message = strdup("Failed to trigger rule"); + result->data=cJSON_CreateObject(); + if(result->data)ts_rule_saved_status(id_param->valuestring,result->data); + const cJSON *change=cJSON_GetObjectItemCaseSensitive(result->data,"pending_change"); + result->message = strdup(cJSON_IsString(change)&&!strcmp(change->valuestring,"delete")? + "restart_pending":"Failed to trigger rule"); } return ESP_OK; @@ -731,8 +735,12 @@ static esp_err_t api_automation_rules_trigger(const cJSON *params, ts_api_result */ static esp_err_t api_automation_rules_get(const cJSON *params, ts_api_result_t *result) { const cJSON *id = cJSON_GetObjectItemCaseSensitive(params, "id"); + if(!ts_rule_edit_begin()){ + ts_api_result_error(result,TS_API_ERR_INTERNAL,"configuration_busy");return ESP_OK; + } ts_auto_rule_t rule; if (!cJSON_IsString(id) || ts_rule_acquire(id->valuestring, &rule) != ESP_OK) { + ts_rule_edit_end(); ts_api_result_error(result, TS_API_ERR_NOT_FOUND, "Rule not found"); return ESP_OK; } @@ -741,6 +749,7 @@ static esp_err_t api_automation_rules_get(const cJSON *params, ts_api_result_t * if(result->data)ts_rule_saved_status(rule.id,result->data); ts_rule_release(&rule); result->code = result->data ? TS_API_OK : TS_API_ERR_INTERNAL; + ts_rule_edit_end(); return ESP_OK; } diff --git a/components/ts_automation/include/ts_action_manager.h b/components/ts_automation/include/ts_action_manager.h index ba17668..f8b0fca 100644 --- a/components/ts_automation/include/ts_action_manager.h +++ b/components/ts_automation/include/ts_action_manager.h @@ -162,6 +162,7 @@ esp_err_t ts_action_manager_resume(void); */ bool ts_action_manager_is_initialized(void); bool ts_action_templates_ready(void); +esp_err_t ts_action_templates_load_state(void); /*===========================================================================*/ /* SSH Host Management */ diff --git a/components/ts_automation/include/ts_rule_pack.h b/components/ts_automation/include/ts_rule_pack.h index 49fd714..13faf4e 100644 --- a/components/ts_automation/include/ts_rule_pack.h +++ b/components/ts_automation/include/ts_rule_pack.h @@ -1,9 +1,12 @@ #pragma once #include "ts_config_pack.h" #include "ts_automation_types.h" +#include "ts_rule_engine.h" /* Strict envelope adapter; plain JSON continues to use ts_rule_decode directly. */ esp_err_t ts_rule_pack_decode(const char *content, size_t length, ts_auto_rule_t *rule); const char *ts_rule_pack_error(ts_config_pack_result_t error); /* Caller holds the SSH configuration binding lock. No execution, connection or pin. */ esp_err_t ts_rule_pack_dependencies(const ts_auto_rule_t *rule, const char **reason, cJSON **warnings); +esp_err_t ts_rule_pack_dependency_change(const ts_auto_rule_t *rule, ts_rule_dependency_t kind, + const char *id, const void *next, const char **reason); diff --git a/components/ts_automation/src/ts_action_manager.c b/components/ts_automation/src/ts_action_manager.c index 723a0a5..9bd53b9 100644 --- a/components/ts_automation/src/ts_action_manager.c +++ b/components/ts_automation/src/ts_action_manager.c @@ -123,6 +123,7 @@ typedef struct { /* Initialization state */ bool initialized; atomic_bool templates_ready; + atomic_int templates_load_result; } action_manager_ctx_t; static action_manager_ctx_t *s_ctx = NULL; @@ -290,6 +291,7 @@ esp_err_t ts_action_manager_init(void) goto cleanup; } + s_ctx->templates_load_result=ESP_ERR_INVALID_STATE; s_ctx->initialized = true; /* 延迟加载模板(等待 SD 卡挂载,避免栈溢出)*/ @@ -355,6 +357,9 @@ void ts_action_deferred_load_task(void *arg) } bool ts_action_templates_ready(void) { return s_ctx && s_ctx->initialized && s_ctx->templates_ready; } +esp_err_t ts_action_templates_load_state(void) { + return s_ctx&&s_ctx->initialized?atomic_load(&s_ctx->templates_load_result):ESP_ERR_INVALID_STATE; +} esp_err_t ts_action_manager_deinit(void) { @@ -504,7 +509,7 @@ esp_err_t ts_action_get_ssh_host_ex(const char *host_id, ts_action_ssh_host_t *h return ESP_OK; } - return ESP_ERR_NOT_FOUND; + return ret; } esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *out) { @@ -3331,6 +3336,7 @@ esp_err_t ts_action_templates_load(void) { esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,NULL,NULL); if (ret == ESP_OK) { ret = templates_load_impl(); + s_ctx->templates_load_result=ret; s_ctx->templates_ready = ret == ESP_OK; } ts_ssh_binding_unlock(); @@ -3342,6 +3348,7 @@ esp_err_t ts_action_templates_load_from_file(const char *path) { esp_err_t ret = ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,NULL,NULL); if (ret == ESP_OK) { ret = templates_load_file_impl(path); + s_ctx->templates_load_result=ret; s_ctx->templates_ready = ret == ESP_OK; } ts_ssh_binding_unlock(); diff --git a/components/ts_automation/src/ts_rule_engine.c b/components/ts_automation/src/ts_rule_engine.c index 53e2321..4558bdb 100644 --- a/components/ts_automation/src/ts_rule_engine.c +++ b/components/ts_automation/src/ts_rule_engine.c @@ -92,6 +92,8 @@ typedef struct { SemaphoreHandle_t transaction; struct { bool committing, executing, readonly; + bool pending_delete; + int source; /* Published version's source; -1 means legacy/unknown. */ } meta[CONFIG_TS_AUTOMATION_MAX_RULES]; ts_rule_engine_stats_t stats; // 统计信息 } ts_rule_engine_ctx_t; @@ -104,6 +106,11 @@ static ts_rule_engine_ctx_t s_rule_ctx = { .initialized = false, }; +typedef enum { + RULE_SYNCED, RULE_PENDING_ADD, RULE_PENDING_UPDATE, RULE_PENDING_DELETE, + RULE_ABSENT, RULE_UNKNOWN +} rule_state_t; + typedef struct { unsigned refs; bool retired; @@ -399,7 +406,6 @@ void ts_rule_deferred_load_task(void *arg) esp_err_t previous = ESP_OK; while (s_rule_ctx.initialized) { /* Readiness is explicit. The sampling interval is not evidence of ready. */ - if (!ts_action_templates_ready()) { vTaskDelay(pdMS_TO_TICKS(100)); continue; } esp_err_t ret = ts_rules_load(); if (ret == ESP_OK) { ESP_LOGI(TAG, "Deferred rule loading complete: %d rules", s_rule_ctx.count); @@ -539,10 +545,50 @@ static bool protected_bindings(const ts_auto_rule_t *old, const ts_auto_rule_t * } return false; } +/* Caller holds the configuration transaction and the short runtime mutex. + * This reads only confirmed in-memory metadata, never decrypts/re-encodes rules. */ +static rule_state_t classify_rule_state(const char *id, int active_index, bool confirmed, + ts_rule_saved_info_t *info) { + if (!confirmed) return RULE_UNKNOWN; + if (!ts_rule_store_set_present()) return active_index < 0 ? RULE_ABSENT : RULE_SYNCED; + esp_err_t e=ts_rule_store_set_info(id,info); + if(e==ESP_ERR_NOT_FOUND) return active_index < 0 ? RULE_ABSENT : RULE_PENDING_DELETE; + if(e!=ESP_OK) return RULE_UNKNOWN; + if(active_index<0) return RULE_PENDING_ADD; + return info->revision!=s_rule_ctx.rules[active_index].revision || + (s_rule_ctx.meta[active_index].source>=0 && s_rule_ctx.meta[active_index].source!=(int)info->source) + ? RULE_PENDING_UPDATE : RULE_SYNCED; +} +static bool state_pending(rule_state_t state) { + return state==RULE_PENDING_ADD||state==RULE_PENDING_UPDATE||state==RULE_PENDING_DELETE; +} static bool pending_id(const char *id, int active_index) { - ts_rule_saved_info_t info = {0}; - return ts_rule_store_set_available() && ts_rule_store_set_info(id, &info) == ESP_OK && - (active_index < 0 || info.revision != s_rule_ctx.rules[active_index].revision); + ts_rule_saved_info_t info={0}; + return state_pending(classify_rule_state(id,active_index, + s_rule_ctx.loaded&&!s_rule_ctx.recovery_error,&info)); +} +static int rule_union_count_locked(void) { + int count=s_rule_ctx.count; + for(int i=0;ierror_code = "revision_conflict"; } else if (i < 0 && !input) { ret = ESP_ERR_NOT_FOUND; - } else if (i < 0 && (ts_rule_store_set_available() ? ts_rule_store_set_count() : s_rule_ctx.count) == s_rule_ctx.capacity) { + } else if (occupied < 0) { + ret = ESP_ERR_INVALID_STATE; + result->error_code = "recovery_required"; + } else if (i < 0 && occupied >= s_rule_ctx.capacity) { ret = ESP_ERR_NO_MEM; result->error_code = "capacity"; } else if (i >= 0 && s_rule_ctx.meta[i].readonly) { @@ -684,6 +734,9 @@ static esp_err_t rule_commit_impl(const ts_auto_rule_t *input, const char *id, u } if (input) { s_rule_ctx.rules[i] = candidate; + ts_rule_saved_info_t info={0}; + s_rule_ctx.meta[i].source=ts_rule_store_set_info(id,&info)==ESP_OK?(int)info.source:-1; + s_rule_ctx.meta[i].pending_delete=false; candidate.lease = NULL; result->revision = s_rule_ctx.rules[i].revision; } else { @@ -871,6 +924,7 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); int preliminary = find_rule_index(id); bool unavailable = preliminary < 0 || !s_rule_ctx.loaded || s_rule_ctx.recovery_error || + s_rule_ctx.meta[preliminary].pending_delete || s_rule_ctx.meta[preliminary].committing || s_rule_ctx.meta[preliminary].executing || (!manual && r.cooldown_ms && r.last_trigger_ms && @@ -914,6 +968,7 @@ static esp_err_t execute_rule(const char *id, bool manual, bool *triggered) { xSemaphoreTake(s_rule_ctx.mutex, portMAX_DELAY); int i = find_rule_index(id); bool valid = i >= 0 && !s_rule_ctx.recovery_error && s_rule_ctx.loaded && + !s_rule_ctx.meta[i].pending_delete && !s_rule_ctx.meta[i].committing && !s_rule_ctx.meta[i].executing && s_rule_ctx.rules[i].revision == r.revision && s_rule_ctx.rules[i].instance == r.instance; @@ -2070,6 +2125,10 @@ static esp_err_t load_rules(const char *file) { if (e == ESP_OK) { ts_ssh_binding_lock(); for (int i = 0; i < count && e == ESP_OK; ++i) { + ts_rule_saved_info_t info={0}; + e=ts_rule_store_set_info(rules[i].id,&info); + if(e!=ESP_OK)break; + if(info.source!=TS_RULE_SET_SD_PACK)continue; const char *reason = NULL; e = ts_rule_pack_dependencies(&rules[i], &reason, NULL); if(e!=ESP_OK)s_rule_ctx.load_error=reason; @@ -2104,8 +2163,11 @@ static esp_err_t load_rules(const char *file) { s_rule_ctx.count = count; s_rule_ctx.source = source; memset(s_rule_ctx.meta, 0, sizeof(s_rule_ctx.meta)); - for (int i = 0; i < count; ++i) + for (int i = 0; i < count; ++i) { s_rule_ctx.meta[i].readonly = readonly[i]; + ts_rule_saved_info_t info={0}; + s_rule_ctx.meta[i].source=source!=TS_RULE_SOURCE_READONLY&&ts_rule_store_set_info(rules[i].id,&info)==ESP_OK?(int)info.source:-1; + } s_rule_ctx.loaded = true; s_rule_ctx.load_error="ok"; s_rule_ctx.recovery_error = false; @@ -2159,17 +2221,20 @@ void ts_rule_config_status(bool *loaded, bool *recovery) { } bool ts_rule_restart_pending(void) { - if (!s_rule_ctx.initialized || !s_rule_ctx.loaded) return false; - xSemaphoreTakeRecursive(s_rule_ctx.transaction, portMAX_DELAY); - bool pending = false; - for (int i=0; i=0&&s_rule_ctx.meta[i].readonly); - xSemaphoreGive(s_rule_ctx.mutex); - cJSON_AddNumberToObject(object,"saved_revision",info.revision); - cJSON_AddNumberToObject(object,"saved_generation",info.generation); - cJSON_AddNumberToObject(object,"active_revision",active); - cJSON_AddBoolToObject(object,"runtime_active",i>=0); - cJSON_AddBoolToObject(object,"restart_required",active!=info.revision); - cJSON_AddBoolToObject(object,"readonly",readonly); + xSemaphoreTake(s_rule_ctx.mutex,portMAX_DELAY); + int i=find_rule_index(id);uint32_t active=i<0?0:s_rule_ctx.rules[i].revision; + bool confirmed=s_rule_ctx.loaded&&!s_rule_ctx.recovery_error; + rule_state_t state=classify_rule_state(id,i,confirmed,&info); + bool exists=state==RULE_SYNCED||state==RULE_PENDING_ADD||state==RULE_PENDING_UPDATE; + bool readonly=(exists&&info.readonly)||(i>=0&&s_rule_ctx.meta[i].readonly); + xSemaphoreGive(s_rule_ctx.mutex); + const char *change=state==RULE_PENDING_ADD?"add":state==RULE_PENDING_UPDATE?"update": + state==RULE_PENDING_DELETE?"delete":state==RULE_UNKNOWN?"unknown":"none"; + cJSON_AddStringToObject(object,"pending_change",change); + if(state==RULE_UNKNOWN)cJSON_AddNullToObject(object,"saved_exists"); + else cJSON_AddBoolToObject(object,"saved_exists",exists); + if(exists)cJSON_AddNumberToObject(object,"saved_revision",info.revision); + else cJSON_AddNullToObject(object,"saved_revision"); + if(confirmed)cJSON_AddNumberToObject(object,"saved_generation",ts_rule_store_set_generation()); + else cJSON_AddNullToObject(object,"saved_generation"); + cJSON_AddNumberToObject(object,"active_revision",active); + cJSON_AddBoolToObject(object,"runtime_active",i>=0); + cJSON_AddBoolToObject(object,"restart_required",state_pending(state)); + cJSON_AddBoolToObject(object,"readonly",readonly); + if(exists){ cJSON_AddStringToObject(object,"saved_source",info.source==TS_RULE_SET_SD_PACK?"encrypted_pack":info.source==TS_RULE_SET_SD_JSON?"sd_json":"nvs_json"); cJSON_AddStringToObject(object,"package_digest",info.digest); + }else{ + cJSON_AddNullToObject(object,"saved_source");cJSON_AddNullToObject(object,"package_digest"); } xSemaphoreGiveRecursive(s_rule_ctx.transaction); } @@ -2209,6 +2289,9 @@ cJSON *ts_rule_pending_list(void) { cJSON *array=cJSON_CreateArray(); if(!array || !s_rule_ctx.initialized) return array; xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); + if(!s_rule_ctx.loaded||s_rule_ctx.recovery_error){ + xSemaphoreGiveRecursive(s_rule_ctx.transaction);return array; + } for(int i=0;i=s_rule_ctx.capacity){*reason="capacity";ret=ESP_ERR_NO_MEM;goto done;} xSemaphoreTake(s_rule_ctx.mutex,portMAX_DELAY); int active=find_rule_index(rule.id); + rule_state_t state=classify_rule_state(rule.id,active,true,&old); + int occupied=rule_union_count_locked(); xSemaphoreGive(s_rule_ctx.mutex); + if(state==RULE_PENDING_DELETE){*reason="restart_pending";ret=ESP_ERR_INVALID_STATE;goto done;} + if(state==RULE_UNKNOWN||occupied<0){*reason="recovery_required";ret=ESP_ERR_INVALID_STATE;goto done;} + if(!exists&&active<0&&occupied>=s_rule_ctx.capacity){*reason="capacity";ret=ESP_ERR_NO_MEM;goto done;} + ret=ts_rule_pack_dependencies(&rule,reason,&warnings); + if(ret!=ESP_OK)goto done; if(active>=0&&protected_bindings(&s_rule_ctx.rules[active],&rule)){ *reason="service_busy";ret=ESP_ERR_INVALID_STATE;goto done; } @@ -2328,72 +2415,26 @@ esp_err_t ts_rule_import_pack(const char *bytes,size_t length,bool preview,bool static esp_err_t dependency_change_rule(const ts_auto_rule_t *rule,ts_rule_dependency_t kind, const char *id,const void *next) { - for(unsigned i=0;iaction_count;++i){ - const ts_auto_action_t *a=&rule->actions[i]; - ts_action_template_t *tpl=NULL; - if(a->template_id[0]){ - if(kind==TS_RULE_DEP_TEMPLATE&&(!id||!strcmp(id,a->template_id))){ - if(!next)return ESP_ERR_INVALID_STATE; - const ts_action_template_t *proposed=next; - if(rule->enabled&&!proposed->enabled)return ESP_ERR_INVALID_STATE; - ts_auto_rule_t check=*rule;check.actions=(ts_auto_action_t *)&proposed->action;check.action_count=1; - const char *reason=NULL; - esp_err_t e=ts_rule_pack_dependencies(&check,&reason,NULL); - if(e!=ESP_OK)return e; - } - tpl=heap_caps_calloc(1,sizeof(*tpl),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); - if(!tpl)return ESP_ERR_NO_MEM; - if(ts_action_template_get(a->template_id,tpl)!=ESP_OK){free(tpl);return ESP_ERR_INVALID_STATE;} - a=&tpl->action; - } - if(a->type==TS_AUTO_ACT_SSH_CMD_REF){ - if(kind==TS_RULE_DEP_COMMAND&&(!id||!strcmp(id,a->ssh_ref.cmd_id))){ - if(!next){free(tpl);return ESP_ERR_INVALID_STATE;} - const ts_ssh_command_config_t *cmd=next;ts_action_ssh_host_t host; - if(ts_action_get_ssh_host(cmd->host_id,&host)!=ESP_OK || - !host.host[0]||!host.username[0]||!host.port || - (rule->enabled&&!cmd->enabled)){free(tpl);return ESP_ERR_INVALID_STATE;} - } - if(kind==TS_RULE_DEP_HOST||kind==TS_RULE_DEP_ACTION_HOST){ - ts_ssh_command_config_t *cmd=heap_caps_calloc(1,sizeof(*cmd),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); - if(!cmd){free(tpl);return ESP_ERR_NO_MEM;} - esp_err_t got=ts_ssh_commands_config_get(a->ssh_ref.cmd_id,cmd); - bool matches=got==ESP_OK&&(!id||!strcmp(cmd->host_id,id)); - if(got!=ESP_OK){free(cmd);free(tpl);return got;} - if(matches){ - ts_action_ssh_host_t actual;bool internal=false; - got=ts_action_get_ssh_host_ex(cmd->host_id,&actual,&internal); - if(got!=ESP_OK){free(cmd);free(tpl);return got;} - if(kind==TS_RULE_DEP_HOST&&!internal){ - const ts_ssh_host_config_t *proposed=next; - if(!proposed||!proposed->host[0]||!proposed->username[0]||!proposed->port){free(cmd);free(tpl);return ESP_ERR_INVALID_STATE;} - }else if(kind==TS_RULE_DEP_ACTION_HOST){ - const ts_action_ssh_host_t *proposed=next; - ts_ssh_host_config_t fallback; - if((proposed&&(!proposed->host[0]||!proposed->username[0]||!proposed->port)) || - (!proposed&&ts_ssh_hosts_config_get(cmd->host_id,&fallback)!=ESP_OK)){ - free(cmd);free(tpl);return ESP_ERR_INVALID_STATE; - } - } - } - free(cmd); - } - } - free(tpl); - } - return ESP_OK; + const char *reason=NULL; + return ts_rule_pack_dependency_change(rule,kind,id,next,&reason); } esp_err_t ts_rule_dependency_change(ts_rule_dependency_t kind,const char *id,const void *next) { - if(!s_rule_ctx.initialized||!s_rule_ctx.loaded)return ESP_OK; + /* Bootstrap loaders must be able to read their own persisted dependencies. + * Retained running versions after a failed reload still keep protection. */ + if(!s_rule_ctx.initialized||(!s_rule_ctx.loaded&&!s_rule_ctx.count))return ESP_OK; xSemaphoreTakeRecursive(s_rule_ctx.transaction,portMAX_DELAY); esp_err_t ret=ESP_OK; if(s_rule_ctx.recovery_error)ret=ESP_ERR_INVALID_STATE; for(int i=0;iconditions.count!=0; - for (unsigned i = 0; i < rule->action_count; ++i) { - const ts_auto_action_t *a = &rule->actions[i]; - dynamic_warning |= a->condition.has_condition; - ts_action_template_t *tpl = NULL; - if (a->template_id[0]) { - if (!ts_action_templates_ready()) { *reason = "dependency_loading"; return ESP_ERR_INVALID_STATE; } - tpl = heap_caps_calloc(1, sizeof(*tpl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - if (!tpl) { *reason = "no_memory"; return ESP_ERR_NO_MEM; } - esp_err_t got = ts_action_template_get(a->template_id, tpl); - if (got != ESP_OK) { free(tpl); *reason = "dependency_missing"; return got; } - if (rule->enabled && !tpl->enabled) { free(tpl); *reason = "dependency_disabled"; return ESP_ERR_INVALID_STATE; } - disabled_warning |= !tpl->enabled; - a = &tpl->action; +/* One proposed object overlays the read-only configuration view. The actual + * executor/snapshot path is never called by static validation. */ +typedef struct { + ts_rule_dependency_t kind; + const char *id; + const void *next; +} dependency_view_t; +static bool view_targets(const dependency_view_t *view, ts_rule_dependency_t kind, const char *id) { + return view && view->kind==kind && (!view->id || !strcmp(view->id,id)); +} +static esp_err_t dependency_error(esp_err_t error, const char **reason) { + *reason=error==ESP_ERR_NO_MEM?"no_memory":error==ESP_ERR_NOT_FOUND||error==ESP_ERR_INVALID_ARG? + "dependency_missing":"dependency_unavailable"; + return error; +} +static esp_err_t dependency_ready(esp_err_t state,const char **reason) { + if(state==ESP_OK)return ESP_OK; + *reason=state==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; + return ESP_ERR_INVALID_STATE; +} +static esp_err_t read_command(const char *id,const dependency_view_t *view, + ts_ssh_command_config_t *out,const char **reason) { + esp_err_t ret=dependency_ready(ts_ssh_commands_config_load_state(),reason); + if(ret!=ESP_OK)return ret; + if(view_targets(view,TS_RULE_DEP_COMMAND,id)) { + if(!view->next)return dependency_error(ESP_ERR_NOT_FOUND,reason); + *out=*(const ts_ssh_command_config_t *)view->next; + return ESP_OK; + } + ret=ts_ssh_commands_config_get(id,out); + return ret==ESP_OK?ret:dependency_error(ret,reason); +} +static esp_err_t read_config_host(const char *id,const dependency_view_t *view, + ts_action_ssh_host_t *out,const char **reason) { + esp_err_t ret=dependency_ready(ts_ssh_hosts_config_load_state(),reason); + if(ret!=ESP_OK)return ret; + ts_ssh_host_config_t *config=heap_caps_calloc(1,sizeof(*config),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!config)return dependency_error(ESP_ERR_NO_MEM,reason); + if(view_targets(view,TS_RULE_DEP_HOST,id)) { + if(view->next)*config=*(const ts_ssh_host_config_t *)view->next; + else ret=ESP_ERR_NOT_FOUND; + }else ret=ts_ssh_hosts_config_get(id,config); + if(ret==ESP_OK){ + /* Static checks use only fields the selected runtime host requires. */ + memset(out,0,sizeof(*out)); + memcpy(out->host,config->host,sizeof(out->host)); + memcpy(out->username,config->username,sizeof(out->username));out->port=config->port; + } + free(config);return ret==ESP_OK?ret:dependency_error(ret,reason); +} +static esp_err_t read_host(const char *id,const dependency_view_t *view, + ts_action_ssh_host_t *out,const char **reason) { + if(view_targets(view,TS_RULE_DEP_ACTION_HOST,id)) { + if(view->next){*out=*(const ts_action_ssh_host_t *)view->next;return ESP_OK;} + /* The removed internal host cannot satisfy its own fallback lookup. */ + return read_config_host(id,NULL,out,reason); + } + bool internal=false; + esp_err_t ret=ts_action_get_ssh_host_ex(id,out,&internal); + if(internal)return ret==ESP_OK?ret:dependency_error(ret,reason); + if(view_targets(view,TS_RULE_DEP_HOST,id))return read_config_host(id,view,out,reason); + esp_err_t ready=dependency_ready(ts_ssh_hosts_config_load_state(),reason); + if(ready!=ESP_OK)return ready; + return ret==ESP_OK?ret:dependency_error(ret,reason); +} +static esp_err_t validate_dependencies(const ts_auto_rule_t *rule,const dependency_view_t *view, + const char **reason,cJSON **warnings) { + *reason="invalid_configuration";if(warnings)*warnings=NULL; + bool disabled_warning=false,dynamic_warning=rule->conditions.count!=0; + for(unsigned i=0;iaction_count;++i){ + const ts_auto_action_t *source=&rule->actions[i],*a=source; + dynamic_warning|=source->condition.has_condition; + bool template_changed=source->template_id[0]&&view_targets(view,TS_RULE_DEP_TEMPLATE,source->template_id); + if(view&&view->kind==TS_RULE_DEP_TEMPLATE&&!template_changed)continue; + ts_action_template_t *owned=NULL;const ts_action_template_t *tpl=NULL; + ts_ssh_command_config_t *cmd=NULL;esp_err_t ret=ESP_OK; + if(source->template_id[0]){ + if(template_changed){ + tpl=view->next; + if(!tpl)return dependency_error(ESP_ERR_NOT_FOUND,reason); + }else{ + ret=dependency_ready(ts_action_templates_load_state(),reason);if(ret!=ESP_OK)return ret; + owned=heap_caps_calloc(1,sizeof(*owned),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!owned)return dependency_error(ESP_ERR_NO_MEM,reason); + ret=ts_action_template_get(source->template_id,owned); + if(ret!=ESP_OK){free(owned);return dependency_error(ret,reason);}tpl=owned; + } + a=&tpl->action; } - if (a->type == TS_AUTO_ACT_SSH_CMD_REF) { - esp_err_t commands=ts_ssh_commands_config_load_state(); - if (commands!=ESP_OK) { - free(tpl); *reason=commands==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; - return ESP_ERR_INVALID_STATE; + /* Establish the effective reference before testing its proposed state. + * Known-unaffected references do not freeze unrelated configuration. */ + if(view&&!template_changed){ + if(view->kind==TS_RULE_DEP_COMMAND){ + if(a->type!=TS_AUTO_ACT_SSH_CMD_REF||!view_targets(view,TS_RULE_DEP_COMMAND,a->ssh_ref.cmd_id)){ + free(owned);continue; + } + }else if(view->kind==TS_RULE_DEP_HOST||view->kind==TS_RULE_DEP_ACTION_HOST){ + const char *host_id=NULL; + if(a->type==TS_AUTO_ACT_SSH_CMD)host_id=a->ssh.host_ref; + else if(a->type==TS_AUTO_ACT_SSH_CMD_REF){ + cmd=heap_caps_calloc(1,sizeof(*cmd),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!cmd){free(owned);return dependency_error(ESP_ERR_NO_MEM,reason);} + ret=read_command(a->ssh_ref.cmd_id,NULL,cmd,reason); + if(ret!=ESP_OK){free(cmd);free(owned);return ret;}host_id=cmd->host_id; + }else {free(owned);continue;} + if(view->id&&strcmp(view->id,host_id)){free(cmd);free(owned);continue;} + ts_action_ssh_host_t actual;bool internal=false; + ret=ts_action_get_ssh_host_ex(host_id,&actual,&internal); + if(view->kind==TS_RULE_DEP_HOST&&ret==ESP_OK&&internal){free(cmd);free(owned);continue;} + if(view->kind==TS_RULE_DEP_ACTION_HOST&&!view->next&&ret==ESP_OK&&!internal){free(cmd);free(owned);continue;} } - ts_ssh_command_config_t *cmd = heap_caps_calloc(1, sizeof(*cmd), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - ts_action_ssh_host_t host; - if (!cmd) { free(tpl); *reason = "no_memory"; return ESP_ERR_NO_MEM; } - esp_err_t got = ts_ssh_commands_config_get(a->ssh_ref.cmd_id, cmd); - bool internal=false; - if (got == ESP_OK) got = ts_action_get_ssh_host_ex(cmd->host_id, &host,&internal); - esp_err_t hosts=ts_ssh_hosts_config_load_state(); - if (!internal && hosts!=ESP_OK) { - free(cmd);free(tpl);*reason=hosts==ESP_ERR_INVALID_STATE?"dependency_loading":"dependency_unavailable"; - return ESP_ERR_INVALID_STATE; + } + if(tpl&&rule->enabled&&!tpl->enabled){*reason="dependency_disabled";ret=ESP_ERR_INVALID_STATE;goto action_done;} + if(tpl)disabled_warning|=!tpl->enabled; + if(a->type==TS_AUTO_ACT_WEBHOOK){*reason="action_unsupported";ret=ESP_ERR_NOT_SUPPORTED;goto action_done;} + if(a->type==TS_AUTO_ACT_SSH_CMD||a->type==TS_AUTO_ACT_SSH_CMD_REF){ + const char *host_id=a->ssh.host_ref; + if(a->type==TS_AUTO_ACT_SSH_CMD_REF){ + if(!cmd){ + cmd=heap_caps_calloc(1,sizeof(*cmd),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!cmd){ret=dependency_error(ESP_ERR_NO_MEM,reason);goto action_done;} + ret=read_command(a->ssh_ref.cmd_id,view,cmd,reason); + if(ret!=ESP_OK)goto action_done; + } + if(rule->enabled&&!cmd->enabled){*reason="dependency_disabled";ret=ESP_ERR_INVALID_STATE;goto action_done;} + disabled_warning|=!cmd->enabled;host_id=cmd->host_id; } - if (got == ESP_OK && (!host.host[0] || !host.username[0] || !host.port)) got=ESP_ERR_INVALID_ARG; - bool disabled = got == ESP_OK && !cmd->enabled; - disabled_warning |= disabled; - free(cmd); free(tpl); - if (got != ESP_OK) { *reason = "dependency_missing"; return got; } - if (rule->enabled && disabled) { *reason = "dependency_disabled"; return ESP_ERR_INVALID_STATE; } - } else free(tpl); + ts_action_ssh_host_t host; + ret=read_host(host_id,view,&host,reason); + if(ret==ESP_OK&&(!host.host[0]||!host.username[0]||!host.port))ret=dependency_error(ESP_ERR_INVALID_ARG,reason); + } +action_done: + free(cmd);free(owned);if(ret!=ESP_OK)return ret; } - /* Dynamic variables are not a static existence proof. Their values remain runtime inputs. */ - if (warnings && (dynamic_warning || disabled_warning)) { - *warnings = cJSON_CreateArray(); - if (!*warnings || (dynamic_warning && !cJSON_AddItemToArray(*warnings,cJSON_CreateString("dynamic_inputs"))) || - (disabled_warning && !cJSON_AddItemToArray(*warnings,cJSON_CreateString("dependency_disabled")))) { - cJSON_Delete(*warnings); *warnings = NULL; *reason = "no_memory"; return ESP_ERR_NO_MEM; + if(warnings&&(dynamic_warning||disabled_warning)){ + *warnings=cJSON_CreateArray(); + if(!*warnings||(dynamic_warning&&!cJSON_AddItemToArray(*warnings,cJSON_CreateString("dynamic_inputs")))|| + (disabled_warning&&!cJSON_AddItemToArray(*warnings,cJSON_CreateString("dependency_disabled")))){ + cJSON_Delete(*warnings);*warnings=NULL;return dependency_error(ESP_ERR_NO_MEM,reason); } } - *reason = "ok"; - return ESP_OK; + *reason="ok";return ESP_OK; +} +esp_err_t ts_rule_pack_dependencies(const ts_auto_rule_t *rule,const char **reason,cJSON **warnings) { + return validate_dependencies(rule,NULL,reason,warnings); +} +esp_err_t ts_rule_pack_dependency_change(const ts_auto_rule_t *rule,ts_rule_dependency_t kind, + const char *id,const void *next,const char **reason) { + dependency_view_t view={kind,id,next}; + return validate_dependencies(rule,&view,reason,NULL); } diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index c7884ea..eed4f70 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -3889,13 +3889,15 @@ async function refreshQuickActions() {
${escapeHtml(cleanName)}
-
${(!nohupInfo?.serviceMode || !rule.enabled || !rule.manual_trigger) ? runtimeText(!rule.enabled ? 'disabled' : rule.manual_trigger ? 'manual' : 'automatic') : ''}${(rule.reference_unresolved || nohupInfo?.unresolved) ? ' · ' + runtimeText('referenceUnresolved') : ''}${statusHtml && (!rule.enabled || !rule.manual_trigger) ? ' · ' : ''}${statusHtml}${nohupBtns}
+
${rule.pending_change === 'delete' ? t('rulePack.pendingDelete') + ' · ' : ''}${(!nohupInfo?.serviceMode || !rule.enabled || !rule.manual_trigger) ? runtimeText(!rule.enabled ? 'disabled' : rule.manual_trigger ? 'manual' : 'automatic') : ''}${(rule.reference_unresolved || nohupInfo?.unresolved) ? ' · ' + runtimeText('referenceUnresolved') : ''}${statusHtml && (!rule.enabled || !rule.manual_trigger) ? ' · ' : ''}${statusHtml}${nohupBtns}
`); } @@ -4124,7 +4126,7 @@ async function triggerQuickAction(ruleId) { } catch (_) { card.dataset.state = 'unknown'; } } if (card.dataset.allowed !== 'true' || (card.dataset.service && card.dataset.state !== 'stopped')) { - showToast(runtimeText('startBlocked'), 'warning'); return; + showToast(card.dataset.pendingChange === 'delete' ? t('rulePack.pendingDelete') : runtimeText('startBlocked'), 'warning'); return; } if (card.dataset.service) card.dataset.state = 'starting'; @@ -15290,16 +15292,17 @@ async function refreshRules() { const label = r.enabled ? t('common.disabled') : t('common.enabled'); const locked = r.restart_required || r.readonly; const active = r.runtime_active !== false; + const deleting = r.pending_change === 'delete'; const handler = (name, ...args) => escapeHtml(name + '(' + args.map(x => JSON.stringify(x)).join(',') + ')'); return `
${escapeHtml(r.id)}
-
${escapeHtml(r.name || r.id)}${r.restart_required ? ` ${t('rulePack.pending')}` : ''}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
+
${escapeHtml(r.name || r.id)}${r.restart_required ? ` ${t(deleting ? 'rulePack.pendingDelete' : 'rulePack.pending')}` : ''}${r.manual_trigger ? ' ' : ''}${r.manual_trigger ? `${t('common.manual')}` : ''}
${r.conditions_count || 0}
${r.actions_count || 0}
${r.trigger_count || 0}
-
${icoBtn('ri-play-line', t(r.restart_required ? 'rulePack.runCurrent' : 'automation.manualTrigger'), handler('triggerRule', r.id), '', !active)}${icoBtn('ri-edit-line', t('common.edit'), handler('editRule', r.id), '', locked)}${icoBtn('ri-download-line', t('rulePack.exportCurrent'), handler('showExportRuleModal', r.id), '', !active)}${icoBtn('ri-delete-bin-line', t('common.delete'), handler('deleteRule', r.id), 'dg', locked)}
+
${icoBtn('ri-play-line', t(deleting ? 'rulePack.pendingDelete' : r.restart_required ? 'rulePack.runCurrent' : 'automation.manualTrigger'), handler('triggerRule', r.id), '', !active || deleting)}${icoBtn('ri-edit-line', t('common.edit'), handler('editRule', r.id), '', locked)}${icoBtn('ri-download-line', t('rulePack.exportCurrent'), handler('showExportRuleModal', r.id), '', !active)}${icoBtn('ri-delete-bin-line', t('common.delete'), handler('deleteRule', r.id), 'dg', locked)}
`; }).join('')} `; @@ -19113,7 +19116,7 @@ async function confirmRuleImport() { try { const state = await api.call('automation.rules.list'); if (attempt !== rulePackPreviewAttempt) return; - const saved = state.code === 0 && state.data?.loaded !== false && state.data?.recovery_required !== true && state.data?.rules?.find(r => r.id === preview.id && r.package_digest === preview.package_digest); + const saved = state.code === 0 && state.data?.loaded !== false && state.data?.recovery_required !== true && state.data?.rules?.find(r => r.id === preview.id && r.saved_exists !== false && r.pending_change !== 'delete' && r.package_digest === preview.package_digest); if (saved && Number.isInteger(saved.saved_revision) && saved.saved_generation >= preview.expected_generation) { box.className = 'result-box success'; box.textContent = t(saved.restart_required ? 'rulePack.saved' : 'rulePack.alreadyActive'); await refreshRules(); diff --git a/components/ts_webui/web/js/lang/en-US.js b/components/ts_webui/web/js/lang/en-US.js index fae56e6..d658a90 100644 --- a/components/ts_webui/web/js/lang/en-US.js +++ b/components/ts_webui/web/js/lang/en-US.js @@ -3,6 +3,7 @@ */ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { rulePack: { + pendingDelete: "Deletion saved; removal requires restart", content: "View rule content", overwriteLabel: "Overwrite impact", overwriteImpact: "Replaces saved revision {revision}; the running version continues until restart.", @@ -20,6 +21,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('en-US', { "summary": "{conditions} conditions, {actions} actions", "verifyFailed": "Verification did not complete. Check the connection and select the file again.", "errors": { + "action_unsupported": "This firmware does not support an action used by the package.", "config_loading": "Rule configuration is still loading. Check again shortly.", "invalid_pack": "The package format or content is invalid. Export it again.", "invalid_rule_pack": "This is not a valid rule configuration package.", diff --git a/components/ts_webui/web/js/lang/zh-CN.js b/components/ts_webui/web/js/lang/zh-CN.js index c7c9adb..b93de19 100644 --- a/components/ts_webui/web/js/lang/zh-CN.js +++ b/components/ts_webui/web/js/lang/zh-CN.js @@ -3,6 +3,7 @@ */ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { rulePack: { + pendingDelete: "已保存删除,待重启移除", content: "查看规则内容", overwriteLabel: "覆盖影响", overwriteImpact: "替换已保存的第 {revision} 版;重启前仍运行当前版本。", @@ -20,6 +21,7 @@ if (typeof i18n !== 'undefined') i18n.registerLanguage('zh-CN', { "summary": "{conditions} 项条件,{actions} 项动作", "verifyFailed": "未能完成验证,请检查连接后重新选择文件。", "errors": { + "action_unsupported": "配置包使用了当前固件尚不支持的动作。", "config_loading": "规则配置仍在加载,请稍后刷新。", "invalid_pack": "配置包格式或内容无效,请重新导出。", "invalid_rule_pack": "此文件不是有效的规则配置包。", diff --git a/tests/prompts/rule-pack.browser.test.cjs b/tests/prompts/rule-pack.browser.test.cjs index f84be2e..35dac3d 100644 --- a/tests/prompts/rule-pack.browser.test.cjs +++ b/tests/prompts/rule-pack.browser.test.cjs @@ -32,6 +32,7 @@ async function pageFor(language,mode,run){ if(endpoint==='automation/rules/import'){ if(params.preview){ const data={...preview,exists:mode==='old',expected_revision:mode==='old'?4:0}; + if(params.tscfg==='unsupported'){res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({code:1,message:'action_unsupported'}));return;} if(params.tscfg==='untrusted')data.trusted=false; if(params.tscfg==='slow'||params.tscfg==='fast')data.rule={...preview.rule,name:params.tscfg}; reply={code:0,data}; @@ -48,7 +49,7 @@ async function pageFor(language,mode,run){ if(mode==='conflict')reply={code:1,message:'revision_conflict'}; else{ saved=true; - if(mode==='lost'||mode==='unknown'){ + if(mode==='lost'||mode==='unknown'||mode==='deleted'){ res.writeHead(200,{'Content-Type':'application/json'});res.flushHeaders(); res.write('{"code":0,');setImmediate(()=>res.destroy());return; } @@ -59,6 +60,12 @@ async function pageFor(language,mode,run){ if(endpoint==='automation/rules/list')reply={code:0,data:{loaded:true,recovery_required:mode==='unknown',rules:saved?[{id,name:'Night test',enabled:false, readonly:true,restart_required:true,runtime_active:mode==='old',saved_revision:mode==='old'?5:1,active_revision:mode==='old'?4:0,saved_generation:8, package_digest:hash,conditions_count:1,actions_count:1}]:[]}}; + if(endpoint==='ssh/hosts/list')reply={code:0,data:{hosts:[]}}; + if(endpoint==='ssh/commands/list')reply={code:0,data:{commands:[]}}; + if(endpoint==='automation/rules/list'&&mode==='deleted')reply={code:0,data:{loaded:true,recovery_required:false,rules:[{ + id,name:'Old rule',enabled:true,readonly:false,allow_manual_trigger:true,manual_trigger:true,show_on_dashboard:true, + pending_change:'delete',saved_exists:false,saved_revision:null,saved_generation:8,saved_source:null,package_digest:null, + restart_required:true,runtime_active:true,active_revision:4,revision:4,conditions_count:0,actions_count:1,actions:[{type:'log',message:'old'}]}]}}; res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify(reply)); });return; } @@ -160,6 +167,31 @@ for(const language of ['zh-CN','en-US']){ assert(await page.locator('#import-rule-btn').isDisabled());assert.equal(writes(),0); }); }); + test(language+': confirmed deletion row and quick action cannot trigger or confirm an uncertain pack save',async()=>{ + await pageFor(language,'deleted',async({page,calls,writes})=>{ + await page.locator('#import-rule-btn').click(); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.unknown')); + assert.equal(writes(),1); + await page.evaluate(async()=>{await refreshRules();document.body.insertAdjacentHTML('beforeend','
');await refreshQuickActions();}); + assert.equal(await page.locator('#rules-list .tr:not(.th)').count(),1); + assert.match(await page.locator('#rules-list').innerText(),language==='zh-CN'?/已保存删除,待重启移除/:/Deletion saved; removal requires restart/); + const buttons=page.locator('#rules-list .tr:not(.th) button'); + for(const [i,disabled] of [[0,true],[1,true],[2,true],[3,false],[4,true]])assert.equal(await buttons.nth(i).isDisabled(),disabled); + const card=page.locator('#quick-actions-grid .quick-action-card');assert.equal(await card.count(),1); + assert.equal(await card.getAttribute('data-allowed'),'false');assert.equal(await card.getAttribute('aria-disabled'),'true'); + assert.match(await card.innerText(),language==='zh-CN'?/已保存删除/:/Deletion saved/); + await page.evaluate(id=>triggerQuickAction(id),id); + assert.equal(calls.filter(c=>c.endpoint==='automation/rules/trigger').length,0); + }); + }); + test(language+': unsupported action preview has a translated error and cannot save',async()=>{ + await pageFor(language,'ok',async({page,writes})=>{ + await page.locator('#import-rule-file').setInputFiles({name:'unsupported.tscfg',buffer:Buffer.from('unsupported')}); + await page.waitForFunction(()=>document.getElementById('import-rule-result').textContent===t('rulePack.errors.action_unsupported')); + assert(await page.locator('#import-rule-btn').isDisabled());assert.equal(writes(),0); + assert.doesNotMatch(await page.locator('#import-rule-result').innerText(),/rulePack\.errors/); + }); + }); test(language+': datasource switch works without a same-name rule',async()=>{ await pageFor(language,'ok',async({page,calls})=>{ await page.evaluate(async()=>{hideImportRuleModal();document.body.insertAdjacentHTML('beforeend','
');await refreshSources();}); diff --git a/tests/runtime/extract_engine.py b/tests/runtime/extract_engine.py index 861f003..b7b8d9b 100644 --- a/tests/runtime/extract_engine.py +++ b/tests/runtime/extract_engine.py @@ -13,11 +13,12 @@ int ts_rule_store_set_count(void){return 0;} uint32_t ts_rule_store_set_generation(void){return 0;} esp_err_t ts_rule_store_set_info(const char *id,ts_rule_saved_info_t *out){return ESP_ERR_NOT_FOUND;} +esp_err_t ts_rule_store_set_info_at(int i,ts_rule_saved_info_t *out){return ESP_ERR_NOT_FOUND;} esp_err_t ts_rule_store_set_commit(const ts_auto_rule_t*r,const char*id,uint32_t v,uint32_t g,const char*p,size_t n,const ts_config_pack_acceptance_t*a,ts_rule_commit_t*out){assert(!"unexpected saved-set commit in legacy engine test");return ESP_FAIL;} #endif """ parts=[header,stubs,s[a:b]] -for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','pending_id','rule_commit_impl','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: +for name in ['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation','ts_rule_acquire','ts_rule_release','same_config','protected_bindings','classify_rule_state','state_pending','pending_id','rule_union_count_locked','rule_commit_impl','ts_rule_commit','compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_get_by_index']: m=re.search(r'^(?:static )?[^\n;]+\b'+name+r'\([^;]+?\)\s*\{',s,re.M);assert m,name end=s.index('\n}',m.start())+2;parts.append(s[m.start():end]) Path('/tmp/tianshan-runtime-tests/engine.inc').write_text('\n'.join(parts)) diff --git a/tests/runtime/rule_pack/R1-R3-review.md b/tests/runtime/rule_pack/R1-R3-review.md new file mode 100644 index 0000000..a0f00a1 --- /dev/null +++ b/tests/runtime/rule_pack/R1-R3-review.md @@ -0,0 +1,125 @@ +# R1–R3 本地修复候选及验证证据 + +基线:`af6fcf262c38b7fc466dfb3e2180791d94f2f173`。独立分支:`fix/rule-pack-import-standalone`。 +开始时本地 HEAD、个人仓库远端分支均为该基线,工作区干净;本次增量不包含他人修改。 +最终提交身份以包含此文件的提交 SHA 为准,可直接比较基线与该提交。没有合并主线或改写历史。 + +## 闭合表 + +| 项目 | 修复前的实际路径 | 共同语义与改动入口 | 定向证据 | 保留的行为 | +|---|---|---|---|---| +| R1 | 删除 selector 已提交、回读未知;恢复后只从保存集合查差异,遗漏运行集合中的 A,仍可新触发且少算容量 | `ts_rule_engine.c` 的统一状态分类覆盖 IDs(A)∪IDs(S);恢复完整读回后先发布 pending_delete,再清恢复状态;`execute_rule` 最终准入检查、普通 commit/包导入共用状态与并集容量;API/list/get 与双语列表、快捷卡片表达同一状态 | A1:未知→失败恢复→确认删除;只有一行,保存字段为 null;自动/手动拒绝,同 ID 普通写及包预览/正式导入拒绝;B 编辑不复活 A;重建启动 A 消失。A2:selector 写前失败及已知成功立即删除。A3:持有旧 lease/已准入动作不受破坏、最终准入屏障、小容量拒绝 C,重启后 C 可新增 | 无持久化 tombstone,无 GET 热删除;旧对象、租约、已准入快照保持;已知成功普通删除仍立即生效,待新增/待更新保持原行为 | +| R2 | 普通 API 启用 A 后,统一包级依赖检查因禁用模板拒绝整个普通集合;延迟入口还全局等待模板 | 按保存记录 source 只校验包;运行 meta 保留发布版本 source,保护扫描运行包与保存包;移除无条件模板等待,增加组件内有界加载结果;真实首次依赖初始化可进入,保留运行版本时不得破坏性重载 | B1:实际 enable→生产加载,A 启用/动作/source/revision 保留,B 可执行,A 因原有执行检查拒绝。B2:实际有上限延迟入口、无模板日志包、普通模板引用先加载后真实初始化 guard 放行。B3:混合集合包等待/失败时无部分发布,依赖就绪后完整加载;普通来源不被待重启包重标记;运行包阻止模板全量重载 | 普通 NVS/SD JSON 原有加载兼容;包来源仍严格,外部运行成功不由静态加载承诺;既有快照/服务保护保留 | +| R3 | 预检漏直接 SSH 和 Webhook;变更保护重复解析,忽略直接引用并仅检查回退 ID 存在 | `ts_rule_pack.c` 唯一只读有效动作/拟对象覆盖视图供预检和变更检查使用;模板一级优先;两条 SSH 统一实际 internal-first 主机解析;必要字段、加载/失败/缺失分开;getter 保留真实读取错误 | C1:真实包直接及模板 SSH 缺主机时预览/正式导入拒绝,存在时通过;直接路径不依赖命令就绪,模板 LOG 覆盖内联 SSH。C2:保存待重启与已运行两个时点,实际主机删除/坏更新拒绝;真实 getter 优先级、被遮蔽配置允许、内部删除后的有效/无效/未就绪/读取错误回退;真实内部注册/删除 guard 在检查失败时未调用写入;旧运行包与新保存包保护各自依赖。C3:直接/模板/禁用 Webhook 拒绝、保护模板不可改为 Webhook;普通旧 Webhook 仍加载;snapshot、pin、执行、连接、入队、变量写入计数不增加(C 组均为 0) | 没有调用 snapshot 做预检;没有连接或执行探测;没有实现 Webhook、增加 host enabled/auth 约束或改执行器 | + +## 先红后绿 + +以下在基线生产代码、仅增加最小测试入口时采集;Python 包装器均退出 1(测试断言失败,原生进程 SIGABRT),不是编译失败。之后才修改生产语义。 + +`red-r1.log`: + +```text +R1: saved_count=1 active_count=2 restart_pending=0 +Assertion failed: (ts_rule_restart_pending()), function main, file test_engine_pack.c, line 89. +``` + +`red-r2.log`: + +```text +R2: reload=259 loaded=0 count=2 template_enabled=0 +Assertion failed: (loaded==ESP_OK&&s_rule_ctx.loaded&&s_rule_ctx.count==2), function main, file test_engine_pack.c, line 94. +``` + +`red-r3_ssh.log`: + +```text +R3 r3_ssh: preview=0 reason=ok +Assertion failed: (got!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing")), function main, file test_engine_pack.c, line 100. +``` + +`red-r3_webhook.log`: + +```text +R3 r3_webhook: preview=0 reason=ok +Assertion failed: (got!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing")), function main, file test_engine_pack.c, line 100. +``` + +最小反例命令:`python3 tests/runtime/test_rule_pack_crypto.py --engine --case=`。 +当前修复已扩展同一夹具为 A1–A3/B1–B3/C1–C3。该当前夹具包含新增 helper 的提取清单;复现历史红证据需使用当时最小夹具,不能将最终清单机械套到缺少 helper 的旧代码上。 + +## 最终验证 + +环境:macOS arm64,Apple Clang 21.0.0,Python 3.14.2,Node v24.10.0。 +固定 SDK:ESP-IDF v5.5.2,`30aaf64524299d3bde422ca9a2848090d1bc5d0f`;使用 SDK 原生 Mbed TLS 3.6.5 和临时 OpenSSL PKI。 +本地测试 CA PEM SHA-256:`a9c2a6c7b202c4acfa34b58e44d257505e5833e3fe70fcd24ad5aa19df4f7371`;没有上传测试私钥。 + +| 实际命令 | 退出码 | 结果与边界 | +|---|---:|---| +| `IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf bash tests/runtime/run_all.sh` | 0 | 既有相关 runtime/codec/store/快照/动作提交/SSH/规则包与新增定向组全部通过;ASan/UBSan 原生夹具,硬件/RTOS/存储出口模拟 | +| `IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf bash tests/runtime/run_rule_pack.sh` | 0 | 最后补齐模板正式导入及无副作用计数后复跑受影响共享夹具:795 保存、620 迁移、436 恢复故障场景;原包加密/信任/跨设备/CAS/幂等与 A/B/C 均通过 | +| `node --test tests/prompts/rule-pack.browser.test.cjs` | 0 | 20/20;实际双语页面+本地模拟 API,覆盖待删除单行、触发禁用、未知保存回读与 action_unsupported;非真机浏览器验收 | +| `CERT_MBEDTLS_BUILD=/tmp/tianshan-pack-mbedtls CERT_TEST_PYTHON=/Users/massif/.espressif/python_env/idf5.5_py3.14_env/bin/python IDF_PATH=/Users/massif/esp/v5.5.2/esp-idf bash tests/certificate/run_host.sh` | 0 | 既有证书 subject/material/time/API/HTTPS 生命周期等回归通过;不等同于部署 PKI 验收 | +| `source /Users/massif/esp/v5.5.2/esp-idf/export.sh && idf.py -B /tmp/tianshan-rule-pack-firmware-build -DSDKCONFIG=/tmp/tianshan-rule-pack-sdkconfig build` | 0 | ESP32-S3、16MB 隔离构建,app 与 www.bin 成功;原生产配置/设备未修改。构建签名根为空,尚不能作为部署后导入验收证据 | +| `git diff --check` | 0 | 增量无空白错误 | + +固件 `TianShanOS.bin` SHA-256:`7b2295d87fc60b1376cb3997c5089bc6f26c8c48bbd934daf6e00da77121c8ea`。 +WebUI `www.bin` SHA-256:`85344da490c52dd1de75ac41dcabd6e1e3ecee8f513dd9758ee11beab9c19d25`。 + +原始输出保存在本机 `/tmp/tianshan-rule-pack-r1-r3-evidence/`;下面摘要哈希对应采集文件,临时目录可能被系统清理。真实 PASS 摘录与失败断言已纳入本报告,不上传凭证。 + +- `red-r1.log` SHA-256 `cd766716c0cd5e13032539a0046737dd119b06f99061cfe18d39de1c0ed6fc5f` +- `red-r2.log` SHA-256 `865be96c6f59c69c1e2c81ffa40c00ee3fe245a9afe0572ced228053ef525f2c` +- `red-r3_ssh.log` SHA-256 `a509289e12ffb31e44c382698e53f805a68cdb2cd2af9a78bf31bff9726e7d36` +- `red-r3_webhook.log` SHA-256 `b33a34cd3b04eb1df5fe9489cf8c40d4dae5c47d5c39e828681300b3ed877720` +- `runtime.log` SHA-256 `71576e556a5488dd9c8dc91c35282865453212c1a57c0901268e028758ac235a` +- `rule-pack-final.log` SHA-256 `4e18f9adda283011930a21cdf6a7172af7ec032ec9c55cdf2514fe1f9848e250` +- `browser.log` SHA-256 `99110bec0b9fd51a18b54891144a774eb20224791480dea5972dcb1b2aa8e9ce` +- `certificate.log` SHA-256 `3df50d403d90be2292a1f031fb3384dd9a4af027b0b23ffd29453ed0caa10082` +- `build.log` SHA-256 `05d8a435f50ac365b22938f6be8c376d76e5878b4beea514f585948e3877c828` + +规则包最终真实输出: + +```text +PASS real production create/trust/signature/ECDH/HKDF/GCM, cross-device PEM, offline accepted reload, malformed inputs +PASS unconfigured signing root refuses a cryptographically valid package +PASS production codec -> real pack -> complete-set store -> offline boot: 795 save, 620 migration and 436 recovery fault cases; full sources/revisions/readonly, idempotence, unrelated edit, SD absence and same-generation JSON mirror +PASS production import + engine + crypto + store: saved/active isolation, held old lease, pending edit/reload/deinit guards, unrelated merge, CAS/credential conflicts, offline idempotence/boot, dependency union, capacity and missing SD recovery +PASS A1-A3: confirmed deletion, unknown/old selector, unchanged active lease/admitted action, final admission barrier, union capacity, sibling edit and reboot +PASS B1-B3: actual enable/execute, ordinary source compatibility, bounded deferred loading, first template load, mixed complete publication and published source identity +PASS C1-C2: direct SSH preview/formal, real host source priority, proposed view/fallback fields and readiness, effective template, active/saved source protection; no snapshot/pin/execute +PASS C3: direct/template/disabled Webhook refused, protected template unchanged, ordinary compatibility, no snapshot/pin/execute side effects +``` + +## 实际修改文件 + +- `components/ts_api/src/ts_api_automation.c` +- `components/ts_automation/include/ts_action_manager.h` +- `components/ts_automation/include/ts_rule_pack.h` +- `components/ts_automation/src/ts_action_manager.c` +- `components/ts_automation/src/ts_rule_engine.c` +- `components/ts_automation/src/ts_rule_pack.c` +- `components/ts_webui/web/js/app.js` +- `components/ts_webui/web/js/lang/en-US.js` +- `components/ts_webui/web/js/lang/zh-CN.js` +- `tests/prompts/rule-pack.browser.test.cjs` +- `tests/runtime/extract_engine.py` +- `tests/runtime/rule_pack/stubs/ts_action_manager.h` +- `tests/runtime/rule_pack/test_engine_pack.c` +- `tests/runtime/rule_pack/test_store_set.c` +- `tests/runtime/rule_pack_contract.txt` +- `tests/runtime/run_rule_pack.sh` +- `tests/runtime/test_action_pack.py` +- `tests/runtime/test_rule_pack_crypto.py` +- `tests/runtime/rule_pack/R1-R3-review.md` + +## 范围与未执行项 + +本轮只有 R1–R3 因果相关代码、既有夹具提取适配及双语 UI 联动,没有存储格式、selector、信任政策、执行器重写,没有实现 Webhook、操作设备、合并主线或覆盖其他 worktree。 +没有新增需要另行修复的范围外问题。为保证读取错误不被伪装成主机不存在,仅联动无副作用 host getter 的错误透传。 + +未执行:刷机、物理重启、真机导入/SSH/服务/浏览器验收;真实设备签名根、Developer OU/KU/EKU、可信时间、NVS/SD 可用空间核验。没有仍失败或环境阻塞的本轮本地检查。 + +发布前提沿用原契约:必须安装并明确配置真实签名根,确认 Developer 证书用途、可信时间及足够存储空间。 +**启动即可发生旧集合到 v3 的迁移,不以用户点击导入为前提。** 老版本不理解 `set_select`,不可承诺原地降级;需明确备份/恢复策略,不双写旧权威、不增加降级框架。 + +结论:本地修复候选及证据已完成,设备验收未完成。 diff --git a/tests/runtime/rule_pack/stubs/ts_action_manager.h b/tests/runtime/rule_pack/stubs/ts_action_manager.h index bbe2f38..baf541f 100644 --- a/tests/runtime/rule_pack/stubs/ts_action_manager.h +++ b/tests/runtime/rule_pack/stubs/ts_action_manager.h @@ -7,8 +7,14 @@ typedef struct { } ts_action_template_t; esp_err_t ts_action_template_get(const char *, ts_action_template_t *); bool ts_action_templates_ready(void); +esp_err_t ts_action_templates_load_state(void); typedef struct { char id[TS_AUTO_NAME_MAX_LEN],host[64];uint16_t port;char username[32],password[64],key_path[96];bool use_key_auth; } ts_action_ssh_host_t; esp_err_t ts_action_get_ssh_host(const char *,ts_action_ssh_host_t *); esp_err_t ts_action_get_ssh_host_ex(const char *,ts_action_ssh_host_t *,bool *); + +/* Side-effect boundaries counted by the integration fixture; preflight must not use them. */ +typedef struct {int status;} ts_action_result_t; +typedef void (*ts_action_callback_t)(const ts_auto_action_t *,const ts_action_result_t *,void *); +esp_err_t ts_action_queue(const ts_auto_action_t *,ts_action_callback_t,void *,uint8_t); diff --git a/tests/runtime/rule_pack/test_engine_pack.c b/tests/runtime/rule_pack/test_engine_pack.c index 0d5c155..ccf0e18 100644 --- a/tests/runtime/rule_pack/test_engine_pack.c +++ b/tests/runtime/rule_pack/test_engine_pack.c @@ -3,12 +3,16 @@ #define RULE_ENGINE_INTEGRATION #include "test_store_set.c" #include +#include +#include +#include "esp_log.h" #include "ts_rule_engine.h" #include "ts_rule_pack.h" #define pdTRUE 1 typedef struct {int code;char *message;cJSON *data;} ts_api_result_t; #define TS_API_OK 0 #define TS_API_ERR_INTERNAL 500 +#define TS_API_ERR_INVALID_ARG 400 static void ts_api_result_error(ts_api_result_t *r,int code,const char *why){r->code=code;r->message=strdup(why);} typedef pthread_mutex_t *SemaphoreHandle_t; #define portMAX_DELAY 0xffffffff @@ -18,6 +22,23 @@ static int xSemaphoreGive(SemaphoreHandle_t m){return !pthread_mutex_unlock(m);} #define xSemaphoreGiveRecursive xSemaphoreGive static pthread_mutex_t binding,lock,transaction; static bool sd_mounted=true; +static unsigned action_calls,snapshot_calls,service_pins,connection_calls,queue_calls,variable_writes; +struct ts_ssh_session_s; +esp_err_t ts_ssh_connect(struct ts_ssh_session_s *session){++connection_calls;return ESP_FAIL;} +esp_err_t ts_action_queue(const ts_auto_action_t *a,ts_action_callback_t cb,void *data,uint8_t priority){++queue_calls;return ESP_FAIL;} +esp_err_t ts_variable_set(const char *id,const ts_auto_value_t *value){++variable_writes;return ESP_FAIL;} +static ts_auto_action_t admitted_action; +static void (*before_admission)(void); +static int64_t esp_timer_get_time(void){return 1000000;} +static const char *esp_err_to_name(int e){return "fixture";} +bool ts_action_manager_accepting(void){return true;} +bool ts_ssh_service_start_admissible(const char *id){return true;} +void ts_ssh_service_set_owner(const char *id,uint32_t r,const char *owner){assert(!"unexpected service owner");} +void ts_ssh_service_unpin(const char *id,uint32_t r){assert(!"unexpected service pin");} +esp_err_t ts_ssh_service_pin(const ts_ssh_command_config_t *c,const char *host,uint16_t port,uint32_t *r){++service_pins;return ESP_ERR_INVALID_STATE;} +esp_err_t ts_variable_get(const char *id,ts_auto_value_t *out){out->type=TS_AUTO_VAL_BOOL;out->bool_val=true;return ESP_OK;} +static void record_execution(const char *id,ts_rule_exec_status_t st,ts_rule_trigger_source_t source,const char *why,uint8_t count,uint8_t failed){} +static esp_err_t execute_actions_with_stats(const ts_auto_action_t *a,int n,int *ok,int *failed){action_calls+=n;admitted_action=a[0];*ok=n;*failed=0;return ESP_OK;} void ts_ssh_binding_lock(void){assert(!pthread_mutex_lock(&binding));} void ts_ssh_binding_unlock(void){assert(!pthread_mutex_unlock(&binding));} bool ts_storage_sd_mounted(void){return sd_mounted;} @@ -34,11 +55,39 @@ static esp_err_t load_legacy_nvs(ts_auto_rule_t *r,int *n){assert(0);return ESP_ static esp_err_t load_directory(ts_auto_rule_t *r,int *n,bool *ro){assert(0);return ESP_FAIL;} #define RULES_SDCARD_DIR DIR_RULES static int find_rule_index(const char *id); +static struct {bool initialized,templates_ready;atomic_int templates_load_result;SemaphoreHandle_t ssh_hosts_mutex;ts_action_ssh_host_t ssh_hosts[1];int ssh_host_count;} manager; +static __typeof__(manager) *s_ctx=&manager; +static unsigned template_load_calls,delay_calls,host_mutations; +bool ts_ssh_service_host_protected(const char *id){return false;} +static esp_err_t register_ssh_host_impl(const ts_action_ssh_host_t *h){++host_mutations;internal_host=true;internal_valid=h->port!=0;return ESP_OK;} +static esp_err_t unregister_ssh_host_impl(const char *id){++host_mutations;internal_host=false;return ESP_OK;} +static esp_err_t templates_load_impl(void){++template_load_calls;templates_ready=true;template_state=ESP_OK;return ESP_OK;} +#define ts_action_get_ssh_host_ex host_get_impl +#define ts_action_snapshot snapshot_impl +#include "pack_snapshot.inc" +#undef ts_action_snapshot +#undef ts_action_get_ssh_host_ex +esp_err_t ts_action_get_ssh_host_ex(const char *id,ts_action_ssh_host_t *out,bool *internal){ + manager.ssh_hosts_mutex=&lock;manager.ssh_host_count=internal_host?1:0; + manager.ssh_hosts[0]=(ts_action_ssh_host_t){.id="host",.host="192.0.2.2",.username="internal",.port=internal_valid?2222:0}; + return host_get_impl(id,out,internal); +} +esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *out){return ts_action_get_ssh_host_ex(id,out,NULL);} +#define pdMS_TO_TICKS(ms) (ms) +static void (*delay_hook)(void); +static void vTaskDelay(unsigned ms){assert(++delay_calls<=3);assert(ms==3000||ms==1000);if(ms==1000&&delay_hook)delay_hook();} +static void vTaskDelete(void *task){} +esp_err_t ts_action_snapshot(const ts_auto_action_t *src,ts_auto_action_t *out){ + ++snapshot_calls;if(before_admission){void (*hook)(void)=before_admission;before_admission=NULL;hook();} + return snapshot_impl(src,out); +} #include "pack_engine.inc" +static int engine_capacity=4; +static ts_auto_condition_t ready_condition={.variable="ready",.value={.type=TS_AUTO_VAL_BOOL,.bool_val=true}}; static void boot(void){ for(int i=0;ivalueint,cJSON_GetObjectItem(pre,"expected_generation")->valueint, cJSON_GetObjectItem(pre,"credential_generation")->valueint,cJSON_GetObjectItem(pre,"package_digest")->valuestring,data,reason); } +static void seed_pair(void){ + reset_disk();ts_auto_rule_t a=ordinary("a","Old A"),b=ordinary("b","B");ts_rule_commit_t result; + a.allow_manual_trigger=b.allow_manual_trigger=true; + a.presentation_fields=b.presentation_fields=3; + a.conditions.conditions=&ready_condition;a.conditions.count=1; + assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); + assert(ts_rule_store_set_commit(&a,"a",0,0,NULL,0,NULL,&result)==ESP_OK); + assert(ts_rule_store_set_commit(&b,"b",0,1,NULL,0,NULL,&result)==ESP_OK);boot(); +} +static void confirm_delete(void){ + ts_rule_commit_result_t result;fail_selector_read=true; + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"commit_unknown")); + fail_selector_read=false;assert(ts_rule_refresh_saved()==ESP_OK); +} +static void r1_cases(const char *dir){ + ts_auto_rule_t a=ordinary("a","A edit"),b=ordinary("b","B edit"),c=ordinary("c","C");ts_rule_commit_result_t result; + char *raw=make_pack(dir,"a","Replacement",0);ts_auto_rule_t held;assert(ts_rule_acquire("a",&held)==ESP_OK); + assert(ts_rule_trigger("a")==ESP_OK);unsigned submitted=action_calls;ts_auto_action_t prior=admitted_action; + fail_selector_read=true; + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"commit_unknown")); + assert(ts_rule_refresh_saved()!=ESP_OK&&s_rule_ctx.recovery_error&&!s_rule_ctx.meta[0].pending_delete); + cJSON *unknown=cJSON_CreateObject();ts_rule_saved_status("a",unknown); + assert(!strcmp(cJSON_GetObjectItem(unknown,"pending_change")->valuestring,"unknown")&&cJSON_IsNull(cJSON_GetObjectItem(unknown,"saved_exists")));cJSON_Delete(unknown); + fail_selector_read=false;assert(ts_rule_refresh_saved()==ESP_OK&&ts_rule_refresh_saved()==ESP_OK); + assert(!s_rule_ctx.recovery_error&&ts_rule_restart_pending()&&s_rule_ctx.count==2&&ts_rule_store_set_count()==1); + assert(!strcmp(held.name,"Old A")&&!memcmp(&prior,&admitted_action,sizeof prior)); + ts_api_result_t view={0};assert(api_automation_rules_list(NULL,&view)==ESP_OK&&view.code==0); + cJSON *rows=cJSON_GetObjectItem(view.data,"rules"),*first=cJSON_GetArrayItem(rows,0); + assert(cJSON_GetArraySize(rows)==2&&!strcmp(cJSON_GetObjectItem(first,"pending_change")->valuestring,"delete")); + assert(cJSON_IsTrue(cJSON_GetObjectItem(first,"runtime_active"))&&cJSON_IsFalse(cJSON_GetObjectItem(first,"saved_exists"))); + assert(cJSON_IsNull(cJSON_GetObjectItem(first,"saved_revision"))&&cJSON_IsNull(cJSON_GetObjectItem(first,"saved_source"))&&cJSON_IsNull(cJSON_GetObjectItem(first,"package_digest"))); + assert(cJSON_GetObjectItem(first,"active_revision")->valueint==1);cJSON_Delete(view.data);free(view.message); + unsigned triggers=s_rule_ctx.rules[0].trigger_count;bool triggered=true; + assert(ts_rule_trigger("a")==ESP_ERR_INVALID_STATE);assert(execute_rule("a",false,&triggered)==ESP_OK&&!triggered); + assert(action_calls==submitted&&s_rule_ctx.rules[0].trigger_count==triggers); + assert(ts_rule_commit(&a,"a",0,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rule_commit(&a,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!strcmp(result.error_code,"restart_pending")); + cJSON *data=NULL;const char *reason;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"restart_pending")); + ts_config_pack_t *decoded=NULL;ts_config_pack_acceptance_t accepted;uint32_t credential; + assert(ts_config_pack_load_verified_mem(raw,strlen(raw),NULL,&decoded,&accepted,&credential)==TS_CONFIG_PACK_OK);ts_config_pack_free(decoded); + assert(ts_rule_import_pack(raw,strlen(raw),false,true,0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,"restart_pending")); + assert(ts_rule_commit(&c,"c",0,&result)!=ESP_OK&&!strcmp(result.error_code,"capacity")); + char *newraw=make_pack(dir,"c","C pack",0); + assert(ts_rule_import_pack(newraw,strlen(newraw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"capacity")); + assert(ts_config_pack_load_verified_mem(newraw,strlen(newraw),NULL,&decoded,&accepted,&credential)==TS_CONFIG_PACK_OK);ts_config_pack_free(decoded); + assert(ts_rule_import_pack(newraw,strlen(newraw),false,true,0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,"capacity")); + assert(ts_rule_commit(&b,"b",1,&result)==ESP_OK);ts_rule_saved_info_t info; + assert(ts_rule_store_set_info("a",&info)==ESP_ERR_NOT_FOUND&&ts_rule_store_set_info("b",&info)==ESP_OK&&info.revision==2&&info.source==TS_RULE_SET_NVS_JSON); + ts_rule_release(&held);boot();assert(s_rule_ctx.count==1&&!strcmp(s_rule_ctx.rules[0].id,"b")&&!ts_rule_restart_pending()); + assert(ts_rule_commit(&c,"c",0,&result)==ESP_OK);free(raw);free(newraw); + /* A2: known old selector and known successful deletion preserve old semantics. */ + seed_pair();fail_selector_write=true;assert(ts_rule_commit(NULL,"a",1,&result)!=ESP_OK&&!result.applied);fail_selector_write=false; + s_rule_ctx.recovery_error=true;assert(ts_rule_refresh_saved()==ESP_OK&&!ts_rule_restart_pending()&&!s_rule_ctx.meta[0].pending_delete); + assert(ts_rule_store_set_info("a",&info)==ESP_OK&&info.revision==1); + assert(ts_rule_commit(NULL,"a",1,&result)==ESP_OK&&s_rule_ctx.count==1&&!ts_rule_restart_pending()); + assert(ts_rule_acquire("a",&held)==ESP_ERR_NOT_FOUND&&ts_rule_commit(&c,"c",0,&result)==ESP_OK); + /* A3: deletion is published after snapshot preparation but before final admission. */ + seed_pair();submitted=action_calls;before_admission=confirm_delete; + triggered=false; + assert(execute_rule("a",false,&triggered)==ESP_OK&&!triggered&&action_calls==submitted); + assert(s_rule_ctx.meta[0].pending_delete&&s_rule_ctx.rules[0].trigger_count==0); + assert(ts_rule_trigger("b")==ESP_OK&&action_calls==submitted+1);boot(); + puts("PASS A1-A3: confirmed deletion, unknown/old selector, unchanged active lease/admitted action, final admission barrier, union capacity, sibling edit and reboot"); +} +static void empty_runtime(void){ + for(int i=0;icontent,pack->content_len,&decoded)==ESP_OK); + ts_rule_saved_info_t info={0};bool exists=ts_rule_store_set_info(decoded.id,&info)==ESP_OK;ts_rule_dispose(&decoded);ts_config_pack_free(pack); + assert(ts_rule_import_pack(raw,strlen(raw),false,true,exists?info.revision:0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,wanted));cJSON_Delete(data); +} +static void r3_cases(const char *dir,bool webhook){ + char *raw=make_pack(dir,"probe","Probe",webhook?4:3);cJSON *data=NULL,*pre=NULL;const char *reason=NULL; + unsigned snapshots=snapshot_calls,pins=service_pins,actions=action_calls; + assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing"));cJSON_Delete(data);data=NULL; + ts_config_pack_t *decoded=NULL;ts_config_pack_acceptance_t accepted;uint32_t credential; + assert(ts_config_pack_load_verified_mem(raw,strlen(raw),NULL,&decoded,&accepted,&credential)==TS_CONFIG_PACK_OK);ts_config_pack_free(decoded); + assert(ts_rule_import_pack(raw,strlen(raw),false,true,0,ts_rule_store_set_generation(),credential,accepted.package_sha256,&data,&reason)!=ESP_OK&&!strcmp(reason,webhook?"action_unsupported":"dependency_missing"));cJSON_Delete(data);data=NULL; + ts_auto_rule_t check=ordinary("check","Check");ts_auto_action_t direct={.type=TS_AUTO_ACT_SSH_CMD};strcpy(direct.ssh.host_ref,"host");strcpy(direct.ssh.command,"true");check.actions=&direct; + if(webhook){ + direct.type=TS_AUTO_ACT_WEBHOOK;check.enabled=false;assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"action_unsupported")); + direct.type=TS_AUTO_ACT_LOG;strcpy(direct.template_id,"template");template_action.type=TS_AUTO_ACT_WEBHOOK; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"action_unsupported")); + char *unsupported=make_pack(dir,"template-webhook","Unsupported template",1);reject_pack(unsupported,"action_unsupported");free(unsupported); + template_action.type=TS_AUTO_ACT_LOG;char *templated=make_pack(dir,"probe","Template package",1);pre=preview(templated); + assert(save(templated,pre,&reason,&data)==ESP_OK);cJSON_Delete(data);cJSON_Delete(pre);free(templated); + ts_action_template_t next={.enabled=true,.action={.type=TS_AUTO_ACT_WEBHOOK}}; + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&next)!=ESP_OK&&template_action.type==TS_AUTO_ACT_LOG); + boot();assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&next)!=ESP_OK); + /* Ordinary legacy Webhook remains load-compatible. */ + seed_pair();ts_rule_commit_result_t result;ts_auto_rule_t old=ordinary("a","Old Webhook");old.actions=&direct;direct.template_id[0]=0;direct.type=TS_AUTO_ACT_WEBHOOK; + assert(ts_rule_commit(&old,"a",1,&result)==ESP_OK);templates_ready=false;assert(ts_rules_load()==ESP_OK&&s_rule_ctx.rules[0].actions[0].type==TS_AUTO_ACT_WEBHOOK); + }else{ + host_exists=true;command_state=ESP_ERR_INVALID_STATE;pre=preview(raw);assert(save(raw,pre,&reason,&data)==ESP_OK);cJSON_Delete(pre);cJSON_Delete(data); + ts_ssh_host_config_t proposed;assert(ts_ssh_hosts_config_get("host",&proposed)==ESP_OK);proposed.port=0; + for(int stage=0;stage<2;++stage){ + if(stage)boot();assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)!=ESP_OK); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",&proposed)!=ESP_OK&&config_valid&&host_exists); + internal_host=true;ts_action_ssh_host_t selected;bool internal=false; + assert(ts_action_get_ssh_host_ex("host",&selected,&internal)==ESP_OK&&internal&&selected.port==2222); + host_state=ESP_ERR_INVALID_STATE;assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",&proposed)==ESP_OK); /* shadowed config */ + assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)!=ESP_OK);host_state=ESP_OK; + config_valid=false;assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)!=ESP_OK);config_valid=true; + assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)==ESP_OK); + config_read_error=ESP_FAIL;assert(ts_rule_dependency_change(TS_RULE_DEP_ACTION_HOST,"host",NULL)!=ESP_OK);config_read_error=ESP_OK; + unsigned mutations=host_mutations;config_valid=false; + assert(ts_action_unregister_ssh_host("host")!=ESP_OK&&internal_host&&host_mutations==mutations);config_valid=true; + assert(ts_action_unregister_ssh_host("host")==ESP_OK&&!internal_host&&host_mutations==mutations+1); + selected.port=0;assert(ts_action_register_ssh_host(&selected)!=ESP_OK&&!internal_host&&host_mutations==mutations+1); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"unrelated",NULL)==ESP_OK); + } + /* Effective template wins over stale inline fields; template SSH uses same direct path. */ + strcpy(direct.template_id,"template");host_exists=false;template_action.type=TS_AUTO_ACT_LOG; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK); + template_action=direct;template_action.template_id[0]=0; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)!=ESP_OK&&!strcmp(reason,"dependency_missing")); + char *template_ssh=make_pack(dir,"probe","Template SSH",1);reject_pack(template_ssh,"dependency_missing");host_exists=true; + assert(ts_rule_pack_dependencies(&check,&reason,NULL)==ESP_OK); + template_enabled=false;reject_pack(template_ssh,"dependency_disabled");template_enabled=true; + pre=preview(template_ssh);data=NULL;assert(save(template_ssh,pre,&reason,&data)==ESP_OK);cJSON_Delete(pre);cJSON_Delete(data);free(template_ssh); + /* Retained package and pending package can protect different effective references. */ + template_action.type=TS_AUTO_ACT_LOG;command_state=ESP_OK;char *templated=make_pack(dir,"probe","Pending template",1);pre=preview(templated);data=NULL; + assert(save(templated,pre,&reason,&data)==ESP_OK);cJSON_Delete(pre);cJSON_Delete(data);free(templated); + assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)!=ESP_OK); /* old running direct SSH */ + ts_action_template_t next={.enabled=true,.action=direct};next.action.template_id[0]=0;strcpy(next.action.ssh.host_ref,"missing"); + assert(ts_rule_dependency_change(TS_RULE_DEP_TEMPLATE,"template",&next)!=ESP_OK&&template_action.type==TS_AUTO_ACT_LOG); + boot();assert(ts_rule_dependency_change(TS_RULE_DEP_HOST,"host",NULL)==ESP_OK); + } + assert(snapshot_calls==snapshots&&service_pins==pins&&action_calls==actions&&connection_calls==0&&queue_calls==0&&variable_writes==0);free(raw); + puts(webhook?"PASS C3: direct/template/disabled Webhook refused, protected template unchanged, ordinary compatibility, no snapshot/pin/execute side effects":"PASS C1-C2: direct SSH preview/formal, real host source priority, proposed view/fallback fields and readiness, effective template, active/saved source protection; no snapshot/pin/execute"); +} int main(int argc,char **argv){ - assert(argc==2);const char *dir=argv[1];pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE); + assert(argc==2||argc==3);const char *dir=argv[1];pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE); pthread_mutex_init(&binding,&attr);pthread_mutex_init(&transaction,&attr);pthread_mutex_init(&lock,NULL);pthread_mutexattr_destroy(&attr); ca=read_file(dir,"root.pem");device_key=read_file(dir,"recipient.key");device_cert=read_file(dir,"recipient.pem");assert(ts_config_pack_init()==ESP_OK); reset_disk();ts_auto_rule_t a=ordinary("a","Old A"),b=ordinary("b","B");ts_rule_commit_t stored; + a.allow_manual_trigger=b.allow_manual_trigger=true; + a.presentation_fields=b.presentation_fields=3; + if(argc==3&&!strcmp(argv[2],"r1")){engine_capacity=2;a.conditions.conditions=&ready_condition;a.conditions.count=1;} + if(argc==3&&!strcmp(argv[2],"r2")){ + static ts_auto_action_t templated={.type=TS_AUTO_ACT_LOG};strcpy(templated.template_id,"template");a.actions=&templated;a.enabled=false;template_enabled=false; + } assert(ts_rule_store_set_adopt(NULL,0,TS_RULE_SOURCE_NVS,NULL)==ESP_OK); assert(ts_rule_store_set_commit(&a,"a",0,0,NULL,0,NULL,&stored)==ESP_OK); assert(ts_rule_store_set_commit(&b,"b",0,1,NULL,0,NULL,&stored)==ESP_OK);boot(); + if(argc==3){ + if(!strcmp(argv[2],"r1")){ + r1_cases(dir); + }else if(!strcmp(argv[2],"r2")){ + r2_cases(dir); + }else{ + bool webhook=!strcmp(argv[2],"r3_webhook");assert(webhook||!strcmp(argv[2],"r3_ssh"));r3_cases(dir,webhook); + } + assert(ts_rule_engine_deinit()==ESP_OK);ts_rule_store_set_reset();reset_disk();free(device_key);free(device_cert);free(ca);return 0; + } char *raw=make_pack(dir,"a","Imported A",true);cJSON *pre=preview(raw),*data=NULL;const char *reason; templates_ready=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_loading"));cJSON_Delete(data);data=NULL;templates_ready=true; template_enabled=false;assert(ts_rule_import_pack(raw,strlen(raw),true,false,0,0,0,NULL,&data,&reason)!=ESP_OK&&!strcmp(reason,"dependency_disabled"));cJSON_Delete(data);data=NULL;template_enabled=true; diff --git a/tests/runtime/rule_pack/test_store_set.c b/tests/runtime/rule_pack/test_store_set.c index 9fe65e6..071b2c6 100644 --- a/tests/runtime/rule_pack/test_store_set.c +++ b/tests/runtime/rule_pack/test_store_set.c @@ -12,26 +12,33 @@ #include "ts_ssh_hosts_config.h" #include "nvs.h" static bool templates_ready=true, template_exists=true, template_enabled=true; +static bool config_valid=true,internal_valid=true; +static esp_err_t config_read_error=ESP_OK; static bool command_exists,command_enabled=true,host_exists,internal_host; static esp_err_t command_state=ESP_OK,host_state=ESP_OK; +static esp_err_t template_state=ESP_OK; static ts_auto_action_t template_action={.type=TS_AUTO_ACT_LOG}; esp_err_t ts_action_template_get(const char *id,ts_action_template_t *t){ if(!template_exists||strcmp(id,"template"))return ESP_ERR_NOT_FOUND; memset(t,0,sizeof *t);strcpy(t->id,id);t->enabled=template_enabled;t->action=template_action;return ESP_OK; } bool ts_action_templates_ready(void){return templates_ready;} +esp_err_t ts_action_templates_load_state(void){return templates_ready?ESP_OK:template_state==ESP_OK?ESP_ERR_INVALID_STATE:template_state;} bool ts_ssh_commands_config_is_initialized(void){return true;} bool ts_ssh_hosts_config_is_initialized(void){return true;} esp_err_t ts_ssh_commands_config_load_state(void){return command_state;} esp_err_t ts_ssh_hosts_config_load_state(void){return host_state;} esp_err_t ts_ssh_commands_config_get(const char *id,ts_ssh_command_config_t *c){if(!command_exists||strcmp(id,"command"))return ESP_ERR_NOT_FOUND;memset(c,0,sizeof *c);strcpy(c->id,id);strcpy(c->host_id,"host");c->enabled=command_enabled;return ESP_OK;} -esp_err_t ts_ssh_hosts_config_get(const char *id,ts_ssh_host_config_t *h){if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;h->enabled=false;return ESP_OK;} +esp_err_t ts_ssh_hosts_config_get(const char *id,ts_ssh_host_config_t *h){if(config_read_error!=ESP_OK)return config_read_error;if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=config_valid?22:0;h->enabled=false;return ESP_OK;} +#ifndef RULE_ENGINE_INTEGRATION esp_err_t ts_action_get_ssh_host_ex(const char *id,ts_action_ssh_host_t *h,bool *internal){*internal=internal_host;if(!host_exists||strcmp(id,"host"))return ESP_ERR_NOT_FOUND;memset(h,0,sizeof *h);strcpy(h->id,id);strcpy(h->host,"192.0.2.1");strcpy(h->username,"test");h->port=22;return ESP_OK;} esp_err_t ts_action_get_ssh_host(const char *id,ts_action_ssh_host_t *h){bool internal;return ts_action_get_ssh_host_ex(id,h,&internal);} +#endif typedef struct {char key[24];void *data;size_t size;} value_t; static value_t values[160]; static unsigned operation, fault, mode; static bool fail_selector_read; +static bool fail_selector_write; static jmp_buf crash; static bool before(void){ ++operation; @@ -59,6 +66,7 @@ esp_err_t nvs_get_blob(nvs_handle_t h,const char *key,void *out,size_t *n){ return after()?ESP_FAIL:ESP_OK; } esp_err_t nvs_set_blob(nvs_handle_t h,const char *key,const void *bytes,size_t n){ + if(fail_selector_write&&!strcmp(key,"set_select"))return ESP_FAIL; if(before())return ESP_FAIL;value_t *v=value(key,true);if(!v)return ESP_ERR_NO_MEM; free(v->data);v->data=malloc(n);assert(v->data);memcpy(v->data,bytes,n);v->size=n; return after()?ESP_FAIL:ESP_OK; @@ -102,7 +110,7 @@ static int unlink_fault(const char *p){if(before()){errno=EIO;return -1;}int r=u #undef fclose #undef unlink static void reset_disk(void){ - fail_selector_read=false; + fail_selector_read=fail_selector_write=false; fault=mode=operation=0;ts_rule_store_set_reset(); for(unsigned i=0;i<160;++i){free(values[i].data);memset(&values[i],0,sizeof values[i]);} DIR *d=opendir(OBJECT_DIR);struct dirent *e; diff --git a/tests/runtime/rule_pack_contract.txt b/tests/runtime/rule_pack_contract.txt index 77df358..268f53e 100644 --- a/tests/runtime/rule_pack_contract.txt +++ b/tests/runtime/rule_pack_contract.txt @@ -40,3 +40,26 @@ and never exempt healthy-storage failures because recover returned an error. Run: IDF_PATH=/path/to/esp-idf bash tests/runtime/run_rule_pack.sh (also included by run_all.sh). The fixture builds the SDK's native Mbed TLS and generates disposable OpenSSL PKI; no installed device credential is read. Browser tests: node --test tests/prompts/rule-pack.browser.test.cjs with the existing Playwright dependency. + +R1-R3 review repair (base af6fcf262c38b7fc466dfb3e2180791d94f2f173): +R1: classify union IDs(A,S), including pending_delete and unknown; recovery publishes deletion admission +flags before reopening mutations. Confirmed pending deletion preserves existing runtime objects/leases and +admitted snapshots but denies new automatic/manual admission and same-ID mutations/imports. New IDs consume +union capacity; known successful ordinary deletion still removes runtime immediately. API pending_change, +saved_exists and null saved fields reflect deletion; no digest from an old running version confirms import. +R2: strict static dependencies apply only to package sources. Ordinary NVS/SD JSON keeps prior loading +compatibility. Published metadata stores its own source; a pending replacement does not relabel runtime. +Package guards cover active retained packages (excluding confirmed deletion) plus saved packages. Genuine +first dependency initialization is allowed; destructive reload of retained packages remains protected. +Deferred loading uses actual dependencies; templates distinguish loading from failure, without global waiting. +R3: one read-only effective-action/proposed-object resolver serves preflight and dependency changes. +One-level template priority, direct SSH and command-reference SSH follow actual internal-first host getter. +Fallback fields/readiness and newly shadowing internal hosts are validated before mutation. Direct SSH does +not depend on command readiness. Webhook remains unimplemented: new packages/protected template changes reject +it even for disabled packages; ordinary legacy rules retain loading behavior. No snapshots/pins/connections, +queue admission, execution or variable writes occur in this checker; dynamic execution is not prevalidated. +Directed A1-A3/B1-B3/C1-C3 tests extract actual production engine/API/snapshot/host-getter/initialization guards +and use real crypto, codec and complete-set store. Hardware/RTOS, local dependency storage and NVS are adapters; +there is no claim of physical reboot, real SSH/network execution, or validation of installed device credentials. +Browser tests use the real bilingual page with simulated API replies; runtime tests separately prove payloads. +No v3 storage layout, selector protocol, encrypted trust or executor semantics are changed by this repair. diff --git a/tests/runtime/run_rule_pack.sh b/tests/runtime/run_rule_pack.sh index bd2e222..db35e57 100755 --- a/tests/runtime/run_rule_pack.sh +++ b/tests/runtime/run_rule_pack.sh @@ -7,3 +7,7 @@ python3 tests/runtime/test_rule_pack_crypto.py python3 tests/runtime/test_rule_pack_crypto.py --unconfigured python3 tests/runtime/test_rule_pack_crypto.py --store python3 tests/runtime/test_rule_pack_crypto.py --engine +# Directed review counterexamples, using the same production fixture and PKI. +for scenario in r1 r2 r3_ssh r3_webhook; do + python3 tests/runtime/test_rule_pack_crypto.py --engine --case="$scenario" +done diff --git a/tests/runtime/test_action_pack.py b/tests/runtime/test_action_pack.py index 722c6ca..405a905 100644 --- a/tests/runtime/test_action_pack.py +++ b/tests/runtime/test_action_pack.py @@ -9,7 +9,7 @@ def extract(path,name): source=Path(path).read_text();m=re.search(r'^(?:static )?(?:\w+[ \t]+)+[ \t*]*'+name+r'\([^;]+?\)\s*\{',source,re.M);assert m,name return source[m.start():source.index('\n}',m.start())+2]+'\n' -code=code.replace('int template_count;', 'int template_count; bool templates_ready;') +code=code.replace('int template_count;', 'int template_count; bool templates_ready; int templates_load_result;') code+=r''' #include "ts_rule_engine.h" esp_err_t ts_rule_dependency_change(ts_rule_dependency_t k,const char *id,const void *next){return ESP_OK;} diff --git a/tests/runtime/test_rule_pack_crypto.py b/tests/runtime/test_rule_pack_crypto.py index a771167..d9a5d67 100644 --- a/tests/runtime/test_rule_pack_crypto.py +++ b/tests/runtime/test_rule_pack_crypto.py @@ -68,16 +68,17 @@ def quiet(args): src=(root/'components/ts_automation/src/ts_rule_engine.c').read_text() a=src.index('typedef struct {\n ts_auto_rule_t *rules;');b=src.index('static void payload_free',a) names=['find_rule_index','payload_free','payload_adopt','ts_rule_resolve_presentation', - 'ts_rule_acquire','ts_rule_release','same_config','protected_bindings','pending_id', + 'ts_rule_acquire','ts_rule_release','same_config','protected_bindings','classify_rule_state','state_pending','pending_id','rule_union_count_locked','rebuild_rule_states', 'rule_commit_impl','ts_rule_commit','ts_rule_get_by_index','ts_rule_restart_pending', 'ts_rule_saved_status','ts_rule_pending_list','pack_reason','ts_rule_import_pack', 'ts_rule_edit_begin','ts_rule_edit_end','ts_rule_count','ts_rule_config_status','ts_rule_load_error', 'dependency_change_rule','ts_rule_dependency_change','load_rules','ts_rules_load', - 'ts_rules_load_from_file','ts_rule_engine_deinit','ts_rule_refresh_saved'] + 'ts_rules_load_from_file','ts_rule_engine_deinit','ts_rule_refresh_saved','ts_rule_deferred_load_task', + 'compare_values','ts_rule_eval_condition','ts_rule_eval_condition_group','execute_rule','ts_rule_trigger'] parts=[src[a:b]] masked=re.sub(r'/\*[\s\S]*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"',lambda m:' '*len(m.group()),src) for name in names: - match=re.search(r'^(?:static )?(?:esp_err_t|void|bool|int|cJSON|const char|rule_payload_t)[ \t]+\**'+name+r'\([^;]*?\)\s*\{',src,re.M) + match=re.search(r'^(?:static )?(?:esp_err_t|void|bool|int|cJSON|const char|rule_payload_t|rule_state_t)[ \t]+\**'+name+r'\([^;]*?\)\s*\{',src,re.M) assert match,name opening=src.index('{',match.start());depth=0 for end in range(opening,len(masked)): @@ -85,12 +86,21 @@ def quiet(args): if not depth:break parts.append(src[match.start():end+1]) api=(root/'components/ts_api/src/ts_api_automation.c').read_text() - a=api.index('static esp_err_t api_automation_rules_list(') - parts.append(api[a:api.index('\n}',a)+2]) + for name in ['api_automation_rules_list','rule_commit_reply','rule_mutate_locked','rule_mutate','api_automation_rules_enable']: + match=re.search(r'^static (?:esp_err_t|void) '+name+r'\([^;]*?\)\s*\{',api,re.M);assert match,name + parts.append(api[match.start():api.index('\n}',match.start())+2]) (build/'pack_engine.inc').write_text('\n'.join(parts)) + manager=(root/'components/ts_automation/src/ts_action_manager.c').read_text() + a=manager.index('typedef struct {\n atomic_uint refs;');b=manager.index('} action_binding_t;',a)+len('} action_binding_t;') + snapshots=[manager[a:b]] + for name in ['ts_action_register_ssh_host','ts_action_unregister_ssh_host','ts_action_get_ssh_host_ex','ts_action_templates_load','ts_action_snapshot_owner','ts_action_snapshot_retain','ts_action_snapshot_release','snapshot_command','ts_action_snapshot']: + m=re.search(r'^(?:static )?(?:void|esp_err_t) '+name+r'\([^;]*?\)\s*\{',manager,re.M);assert m,name + snapshots.append(manager[m.start():manager.index('\n}',m.start())+2]) + (build/'pack_snapshot.inc').write_text('\n'.join(snapshots)) sources[0]=root/'tests/runtime/rule_pack/test_engine_pack.c' cmd += ['-I'+str(build),'-DCONFIG_TS_AUTOMATION_MAX_RULES=4'] executable=build/('crypto-baseline' if '--baseline' in sys.argv else 'unconfigured' if '--unconfigured' in sys.argv else 'engine' if '--engine' in sys.argv else 'store' if '--store' in sys.argv else 'crypto') cmd += sources+[native/'library/libmbedx509.a',native/'library/libmbedcrypto.a','-lpthread','-o',executable] run(cmd,cwd=root) -run([executable,pki],cwd=root,env={**env,'ASAN_OPTIONS':'detect_leaks=0'} if '--store' in sys.argv else env) +case=next((a.split('=',1)[1] for a in sys.argv if a.startswith('--case=')),None) +run([executable,pki,*([case] if case else [])],cwd=root,env={**env,'ASAN_OPTIONS':'detect_leaks=0'} if '--store' in sys.argv else env) From bdc987d93d6050de2602528f2750ede89225c276 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sat, 10 Oct 2026 11:28:09 +0800 Subject: [PATCH 7/7] test: link rule-pack math and declare POSIX gate APIs on Linux --- docs/repair/delete-protection/INTEGRATION.md | 8 ++++++++ tests/runtime/test_configuration_protocol.py | 2 +- tests/runtime/test_rule_pack_crypto.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/repair/delete-protection/INTEGRATION.md b/docs/repair/delete-protection/INTEGRATION.md index 46d19b5..a1f5ed1 100644 --- a/docs/repair/delete-protection/INTEGRATION.md +++ b/docs/repair/delete-protection/INTEGRATION.md @@ -58,3 +58,11 @@ 真实签名根、Developer 证书用途、可信时间、NVS/SD 空间及设备验收仍未完成。 启动即可发生旧集合到 v3 的迁移;不承诺旧固件原地降级。完整迁移/信任边界见 `tests/runtime/rule_pack_contract.txt`;R1–R3 历史红/绿证据见 `tests/runtime/rule_pack/R1-R3-review.md`。 + +## GitHub Linux CI 跟进 + +整合提交后的首轮 runtime job(run `38020495204`,job `114120217161`)在实际 Linux 链接时 +报告 `round@@GLIBC_2.2.5` / `DSO missing from command line`。规则包原生夹具编译命令漏了 `-lm`, +macOS 自动带入系统数学符号,因而本地未暴露。新增显式 `-lm`;不改固件或断言。 +同时给同样编译真实递归锁夹具的 configuration_protocol 命令补 POSIX 接口声明,与其余调用者一致。 +这是测试构建适配,不修改生产源码;实际固件和 WebUI 产物因此不需重建。 diff --git a/tests/runtime/test_configuration_protocol.py b/tests/runtime/test_configuration_protocol.py index ee97f7f..9e22883 100644 --- a/tests/runtime/test_configuration_protocol.py +++ b/tests/runtime/test_configuration_protocol.py @@ -53,5 +53,5 @@ def extract(path,name): ''' (build/'configuration_protocol.c').write_text(s) env={**os.environ,'DEVELOPER_DIR':'/Library/Developer/CommandLineTools'} -subprocess.run(['cc','-std=c11','-g','-fsanitize=address,undefined',*(['-isysroot','/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk'] if sys.platform == 'darwin' else []),*[f'-I{x}' for x in ['tests/runtime/state_stubs','tests/runtime/ssh_stubs','tests/runtime/stubs','tests/certificate/stubs','components/ts_security/include','components/ts_automation/include']],str(build/'configuration_protocol.c'),'components/ts_security/src/ts_ssh_service.c','components/ts_security/src/ts_ssh_log_watch.c','components/ts_security/src/ts_ssh_probe.c','-lpthread','-o',str(build/'configuration_protocol')],check=True,env=env) +subprocess.run(['cc','-std=c11','-D_POSIX_C_SOURCE=200809L','-g','-fsanitize=address,undefined',*(['-isysroot','/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk'] if sys.platform == 'darwin' else []),*[f'-I{x}' for x in ['tests/runtime/state_stubs','tests/runtime/ssh_stubs','tests/runtime/stubs','tests/certificate/stubs','components/ts_security/include','components/ts_automation/include']],str(build/'configuration_protocol.c'),'components/ts_security/src/ts_ssh_service.c','components/ts_security/src/ts_ssh_log_watch.c','components/ts_security/src/ts_ssh_probe.c','-lpthread','-o',str(build/'configuration_protocol')],check=True,env=env) subprocess.run([str(build/'configuration_protocol')],check=True,env=env) diff --git a/tests/runtime/test_rule_pack_crypto.py b/tests/runtime/test_rule_pack_crypto.py index d8693ca..f6f0c96 100644 --- a/tests/runtime/test_rule_pack_crypto.py +++ b/tests/runtime/test_rule_pack_crypto.py @@ -100,7 +100,7 @@ def quiet(args): sources[0]=root/'tests/runtime/rule_pack/test_engine_pack.c' cmd += ['-I'+str(build),'-DCONFIG_TS_AUTOMATION_MAX_RULES=4'] executable=build/('crypto-baseline' if '--baseline' in sys.argv else 'unconfigured' if '--unconfigured' in sys.argv else 'engine' if '--engine' in sys.argv else 'store' if '--store' in sys.argv else 'crypto') -cmd += sources+[native/'library/libmbedx509.a',native/'library/libmbedcrypto.a','-lpthread','-o',executable] +cmd += sources+[native/'library/libmbedx509.a',native/'library/libmbedcrypto.a','-lpthread','-lm','-o',executable] run(cmd,cwd=root) case=next((a.split('=',1)[1] for a in sys.argv if a.startswith('--case=')),None) run([executable,pki,*([case] if case else [])],cwd=root,env={**env,'ASAN_OPTIONS':'detect_leaks=0'} if '--store' in sys.argv else env)