Skip to main content

tauri_plugin_configurate/
models.rs

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/// Supported provider kinds for the normalized runtime model.
11#[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/// Provider payload sent from the guest side.
22#[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/// Optional path options sent from the guest side.
33#[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/// A single keyring entry containing the keyring id and its plaintext value.
41#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct KeyringEntry {
44    /// Unique keyring id as declared in the TS schema via `keyring(T, { id })`.
45    pub id: String,
46    /// Dot-separated path to this field inside the config object (e.g. `"database.password"`).
47    pub dotpath: String,
48    /// Plaintext value to store in the OS keyring.
49    pub value: String,
50    /// When true, a "not found" keyring error on read is treated as absent (null) rather than an error.
51    #[serde(default)]
52    pub is_optional: bool,
53}
54
55/// Options required to access the OS keyring.
56/// The final key stored in the OS keyring uses:
57/// - service = `{service}`
58/// - user    = `{account}/{id}`
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct KeyringOptions {
61    /// The keyring service name (e.g. your app name).
62    pub service: String,
63    /// The keyring account name (e.g. "default").
64    pub account: String,
65}
66
67/// Value type inferred from `defineConfig` for SQLite column materialization.
68#[derive(Debug, Clone, Deserialize, Serialize)]
69#[serde(rename_all = "lowercase")]
70pub enum SqliteValueType {
71    String,
72    Number,
73    Boolean,
74}
75
76/// Flattened column definition for SQLite persistence.
77#[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/// Unified payload sent from TypeScript side for create/load/save/delete.
88#[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    // Common fields
99    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    /// Whether create/save should return the resulting config data.
107    /// Defaults to true for backward compatibility.
108    pub return_data: Option<bool>,
109    /// When true, `patch` creates the config with the patch data if it does
110    /// not yet exist instead of returning an error.
111    #[serde(default)]
112    pub create_if_missing: bool,
113    /// When true, rolling backup files are created before each write.
114    /// Defaults to false (opt-in).
115    #[serde(default)]
116    pub backup: bool,
117}
118
119/// Key derivation function used by the Binary provider.
120#[derive(Debug, Clone, Deserialize, Serialize)]
121#[serde(rename_all = "lowercase")]
122pub enum KeyDerivation {
123    Sha256,
124    Argon2,
125}
126
127/// Normalized provider used internally after payload normalization.
128///
129/// Each variant carries only the fields that are meaningful for that provider,
130/// eliminating the spurious `db_name`/`table_name` on non-SQLite providers and
131/// the spurious `encryption_key` on non-Binary providers.
132#[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
144/// Custom Debug impl that redacts the `encryption_key` so it is never
145/// accidentally printed in log output, panic messages, or test failures.
146impl 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/// Normalized payload used internally across all commands.
170#[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    /// When true, `patch` creates the config if it does not exist.
185    pub create_if_missing: bool,
186    /// When true, rolling backup files are created before each write.
187    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        // Validate dotpaths early so callers get a clear error referencing the
252        // offending column rather than a cryptic dotpath error at write time.
253        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/// Payload for the `unlock` command.
282#[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
290/// Payload for the `patch` command (reuses `ConfiguratePayload`).
291pub type PatchPayload = ConfiguratePayload;
292
293/// Single entry used by `load_all` and `save_all`.
294#[derive(Debug, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct BatchEntryPayload {
297    pub id: String,
298    pub payload: ConfiguratePayload,
299}
300
301/// Batch payload used by `load_all` and `save_all`.
302#[derive(Debug, Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub struct BatchPayload {
305    pub entries: Vec<BatchEntryPayload>,
306}
307
308/// Per-entry successful result.
309#[derive(Debug, Serialize)]
310pub struct BatchEntrySuccess {
311    pub ok: bool,
312    pub data: serde_json::Value,
313}
314
315/// Per-entry failed result.
316#[derive(Debug, Serialize)]
317pub struct BatchEntryFailure {
318    pub ok: bool,
319    pub error: serde_json::Value,
320}
321
322/// Per-entry result envelope.
323#[derive(Debug, Serialize)]
324#[serde(untagged)]
325pub enum BatchEntryResult {
326    Success(BatchEntrySuccess),
327    Failure(BatchEntryFailure),
328}
329
330/// Top-level batch response.
331#[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}