1use serde::{Deserialize, Serialize};
2use tauri::path::BaseDirectory;
3
4use crate::dotpath;
5use crate::error::{Error, Result};
6
7pub const DEFAULT_SQLITE_DB_NAME: &str = "configurate.db";
8pub const DEFAULT_SQLITE_TABLE_NAME: &str = "configurate_configs";
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum ProviderKind {
14 Json,
15 Yml,
16 Binary,
17 Sqlite,
18 Toml,
19}
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct ProviderPayload {
25 pub kind: ProviderKind,
26 pub encryption_key: Option<String>,
27 pub kdf: Option<KeyDerivation>,
28 pub db_name: Option<String>,
29 pub table_name: Option<String>,
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct PathOptions {
36 pub dir_name: Option<String>,
37 pub current_path: Option<String>,
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct KeyringEntry {
44 pub id: String,
46 pub dotpath: String,
48 pub value: String,
50 #[serde(default)]
52 pub is_optional: bool,
53}
54
55#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct KeyringOptions {
61 pub service: String,
63 pub account: String,
65}
66
67#[derive(Debug, Clone, Deserialize, Serialize)]
69#[serde(rename_all = "lowercase")]
70pub enum SqliteValueType {
71 String,
72 Number,
73 Boolean,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct SqliteColumn {
80 pub column_name: String,
81 pub dotpath: String,
82 pub value_type: SqliteValueType,
83 #[serde(default)]
84 pub is_keyring: bool,
85}
86
87#[derive(Debug, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ConfiguratePayload {
91 pub file_name: Option<String>,
92 pub base_dir: Option<BaseDirectory>,
93 pub options: Option<PathOptions>,
94 pub provider: Option<ProviderPayload>,
95 #[serde(default)]
96 pub schema_columns: Vec<SqliteColumn>,
97
98 pub data: Option<serde_json::Value>,
100 pub keyring_entries: Option<Vec<KeyringEntry>>,
101 pub keyring_options: Option<KeyringOptions>,
102 #[serde(default)]
103 pub keyring_delete_ids: Vec<String>,
104 #[serde(default)]
105 pub with_unlock: bool,
106 pub return_data: Option<bool>,
109 #[serde(default)]
112 pub create_if_missing: bool,
113 #[serde(default)]
116 pub backup: bool,
117}
118
119#[derive(Debug, Clone, Deserialize, Serialize)]
121#[serde(rename_all = "lowercase")]
122pub enum KeyDerivation {
123 Sha256,
124 Argon2,
125}
126
127#[derive(Clone)]
133pub enum NormalizedProvider {
134 Json,
135 Yml,
136 Toml,
137 Binary {
138 encryption_key: Option<String>,
139 kdf: KeyDerivation,
140 },
141 Sqlite { db_name: String, table_name: String },
142}
143
144impl std::fmt::Debug for NormalizedProvider {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 match self {
149 Self::Json => write!(f, "Json"),
150 Self::Yml => write!(f, "Yml"),
151 Self::Toml => write!(f, "Toml"),
152 Self::Binary { encryption_key, kdf } => f
153 .debug_struct("Binary")
154 .field(
155 "encryption_key",
156 &encryption_key.as_ref().map(|_| "[REDACTED]"),
157 )
158 .field("kdf", kdf)
159 .finish(),
160 Self::Sqlite { db_name, table_name } => f
161 .debug_struct("Sqlite")
162 .field("db_name", db_name)
163 .field("table_name", table_name)
164 .finish(),
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
171pub struct NormalizedConfiguratePayload {
172 pub file_name: String,
173 pub base_dir: BaseDirectory,
174 pub dir_name: Option<String>,
175 pub current_path: Option<String>,
176 pub provider: NormalizedProvider,
177 pub schema_columns: Vec<SqliteColumn>,
178 pub data: Option<serde_json::Value>,
179 pub keyring_entries: Option<Vec<KeyringEntry>>,
180 pub keyring_options: Option<KeyringOptions>,
181 pub keyring_delete_ids: Vec<String>,
182 pub with_unlock: bool,
183 pub return_data: bool,
184 pub create_if_missing: bool,
186 pub backup: bool,
188}
189
190impl ConfiguratePayload {
191 pub fn normalize(self) -> Result<NormalizedConfiguratePayload> {
192 let file_name = self
193 .file_name
194 .ok_or_else(|| Error::InvalidPayload("missing fileName".to_string()))?;
195
196 let base_dir = self
197 .base_dir
198 .ok_or_else(|| Error::InvalidPayload("missing baseDir".to_string()))?;
199
200 let (dir_name, current_path) = match self.options {
201 Some(opts) => (opts.dir_name, opts.current_path),
202 None => (None, None),
203 };
204
205 let provider_payload = self
206 .provider
207 .ok_or_else(|| Error::InvalidPayload("missing provider".to_string()))?;
208
209 if !self.keyring_delete_ids.is_empty() && self.keyring_options.is_none() {
210 return Err(Error::InvalidPayload(
211 "keyringDeleteIds provided without keyringOptions".to_string(),
212 ));
213 }
214
215 if !matches!(&provider_payload.kind, ProviderKind::Binary)
216 && provider_payload.encryption_key.is_some()
217 {
218 return Err(Error::InvalidPayload(
219 "encryptionKey is only supported with provider.kind='binary'".to_string(),
220 ));
221 }
222
223 if !matches!(&provider_payload.kind, ProviderKind::Binary)
224 && provider_payload.kdf.is_some()
225 {
226 return Err(Error::InvalidPayload(
227 "kdf is only supported with provider.kind='binary'".to_string(),
228 ));
229 }
230
231 let provider = match provider_payload.kind {
232 ProviderKind::Json => NormalizedProvider::Json,
233 ProviderKind::Yml => NormalizedProvider::Yml,
234 ProviderKind::Toml => NormalizedProvider::Toml,
235 ProviderKind::Binary => NormalizedProvider::Binary {
236 encryption_key: provider_payload.encryption_key,
237 kdf: provider_payload.kdf.unwrap_or(KeyDerivation::Sha256),
238 },
239 ProviderKind::Sqlite => NormalizedProvider::Sqlite {
240 db_name: provider_payload
241 .db_name
242 .unwrap_or_else(|| DEFAULT_SQLITE_DB_NAME.to_string()),
243 table_name: provider_payload
244 .table_name
245 .unwrap_or_else(|| DEFAULT_SQLITE_TABLE_NAME.to_string()),
246 },
247 };
248
249 let schema_columns = self.schema_columns;
250
251 for column in &schema_columns {
254 dotpath::validate_path(&column.dotpath).map_err(|e| {
255 Error::InvalidPayload(format!(
256 "invalid dotpath in column '{}': {}",
257 column.column_name, e
258 ))
259 })?;
260 }
261
262 Ok(NormalizedConfiguratePayload {
263 file_name,
264 base_dir,
265 dir_name,
266 current_path,
267 provider,
268 schema_columns,
269 data: self.data,
270 keyring_entries: self.keyring_entries,
271 keyring_options: self.keyring_options,
272 keyring_delete_ids: self.keyring_delete_ids,
273 with_unlock: self.with_unlock,
274 return_data: self.return_data.unwrap_or(true),
275 create_if_missing: self.create_if_missing,
276 backup: self.backup,
277 })
278 }
279}
280
281#[derive(Debug, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct UnlockPayload {
285 pub data: serde_json::Value,
286 pub keyring_entries: Option<Vec<KeyringEntry>>,
287 pub keyring_options: Option<KeyringOptions>,
288}
289
290pub type PatchPayload = ConfiguratePayload;
292
293#[derive(Debug, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct BatchEntryPayload {
297 pub id: String,
298 pub payload: ConfiguratePayload,
299}
300
301#[derive(Debug, Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub struct BatchPayload {
305 pub entries: Vec<BatchEntryPayload>,
306}
307
308#[derive(Debug, Serialize)]
310pub struct BatchEntrySuccess {
311 pub ok: bool,
312 pub data: serde_json::Value,
313}
314
315#[derive(Debug, Serialize)]
317pub struct BatchEntryFailure {
318 pub ok: bool,
319 pub error: serde_json::Value,
320}
321
322#[derive(Debug, Serialize)]
324#[serde(untagged)]
325pub enum BatchEntryResult {
326 Success(BatchEntrySuccess),
327 Failure(BatchEntryFailure),
328}
329
330#[derive(Debug, Serialize)]
332pub struct BatchRunResult {
333 pub results: std::collections::BTreeMap<String, BatchEntryResult>,
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 fn base_payload() -> ConfiguratePayload {
341 ConfiguratePayload {
342 file_name: Some("app.json".to_string()),
343 base_dir: Some(BaseDirectory::AppConfig),
344 options: None,
345 provider: None,
346 schema_columns: Vec::new(),
347 data: None,
348 keyring_entries: None,
349 keyring_options: None,
350 keyring_delete_ids: Vec::new(),
351 with_unlock: false,
352 return_data: None,
353 create_if_missing: false,
354 backup: false,
355 }
356 }
357
358 #[test]
359 fn normalize_rejects_encryption_key_with_non_binary_provider() {
360 let mut payload = base_payload();
361 payload.provider = Some(ProviderPayload {
362 kind: ProviderKind::Json,
363 encryption_key: Some("key".to_string()),
364 kdf: None,
365 db_name: None,
366 table_name: None,
367 });
368
369 let err = payload.normalize().expect_err("expected invalid payload");
370 match err {
371 Error::InvalidPayload(msg) => {
372 assert_eq!(
373 msg,
374 "encryptionKey is only supported with provider.kind='binary'"
375 );
376 }
377 _ => panic!("unexpected error variant"),
378 }
379 }
380
381 #[test]
382 fn normalize_allows_encryption_key_with_binary_provider() {
383 let mut payload = base_payload();
384 payload.provider = Some(ProviderPayload {
385 kind: ProviderKind::Binary,
386 encryption_key: Some("my-key".to_string()),
387 kdf: None,
388 db_name: None,
389 table_name: None,
390 });
391
392 let normalized = payload.normalize().expect("expected valid payload");
393 match normalized.provider {
394 NormalizedProvider::Binary { encryption_key, kdf } => {
395 assert_eq!(encryption_key.as_deref(), Some("my-key"));
396 assert!(matches!(kdf, KeyDerivation::Sha256), "expected default kdf to be Sha256");
397 }
398 _ => panic!("unexpected provider variant"),
399 }
400 }
401
402 #[test]
403 fn normalize_rejects_keyring_delete_ids_without_keyring_options() {
404 let mut payload = base_payload();
405 payload.provider = Some(ProviderPayload {
406 kind: ProviderKind::Json,
407 encryption_key: None,
408 kdf: None,
409 db_name: None,
410 table_name: None,
411 });
412 payload.keyring_delete_ids = vec!["tok".to_string()];
413
414 let err = payload.normalize().expect_err("expected invalid payload");
415 match err {
416 Error::InvalidPayload(msg) => {
417 assert_eq!(msg, "keyringDeleteIds provided without keyringOptions");
418 }
419 _ => panic!("unexpected error variant"),
420 }
421 }
422}