diff --git a/easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs b/easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs index 53b3ecaf..bed9fe3f 100644 --- a/easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs +++ b/easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs @@ -1,4 +1,6 @@ -use crate::config::types::stored_config::{StoredConfigList, StoredConfigMeta}; +use crate::config::types::stored_config::{ + SnapshotImportResult, StoredConfigList, StoredConfigMeta, +}; use ohos_hilog_binding::{hilog_debug, hilog_error}; use rusqlite::{Connection, OptionalExtension, params}; use std::path::{Path, PathBuf}; @@ -18,6 +20,30 @@ struct StoredConfigMetaRecord { temporary: bool, } +type SnapshotFieldRow = (String, String, String, String); + +fn snapshot_import_ok() -> SnapshotImportResult { + SnapshotImportResult { + ok: true, + error_code: String::new(), + error_message: String::new(), + snapshot_invalid: false, + } +} + +fn snapshot_import_err( + error_code: &str, + error_message: impl Into, + snapshot_invalid: bool, +) -> SnapshotImportResult { + SnapshotImportResult { + ok: false, + error_code: error_code.to_string(), + error_message: error_message.into(), + snapshot_invalid, + } +} + pub(crate) fn now_ts_string() -> String { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -125,38 +151,64 @@ fn validate_snapshot_schema(conn: &Connection) -> bool { has_stored_configs && has_stored_fields } -fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Result<()> { +fn read_snapshot_tables( + src: &Connection, +) -> rusqlite::Result<(Vec, Vec)> { + src.execute_batch("BEGIN DEFERRED TRANSACTION")?; + let mut meta_rows = Vec::::new(); - { - let mut stmt = src.prepare( - "SELECT config_id, display_name, created_at, updated_at, favorite, temporary - FROM stored_configs", - )?; - let rows = stmt.query_map([], row_to_meta)?; - for row in rows { - meta_rows.push(row?); - } - } - - let mut field_rows = Vec::<(String, String, String, String)>::new(); - { - let mut stmt = src.prepare( - "SELECT config_id, field_name, field_json, updated_at - FROM stored_config_fields", - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - })?; - for row in rows { - field_rows.push(row?); + let mut field_rows = Vec::::new(); + + let read_result = (|| -> rusqlite::Result<()> { + { + let mut stmt = src.prepare( + "SELECT config_id, display_name, created_at, updated_at, favorite, temporary + FROM stored_configs", + )?; + let rows = stmt.query_map([], row_to_meta)?; + for row in rows { + meta_rows.push(row?); + } + } + + { + let mut stmt = src.prepare( + "SELECT config_id, field_name, field_json, updated_at + FROM stored_config_fields", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + })?; + for row in rows { + field_rows.push(row?); + } + } + + Ok(()) + })(); + + match read_result { + Ok(()) => { + src.execute_batch("COMMIT")?; + Ok((meta_rows, field_rows)) + } + Err(err) => { + let _ = src.execute_batch("ROLLBACK"); + Err(err) } } +} +fn write_snapshot_tables( + dst: &mut Connection, + meta_rows: Vec, + field_rows: Vec, +) -> rusqlite::Result<()> { let tx = dst.unchecked_transaction()?; tx.execute("DELETE FROM stored_config_fields", [])?; tx.execute("DELETE FROM stored_configs", [])?; @@ -188,6 +240,11 @@ fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Res tx.commit() } +fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Result<()> { + let (meta_rows, field_rows) = read_snapshot_tables(src)?; + write_snapshot_tables(dst, meta_rows, field_rows) +} + fn ensure_parent_dir(path: &Path) -> bool { match path.parent() { Some(parent) => match std::fs::create_dir_all(parent) { @@ -286,7 +343,7 @@ pub fn export_config_store_snapshot(target_path: String) -> bool { } } -pub fn import_config_store_snapshot(source_path: String) -> bool { +pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult { let source = PathBuf::from(source_path); let src = match Connection::open(&source) { Ok(conn) => conn, @@ -296,29 +353,52 @@ pub fn import_config_store_snapshot(source_path: String) -> bool { source.display(), e ); - return false; + return snapshot_import_err("source_open_failed", e.to_string(), false); } }; if !validate_snapshot_schema(&src) { hilog_error!("[Rust] invalid snapshot schema {}", source.display()); - return false; + return snapshot_import_err( + "invalid_snapshot_schema", + format!("invalid snapshot schema: {}", source.display()), + true, + ); } - let Some(mut dst) = open_db() else { - return false; + let (meta_rows, field_rows) = match read_snapshot_tables(&src) { + Ok(rows) => rows, + Err(e) => { + hilog_error!( + "[Rust] failed to read snapshot source {}: {}", + source.display(), + e + ); + return snapshot_import_err("invalid_snapshot_data", e.to_string(), true); + } }; - match copy_snapshot_tables(&src, &mut dst) { - Ok(_) => true, + let Some(mut dst) = open_db() else { + return snapshot_import_err( + "destination_open_failed", + "failed to open local config store", + false, + ); + }; + match write_snapshot_tables(&mut dst, meta_rows, field_rows) { + Ok(_) => snapshot_import_ok(), Err(e) => { hilog_error!( "[Rust] failed to import snapshot {}: {}", source.display(), e ); - false + snapshot_import_err("destination_write_failed", e.to_string(), false) } } } +pub fn import_config_store_snapshot(source_path: String) -> bool { + import_config_store_snapshot_with_result(source_path).ok +} + pub fn list_config_meta_entries() -> StoredConfigList { let Some(conn) = open_db() else { return StoredConfigList { configs: vec![] }; @@ -492,6 +572,49 @@ pub fn set_config_display_name( Some(to_meta(record)) } +pub fn set_config_favorite(config_id: String, favorite: bool) -> Option { + let conn = open_db()?; + let now = now_ts_string(); + let tx = conn.unchecked_transaction().ok()?; + + if favorite { + tx.execute( + "UPDATE stored_configs + SET favorite = 0, + updated_at = CASE WHEN favorite != 0 THEN ?1 ELSE updated_at END + WHERE favorite != 0 AND config_id <> ?2", + params![now, config_id.clone()], + ) + .ok()?; + } + + let rows = tx + .execute( + "UPDATE stored_configs + SET favorite = ?2, updated_at = ?3 + WHERE config_id = ?1", + params![config_id.clone(), if favorite { 1 } else { 0 }, now], + ) + .ok()?; + if rows == 0 { + return None; + } + + let meta = tx + .query_row( + "SELECT config_id, display_name, created_at, updated_at, favorite, temporary + FROM stored_configs WHERE config_id = ?1", + params![config_id], + row_to_meta, + ) + .optional() + .ok() + .flatten() + .map(to_meta)?; + tx.commit().ok()?; + Some(meta) +} + pub fn delete_config_meta(config_id: &str) -> bool { let Some(conn) = open_db() else { return false; diff --git a/easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs b/easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs index 86375416..4b91168e 100644 --- a/easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs +++ b/easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs @@ -66,3 +66,13 @@ pub struct KeyValuePair { pub key: String, pub value: String, } + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct SnapshotImportResult { + pub ok: bool, + pub error_code: String, + pub error_message: String, + pub snapshot_invalid: bool, +} diff --git a/easytier-contrib/easytier-ohrs/src/config_repo.rs b/easytier-contrib/easytier-ohrs/src/config_repo.rs index cbd8bf5b..d415c502 100644 --- a/easytier-contrib/easytier-ohrs/src/config_repo.rs +++ b/easytier-contrib/easytier-ohrs/src/config_repo.rs @@ -65,6 +65,9 @@ pub fn init_config_store(root_dir: String) -> bool { } fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> { + if validation::validate_config_id(config_id).is_err() { + return None; + } legacy_migration::migrate_legacy_file_if_needed( &config_root_dir(), CONFIG_DIR_NAME, @@ -133,18 +136,21 @@ pub fn save_config_record( } pub fn load_config_json(config_id: &str) -> Option { + validation::validate_config_id(config_id).ok()?; migrate_legacy_file_if_needed(config_id)?; let object = field_store::load_config_map_from_db(config_id)?; serde_json::to_string(&Value::Object(object)).ok() } pub fn get_config_record(config_id: &str) -> Option { + validation::validate_config_id(config_id).ok()?; let config_json = load_config_json(config_id)?; let meta = get_config_meta(config_id)?; Some(StoredConfigRecord { meta, config_json }) } pub fn get_config_field_value(config_id: &str, field: &str) -> Option { + validation::validate_config_id(config_id).ok()?; migrate_legacy_file_if_needed(config_id)?; let conn = open_db()?; conn.query_row( @@ -157,6 +163,9 @@ pub fn get_config_field_value(config_id: &str, field: &str) -> Option { } pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> bool { + if validation::validate_config_id(config_id).is_err() { + return false; + } if field.contains('.') { return false; } @@ -192,6 +201,7 @@ pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> } pub fn get_display_name(config_id: &str) -> Option { + validation::validate_config_id(config_id).ok()?; get_config_meta(config_id).map(|meta| meta.display_name) } @@ -200,6 +210,7 @@ pub fn get_default_config_json() -> Option { } pub fn create_config_record(config_id: String, display_name: String) -> Option { + validation::validate_config_id(&config_id).ok()?; let raw = get_default_config_json()?; let mut config = serde_json::from_str::(&raw).ok()?; config.instance_id = Some(config_id.clone()); @@ -208,6 +219,9 @@ pub fn create_config_record(config_id: String, display_name: String) -> Option bool { + if validation::validate_config_id(config_id).is_err() { + return false; + } let raw = match load_config_json(config_id) { Some(raw) => raw, None => return false, @@ -220,6 +234,9 @@ pub fn list_config_meta_json() -> String { } pub fn delete_config_record(config_id: &str) -> bool { + if validation::validate_config_id(config_id).is_err() { + return false; + } if let Some(path) = legacy_config_file_path(config_id) { if path.exists() { let _ = std::fs::remove_file(path); @@ -242,6 +259,7 @@ pub fn delete_config_record(config_id: &str) -> bool { } pub fn export_config_toml(config_id: &str) -> Option { + validation::validate_config_id(config_id).ok()?; let record = get_config_record(config_id)?; import_export::export_config_toml_from_record(&record) } diff --git a/easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs b/easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs index 6efa3b6c..4dbee7c8 100644 --- a/easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs +++ b/easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs @@ -2,11 +2,17 @@ use crate::config::storage::config_meta::get_config_meta; use ohos_hilog_binding::hilog_error; use std::path::PathBuf; +use super::validation; + pub(super) fn legacy_config_file_path( root_dir: &Option, config_dir_name: &str, config_id: &str, ) -> Option { + if !validation::is_valid_config_id(config_id) { + hilog_error!("[Rust] invalid legacy config_id {}", config_id); + return None; + } root_dir.as_ref().map(|root| { root.join(config_dir_name) .join(format!("{}.json", config_id)) diff --git a/easytier-contrib/easytier-ohrs/src/config_repo/validation.rs b/easytier-contrib/easytier-ohrs/src/config_repo/validation.rs index cc7551fb..7e91fd6b 100644 --- a/easytier-contrib/easytier-ohrs/src/config_repo/validation.rs +++ b/easytier-contrib/easytier-ohrs/src/config_repo/validation.rs @@ -1,13 +1,25 @@ use easytier::proto::api::manage::NetworkConfig; use serde_json::{Map, Value}; +use uuid::Uuid; + +pub(super) fn validate_config_id(config_id: &str) -> Result<(), String> { + if config_id.is_empty() { + return Err("config_id is required".to_string()); + } + Uuid::parse_str(config_id) + .map(|_| ()) + .map_err(|e| format!("invalid config_id {}: {}", config_id, e)) +} + +pub(super) fn is_valid_config_id(config_id: &str) -> bool { + validate_config_id(config_id).is_ok() +} pub(super) fn normalize_config_id( mut config: NetworkConfig, requested_id: String, ) -> Result { - if requested_id.is_empty() { - return Err("config_id is required".to_string()); - } + validate_config_id(&requested_id)?; config.instance_id = Some(requested_id); Ok(config) } diff --git a/easytier-contrib/easytier-ohrs/src/exports/config_api.rs b/easytier-contrib/easytier-ohrs/src/exports/config_api.rs index ba8bc02e..31d55842 100644 --- a/easytier-contrib/easytier-ohrs/src/exports/config_api.rs +++ b/easytier-contrib/easytier-ohrs/src/exports/config_api.rs @@ -1,4 +1,5 @@ use crate::config; +use crate::config::types::stored_config::SnapshotImportResult; pub(crate) fn init_config_store(root_dir: String) -> bool { config::repository::init_config_store(root_dir) @@ -36,6 +37,10 @@ pub(crate) fn set_config_field(config_id: String, field: String, json_value: Str config::repository::set_config_field_value(&config_id, &field, &json_value) } +pub(crate) fn set_config_favorite(config_id: String, favorite: bool) -> bool { + config::storage::config_meta::set_config_favorite(config_id, favorite).is_some() +} + pub(crate) fn import_toml(toml_text: String, display_name: Option) -> Option { config::repository::import_toml_config(toml_text, display_name) .map(|record| record.meta.config_id) @@ -52,3 +57,9 @@ pub(crate) fn export_config_store_snapshot(target_path: String) -> bool { pub(crate) fn import_config_store_snapshot(source_path: String) -> bool { config::storage::config_meta::import_config_store_snapshot(source_path) } + +pub(crate) fn import_config_store_snapshot_with_result( + source_path: String, +) -> SnapshotImportResult { + config::storage::config_meta::import_config_store_snapshot_with_result(source_path) +} diff --git a/easytier-contrib/easytier-ohrs/src/lib.rs b/easytier-contrib/easytier-ohrs/src/lib.rs index a8634f0f..a9c3e1ff 100644 --- a/easytier-contrib/easytier-ohrs/src/lib.rs +++ b/easytier-contrib/easytier-ohrs/src/lib.rs @@ -20,7 +20,7 @@ use config::services::share_link_service::{ parse_config_share_link as parse_config_share_link_inner, }; use config::storage::config_meta::get_config_display_name; -use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload}; +use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload, SnapshotImportResult}; use easytier::common::constants::EASYTIER_VERSION; use easytier::common::{ MachineIdOptions, @@ -353,6 +353,11 @@ pub fn set_config_field(config_id: String, field: String, json_value: String) -> exports::config_api::set_config_field(config_id, field, json_value) } +#[napi] +pub fn set_config_favorite(config_id: String, favorite: bool) -> bool { + exports::config_api::set_config_favorite(config_id, favorite) +} + #[napi] pub fn import_toml(toml_text: String, display_name: Option) -> Option { exports::config_api::import_toml(toml_text, display_name) @@ -373,6 +378,11 @@ pub fn import_config_store_snapshot(source_path: String) -> bool { exports::config_api::import_config_store_snapshot(source_path) } +#[napi] +pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult { + exports::config_api::import_config_store_snapshot_with_result(source_path) +} + #[napi] pub fn start_kernel(config_id: String) -> bool { exports::runtime_api::start_kernel(config_id, start_kernel_with_config_id)