Skip to main content

memfaultd/config/
config_file.rs

1//
2// Copyright (c) Memfault, Inc.
3// See License.txt for details
4use eyre::{eyre, Context};
5use log::warn;
6use serde::{Deserialize, Serialize};
7use std::net::IpAddr;
8use std::time::Duration;
9use std::{
10    collections::{HashMap, HashSet},
11    num::NonZeroU32,
12};
13use std::{net::SocketAddr, path::PathBuf};
14
15use crate::metrics::{system_metrics::SystemMetricConfig, MetricStringKey, SessionName};
16use crate::util::*;
17use crate::util::{path::AbsolutePath, serialization::*};
18use crate::{mar::CompressionAlgorithm, util::disk_size::DiskSize};
19
20#[derive(Serialize, Deserialize, Debug)]
21pub struct MemfaultdConfig {
22    pub persist_dir: AbsolutePath,
23    pub tmp_dir: Option<AbsolutePath>,
24    #[serde(rename = "tmp_dir_min_headroom_kib", with = "kib_to_usize")]
25    pub tmp_dir_min_headroom: usize,
26    pub tmp_dir_min_inodes: usize,
27    #[serde(rename = "tmp_dir_max_usage_kib", with = "kib_to_usize")]
28    pub tmp_dir_max_usage: usize,
29    #[serde(rename = "upload_interval_seconds", with = "seconds_to_duration")]
30    pub upload_interval: Duration,
31    #[serde(rename = "heartbeat_interval_seconds", with = "seconds_to_duration")]
32    pub heartbeat_interval: Duration,
33    pub enable_data_collection: bool,
34    pub enable_dev_mode: bool,
35    pub software_version: Option<String>,
36    pub software_type: Option<String>,
37    pub project_key: String,
38    pub base_url: String,
39    pub swupdate: SwUpdateConfig,
40    pub reboot: RebootConfig,
41    pub coredump: CoredumpConfig,
42    #[serde(rename = "fluent-bit")]
43    pub fluent_bit: FluentBitConfig,
44    pub logs: LogsConfig,
45    pub mar: MarConfig,
46    pub http_server: HttpServerConfig,
47    pub battery_monitor: Option<BatteryMonitorConfig>,
48    pub connectivity_monitor: Option<ConnectivityMonitorConfig>,
49    pub sessions: Option<Vec<SessionConfig>>,
50    pub metrics: MetricReportConfig,
51    pub custom_trace: Option<LinuxCustomTraceConfig>,
52    pub persist_storage: Option<PersistStorageConfig>,
53}
54
55#[derive(Serialize, Deserialize, Debug)]
56pub struct SwUpdateConfig {
57    pub input_file: PathBuf,
58    pub output_file: PathBuf,
59}
60
61#[derive(Serialize, Deserialize, Debug)]
62pub struct RebootConfig {
63    pub last_reboot_reason_file: PathBuf,
64    pub capture_pstore: bool,
65}
66
67#[derive(Serialize, Deserialize, Debug, Copy, Clone)]
68pub enum CoredumpCompression {
69    #[serde(rename = "gzip")]
70    Gzip,
71    #[serde(rename = "none")]
72    None,
73}
74
75#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
76#[serde(tag = "type")]
77pub enum CoredumpCaptureStrategy {
78    #[serde(rename = "threads")]
79    /// Only capture the stacks of the threads that were running at the time of the crash.
80    Threads {
81        #[serde(rename = "max_thread_size_kib", with = "kib_to_usize")]
82        max_thread_size: usize,
83    },
84    #[serde(rename = "kernel_selection")]
85    /// Keep in the coredump what the kernel selected to be included in the coredump.
86    /// See https://man7.org/linux/man-pages/man5/core.5.html for more details.
87    KernelSelection,
88    #[serde(rename = "stacktrace")]
89    /// Capture a stack trace only without locals or registers
90    Stacktrace,
91}
92
93#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
94pub struct LinuxCustomTraceConfig {
95    pub log_compression: LinuxCustomTraceLogCompression,
96    pub rate_limit_count: u32,
97    #[serde(rename = "rate_limit_duration_seconds", with = "seconds_to_duration")]
98    pub rate_limit_duration: Duration,
99}
100
101#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
102#[serde(tag = "type")]
103pub enum TraceFilter {
104    #[serde(rename = "executable_name")]
105    ExecutableName { name: String },
106    #[serde(rename = "executable_path")]
107    ExecutablePath { path: String },
108}
109
110impl Default for LinuxCustomTraceConfig {
111    fn default() -> Self {
112        Self {
113            log_compression: LinuxCustomTraceLogCompression::Gzip,
114            rate_limit_count: 5,
115            rate_limit_duration: Duration::from_secs(3600),
116        }
117    }
118}
119
120#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
121pub enum LinuxCustomTraceLogCompression {
122    #[serde(rename = "gzip")]
123    Gzip,
124    #[serde(rename = "zlib")]
125    Zlib,
126    #[serde(rename = "none")]
127    None,
128}
129
130impl From<LinuxCustomTraceLogCompression> for CompressionAlgorithm {
131    fn from(value: LinuxCustomTraceLogCompression) -> CompressionAlgorithm {
132        match value {
133            LinuxCustomTraceLogCompression::Gzip => CompressionAlgorithm::Gzip,
134            LinuxCustomTraceLogCompression::Zlib => CompressionAlgorithm::Zlib,
135            LinuxCustomTraceLogCompression::None => CompressionAlgorithm::None,
136        }
137    }
138}
139
140#[derive(Serialize, Deserialize, Debug, Clone)]
141pub struct CoredumpConfig {
142    pub compression: CoredumpCompression,
143    #[serde(rename = "coredump_max_size_kib", with = "kib_to_usize")]
144    pub coredump_max_size: usize,
145    pub rate_limit_count: u32,
146    #[serde(rename = "rate_limit_duration_seconds", with = "seconds_to_duration")]
147    pub rate_limit_duration: Duration,
148    pub capture_strategy: CoredumpCaptureStrategy,
149    pub log_lines: usize,
150    pub filters: Option<Vec<TraceFilter>>,
151}
152
153#[derive(Serialize, Deserialize, Debug)]
154pub struct FluentBitConfig {
155    pub extra_fluentd_attributes: Vec<String>,
156    pub bind_address: SocketAddr,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub max_buffered_lines: Option<usize>,
159    pub max_connections: usize,
160}
161
162#[derive(Serialize, Deserialize, Debug)]
163pub struct HttpServerConfig {
164    pub bind_address: SocketAddr,
165}
166
167#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
168pub struct SyslogConfig {
169    pub bind_address: SocketAddr,
170}
171
172#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
173pub enum LogSource {
174    #[serde(rename = "fluent-bit")]
175    FluentBit,
176    #[serde(rename = "journald")]
177    Journald,
178    #[serde(rename = "syslog")]
179    Syslog(SyslogConfig),
180}
181
182#[derive(Serialize, Deserialize, Debug)]
183pub struct LogsConfig {
184    #[serde(rename = "rotate_size_kib", with = "kib_to_usize")]
185    pub rotate_size: usize,
186    #[serde(rename = "rotate_after_seconds", with = "seconds_to_duration")]
187    pub rotate_after: Duration,
188    #[serde(with = "number_to_compression")]
189    pub compression_level: Compression,
190    pub max_lines_per_minute: NonZeroU32,
191    pub log_to_metrics: Option<LogToMetricsConfig>,
192    pub storage: StorageConfig,
193    pub source: LogSource,
194    pub level_mapping: LevelMappingConfig,
195    pub extra_attributes: Vec<String>,
196    pub max_buffered_lines: usize,
197    pub filtering: Option<LogFilterConfig>,
198}
199
200#[derive(Clone, Serialize, Deserialize, Debug)]
201pub struct LogFilterConfig {
202    pub default_action: LogRuleAction,
203    pub rules: Vec<LogFilterRule>,
204}
205
206impl Default for LogFilterConfig {
207    fn default() -> Self {
208        LogFilterConfig {
209            rules: vec![],
210            default_action: LogRuleAction::Include,
211        }
212    }
213}
214
215#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug, Eq)]
216#[serde(rename_all = "lowercase")]
217pub enum LogRuleAction {
218    Pass,
219    Include,
220    Exclude,
221}
222
223#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
224pub struct LogFilterRule {
225    pub service: Option<String>,
226    pub counter_name: Option<String>,
227    pub pattern: Option<String>,
228    pub level: Option<String>,
229    pub extra_fields: Option<HashMap<String, String>>,
230    pub action: Option<LogRuleAction>,
231}
232
233#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
234pub enum StorageConfig {
235    #[serde(rename = "disabled")]
236    Disabled,
237    #[serde(rename = "persist")]
238    Persist,
239}
240
241#[derive(Serialize, Deserialize, Debug)]
242pub struct LogToMetricsConfig {
243    pub rules: Vec<LogToMetricRule>,
244}
245
246#[derive(Serialize, Deserialize, Debug, Clone)]
247#[serde(tag = "type")]
248pub enum LogToMetricRule {
249    #[serde(rename = "count_matching")]
250    CountMatching {
251        /// Regex applied on the MESSAGE field
252        pattern: String,
253        metric_name: String,
254        /// List of key-value that must exactly match before the regexp is applied
255        #[serde(default)]
256        filter: HashMap<String, String>,
257    },
258}
259
260#[derive(Serialize, Deserialize, Debug)]
261pub struct MarConfig {
262    #[serde(rename = "mar_file_max_size_kib", with = "kib_to_usize")]
263    pub mar_file_max_size: usize,
264    #[serde(rename = "mar_entry_max_age_seconds", with = "seconds_to_duration")]
265    pub mar_entry_max_age: Duration,
266    pub mar_entry_max_count: usize,
267}
268
269#[derive(Serialize, Deserialize, Debug)]
270pub struct BatteryMonitorConfig {
271    pub battery_info_command: Option<String>,
272    pub auto: bool,
273    #[serde(with = "seconds_to_duration")]
274    pub interval_seconds: Duration,
275}
276
277#[derive(Serialize, Deserialize, Debug)]
278pub struct ConnectivityMonitorConfig {
279    #[serde(with = "seconds_to_duration")]
280    pub interval_seconds: Duration,
281    pub targets: Vec<ConnectivityMonitorTarget>,
282    #[serde(
283        with = "seconds_to_duration",
284        default = "default_connection_check_timeout"
285    )]
286    pub timeout_seconds: Duration,
287}
288fn default_connection_check_timeout() -> Duration {
289    Duration::from_secs(10)
290}
291
292#[derive(Serialize, Deserialize, Debug)]
293pub struct MetricReportConfig {
294    pub enable_daily_heartbeats: bool,
295    pub system_metric_collection: SystemMetricConfig,
296    pub statsd_server: Option<StatsDServerConfig>,
297    pub high_resolution_telemetry: HrtConfig,
298    #[serde(default)]
299    pub min_max_metrics: Vec<MetricStringKey>,
300}
301
302#[derive(Serialize, Deserialize, Debug)]
303pub struct HrtConfig {
304    pub enable: bool,
305    pub max_samples_per_minute: NonZeroU32,
306}
307
308#[derive(Serialize, Deserialize, Debug, Clone)]
309pub struct ConnectivityMonitorTarget {
310    #[serde(default = "default_connection_check_protocol")]
311    pub protocol: ConnectionCheckProtocol,
312    pub host: IpAddr,
313    pub port: u16,
314}
315
316#[derive(Serialize, Deserialize, Debug, Clone)]
317#[serde(rename_all = "lowercase")]
318pub enum ConnectionCheckProtocol {
319    Tcp,
320}
321
322fn default_connection_check_protocol() -> ConnectionCheckProtocol {
323    ConnectionCheckProtocol::Tcp
324}
325
326#[derive(Serialize, Clone, Deserialize, Debug)]
327pub struct SessionConfig {
328    pub name: SessionName,
329    pub captured_metrics: HashSet<MetricStringKey>,
330}
331
332#[derive(Serialize, Deserialize, Debug)]
333pub struct StatsDServerConfig {
334    pub bind_address: SocketAddr,
335    pub legacy_gauge_aggregation: Option<bool>,
336    pub legacy_key_names: Option<bool>,
337}
338
339#[derive(Clone, Serialize, Deserialize, Debug)]
340pub struct LevelMappingConfig {
341    pub enable: bool,
342    pub regex: Option<LevelMappingRegex>,
343}
344
345#[derive(Clone, Serialize, Deserialize, Debug)]
346pub struct LevelMappingRegex {
347    pub emergency: Option<String>,
348    pub alert: Option<String>,
349    pub critical: Option<String>,
350    pub error: Option<String>,
351    pub warning: Option<String>,
352    pub notice: Option<String>,
353    pub info: Option<String>,
354    pub debug: Option<String>,
355}
356
357#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
358pub struct PersistStorageConfig {
359    #[serde(rename = "min_headroom_kib", with = "kib_to_usize")]
360    pub min_headroom: usize,
361    #[serde(rename = "max_usage_kib", with = "kib_to_usize")]
362    pub max_usage: usize,
363    pub min_inodes: usize,
364    pub reboots: bool,
365    pub coredumps: bool,
366    pub metrics: bool,
367    pub logs: bool,
368}
369
370impl PersistStorageConfig {
371    pub fn max_total_size(&self) -> DiskSize {
372        DiskSize::new_capacity(self.max_usage as u64)
373    }
374
375    pub fn min_headroom(&self) -> DiskSize {
376        DiskSize {
377            bytes: self.min_headroom as u64,
378            inodes: self.min_inodes as u64,
379        }
380    }
381}
382
383use flate2::Compression;
384use serde_json::Value;
385use std::fs;
386use std::path::Path;
387
388use crate::config::utils::{
389    filter_path_is_valid, software_type_is_valid, software_version_is_valid,
390};
391
392pub struct JsonConfigs {
393    /// Built-in configuration and System configuration
394    pub base: Value,
395    /// Runtime configuration
396    pub runtime: Value,
397}
398
399impl MemfaultdConfig {
400    pub fn load(config_path: &Path) -> eyre::Result<MemfaultdConfig> {
401        let JsonConfigs {
402            base: mut config_json,
403            runtime,
404        } = Self::parse_configs(config_path)?;
405        Self::merge_into(&mut config_json, runtime);
406
407        // Transform the JSON object into a typed structure.
408        let config: MemfaultdConfig = serde_json::from_value(config_json)?;
409
410        let mut validation_errors = vec![];
411        if let Some(software_version) = &config.software_version {
412            if let Err(e) = software_version_is_valid(software_version) {
413                validation_errors.push(format!("  Invalid value for \"software_version\": {}", e));
414            }
415        }
416        if let Some(software_type) = &config.software_type {
417            if let Err(e) = software_type_is_valid(software_type) {
418                validation_errors.push(format!("  Invalid value for \"software_type\": {}", e));
419            }
420        }
421        if let Some(filters) = &config.coredump.filters {
422            filters.iter().for_each(|filter| {
423                if let TraceFilter::ExecutablePath { path } = filter {
424                    if let Err(e) = filter_path_is_valid(path) {
425                        warn!("Invalid coredump filter path: {}", e);
426                    }
427                }
428            });
429        }
430
431        match validation_errors.is_empty() {
432            true => Ok(config),
433            false => Err(eyre::eyre!("\n{}", validation_errors.join("\n"))),
434        }
435    }
436
437    /// Parse config file from given path and returns (builtin+system config, runtime config).
438    pub fn parse_configs(config_path: &Path) -> eyre::Result<JsonConfigs> {
439        // Initialize with the builtin config file.
440        let mut base: Value = Self::parse(include_str!("../../builtin.conf"))
441            .wrap_err("Error parsing built-in configuration file")?;
442
443        // Read and parse the user config file.
444        let user_config = Self::parse(std::fs::read_to_string(config_path)?.as_str())
445            .wrap_err(eyre!("Error reading {}", config_path.display()))?;
446
447        // Merge the two JSON objects together
448        Self::merge_into(&mut base, user_config);
449
450        // Load the runtime config but only if the file exists. (Missing runtime config is not an error.)
451        let runtime_config_path = Self::runtime_config_path_from_json(&base)?;
452        let runtime = if runtime_config_path.exists() {
453            Self::parse(fs::read_to_string(&runtime_config_path)?.as_str()).wrap_err(eyre!(
454                "Error reading runtime configuration {}",
455                runtime_config_path.display()
456            ))?
457        } else {
458            Value::Object(serde_json::Map::new())
459        };
460
461        Ok(JsonConfigs { base, runtime })
462    }
463
464    /// Set and write boolean in runtime config.
465    pub fn set_and_write_bool_to_runtime_config(&self, key: &str, value: bool) -> eyre::Result<()> {
466        let config_string = match fs::read_to_string(self.runtime_config_path()) {
467            Ok(config_string) => config_string,
468            Err(e) => {
469                if e.kind() == std::io::ErrorKind::NotFound {
470                    "{}".to_string()
471                } else {
472                    return Err(eyre::eyre!("Failed to read runtime config: {}", e));
473                }
474            }
475        };
476
477        let mut config_val = Self::parse(&config_string)?;
478        config_val[key] = Value::Bool(value);
479
480        self.write_value_to_runtime_config(config_val)
481    }
482
483    /// Write config to runtime config file.
484    ///
485    /// This is used to write the config to a file that can be read by the memfaultd process.
486    fn write_value_to_runtime_config(&self, value: Value) -> eyre::Result<()> {
487        let runtime_config_path = self.runtime_config_path();
488        fs::write(runtime_config_path, value.to_string())?;
489
490        Ok(())
491    }
492
493    pub fn runtime_config_path(&self) -> PathBuf {
494        PathBuf::from(self.persist_dir.clone()).join("runtime.conf")
495    }
496
497    // Parse a Memfaultd JSON configuration file (with optional C-style comments) and return a serde_json::Value object.
498    fn parse(config_string: &str) -> eyre::Result<Value> {
499        let json_text = string::remove_comments(config_string);
500        let json: Value = serde_json::from_str(json_text.as_str())?;
501        if !json.is_object() {
502            return Err(eyre::eyre!("Configuration should be a JSON object."));
503        }
504        Ok(json)
505    }
506
507    /// Merge two JSON objects together. The values from the second one will override values in the first one.
508    fn merge_into(dest: &mut Value, src: Value) {
509        assert!(dest.is_object());
510        if let Value::Object(src_map) = src {
511            for (key, value) in src_map {
512                if let Some(obj) = dest.get_mut(&key) {
513                    if obj.is_object() {
514                        MemfaultdConfig::merge_into(obj, value);
515                        continue;
516                    }
517                }
518                dest[&key] = value;
519            }
520        } else if let Value::Null = src {
521            *dest = Value::Null
522        }
523    }
524
525    pub fn generate_tmp_filename(&self, filename: &str) -> PathBuf {
526        // Fall back to persist dir if tmp_dir is not set.
527        let tmp_dir = self.tmp_dir.as_ref().unwrap_or(&self.persist_dir);
528        PathBuf::from(tmp_dir.clone()).join(filename)
529    }
530
531    pub fn generate_persist_filename(&self, filename: &str) -> PathBuf {
532        PathBuf::from(self.persist_dir.clone()).join(filename)
533    }
534
535    /// Generate the path to the runtime config file from a serde_json::Value object. This should include the "persist_dir" field.
536    fn runtime_config_path_from_json(config: &Value) -> eyre::Result<PathBuf> {
537        let mut persist_dir = PathBuf::from(
538            config["persist_dir"]
539                .as_str()
540                .ok_or(eyre::eyre!("Config['persist_dir'] must be a string."))?,
541        );
542        persist_dir.push("runtime.conf");
543        Ok(persist_dir)
544    }
545}
546
547#[cfg(test)]
548impl MemfaultdConfig {
549    pub fn test_fixture() -> Self {
550        use std::fs::write;
551        use tempfile::tempdir;
552
553        let tmp = tempdir().unwrap();
554        let config_path = tmp.path().join("memfaultd.conf");
555        write(&config_path, "{}").unwrap();
556        MemfaultdConfig::load(&config_path).unwrap()
557    }
558}
559
560#[cfg(test)]
561mod test {
562    use insta::{assert_json_snapshot, with_settings};
563    use rstest::rstest;
564
565    use super::*;
566
567    use crate::test_utils::set_snapshot_suffix;
568
569    #[test]
570    fn test_merge() {
571        let mut c =
572            serde_json::from_str(r#"{ "node": { "value": true, "valueB": false } }"#).unwrap();
573        let j = serde_json::from_str(r#"{ "node2": "xxx" }"#).unwrap();
574
575        MemfaultdConfig::merge_into(&mut c, j);
576
577        assert_eq!(
578            serde_json::to_string(&c).unwrap(),
579            r#"{"node":{"value":true,"valueB":false},"node2":"xxx"}"#
580        );
581    }
582
583    #[test]
584    fn test_merge_overwrite() {
585        let mut c =
586            serde_json::from_str(r#"{ "node": { "value": true, "valueB": false } }"#).unwrap();
587        let j = serde_json::from_str(r#"{ "node": { "value": false }}"#).unwrap();
588
589        MemfaultdConfig::merge_into(&mut c, j);
590
591        assert_eq!(
592            serde_json::to_string(&c).unwrap(),
593            r#"{"node":{"value":false,"valueB":false}}"#
594        );
595    }
596
597    #[test]
598    fn test_merge_overwrite_nested() {
599        let mut c = serde_json::from_str(
600            r#"{ "node": { "value": true, "valueB": false, "valueC": { "a": 1, "b": 2 } } }"#,
601        )
602        .unwrap();
603        let j = serde_json::from_str(r#"{ "node": { "valueC": { "b": 42 } }}"#).unwrap();
604
605        MemfaultdConfig::merge_into(&mut c, j);
606
607        assert_eq!(
608            serde_json::to_string(&c).unwrap(),
609            r#"{"node":{"value":true,"valueB":false,"valueC":{"a":1,"b":42}}}"#
610        );
611    }
612
613    #[test]
614    fn test_merge_overwrite_with_null() {
615        let mut c = serde_json::from_str(
616            r#"{ "node": { "value": true, "valueB": false, "valueC": { "a": 1, "b": 2 } } }"#,
617        )
618        .unwrap();
619        let j = serde_json::from_str(r#"{ "node": { "valueC": null }}"#).unwrap();
620
621        MemfaultdConfig::merge_into(&mut c, j);
622
623        assert_eq!(
624            serde_json::to_string(&c).unwrap(),
625            r#"{"node":{"value":true,"valueB":false,"valueC":null}}"#
626        );
627    }
628
629    #[rstest]
630    #[case("empty_object")]
631    #[case("with_partial_logs")]
632    #[case("without_coredump_compression")]
633    #[case("with_coredump_capture_strategy_threads")]
634    #[case("with_log_to_metrics_rules")]
635    #[case("with_connectivity_monitor")]
636    #[case("with_sessions")]
637    #[case("metrics_config")]
638    #[case("log_filters")]
639    #[case("syslog")]
640    #[case("with_linux_custom_trace_gzip")]
641    #[case("with_linux_custom_trace_zlib")]
642    #[case("with_linux_custom_trace_none")]
643    #[case("with_persist_storage_config")]
644    #[case("with_battery_monitor")]
645    #[case("with_min_max_metrics")]
646    fn can_parse_test_files(#[case] name: &str) {
647        let input_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
648            .join("src/config/test-config")
649            .join(name)
650            .with_extension("json");
651        // Verifies that the file is parsable
652        let content = MemfaultdConfig::load(&input_path).unwrap();
653        // And that the configuration generated is what we expect.
654        // Use `cargo insta review` to quickly approve changes.
655        with_settings!({sort_maps => true}, {
656            assert_json_snapshot!(name, content);
657        });
658    }
659
660    #[rstest]
661    #[case("with_invalid_path")]
662    #[case("with_invalid_swt_swv")]
663    #[case("with_sessions_invalid_metric_name")]
664    #[case("with_sessions_invalid_session_name")]
665    fn will_reject_bad_config(#[case] name: &str) {
666        let input_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
667            .join("src/config/test-config")
668            .join(name)
669            .with_extension("json");
670        let result = MemfaultdConfig::load(&input_path);
671        assert!(result.is_err());
672    }
673
674    #[rstest]
675    #[case("no_file", None)]
676    #[case("empty_object", Some("{}"))]
677    #[case("other_key", Some(r#"{"key2":false}"#))]
678    fn test_set_and_write_bool_to_runtime_config(
679        #[case] test_name: &str,
680        #[case] config_string: Option<&str>,
681    ) {
682        let mut config = MemfaultdConfig::test_fixture();
683        let temp_data_dir = tempfile::tempdir().unwrap();
684        config.persist_dir = AbsolutePath::try_from(temp_data_dir.path().to_path_buf()).unwrap();
685
686        if let Some(config_string) = config_string {
687            std::fs::write(config.runtime_config_path(), config_string).unwrap();
688        }
689
690        config
691            .set_and_write_bool_to_runtime_config("key", true)
692            .unwrap();
693
694        let disk_config_string = std::fs::read_to_string(config.runtime_config_path()).unwrap();
695
696        set_snapshot_suffix!("{}", test_name);
697        insta::assert_json_snapshot!(disk_config_string);
698    }
699}