Skip to main content

hardware_enclave/internal/core/
config_block.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! Generic managed config block injection and removal.
5//!
6//! Many enclave apps inject managed blocks into config files (SSH config,
7//! AWS config, shell rc files). This module provides the shared logic for
8//! finding, inserting, replacing, and removing comment-delimited blocks.
9//!
10//! # Marker Format
11//!
12//! Blocks are delimited by comment markers:
13//! ```text
14//! # BEGIN app-name managed block -- do not edit
15//! ... managed content ...
16//! # END app-name managed block
17//! ```
18//!
19//! An optional sub-identifier (e.g., profile name) can be included:
20//! ```text
21//! # --- BEGIN awsenc managed (production) ---
22//! ... content ...
23//! # --- END awsenc managed (production) ---
24//! ```
25#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
26
27use std::path::Path;
28
29/// Configuration for a managed block's markers.
30#[derive(Debug, Clone)]
31pub struct BlockMarkers {
32    /// The begin marker line (without trailing newline).
33    pub begin: String,
34    /// The end marker line (without trailing newline).
35    pub end: String,
36}
37
38impl BlockMarkers {
39    /// Create markers using the standard format: `# BEGIN {app} managed block -- do not edit`.
40    pub fn standard(app_name: &str) -> Self {
41        Self {
42            begin: format!("# BEGIN {app_name} managed block -- do not edit"),
43            end: format!("# END {app_name} managed block"),
44        }
45    }
46
47    /// Create markers with an optional sub-identifier.
48    ///
49    /// Format: `# --- BEGIN {app} managed ({id}) ---`
50    pub fn with_id(app_name: &str, id: &str) -> Self {
51        Self {
52            begin: format!("# --- BEGIN {app_name} managed ({id}) ---"),
53            end: format!("# --- END {app_name} managed ({id}) ---"),
54        }
55    }
56
57    /// Create markers with fully custom begin/end strings.
58    pub fn custom(begin: impl Into<String>, end: impl Into<String>) -> Self {
59        Self {
60            begin: begin.into(),
61            end: end.into(),
62        }
63    }
64}
65
66/// Result of an install/upsert operation.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum BlockInstallResult {
69    /// Block was newly appended.
70    Installed,
71    /// An existing block was replaced.
72    Replaced,
73    /// Block was already present with identical content.
74    AlreadyPresent,
75}
76
77/// Result of a removal operation.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum BlockRemoveResult {
80    /// Block was found and removed.
81    Removed,
82    /// Block was not present.
83    NotPresent,
84}
85
86/// Find the byte range of a managed block in the content.
87///
88/// Returns `Some((start, end))` where the range includes the begin marker,
89/// all content, and the end marker (including its trailing newline if present).
90pub fn find_block(content: &str, markers: &BlockMarkers) -> Option<(usize, usize)> {
91    let begin_idx = content.find(&markers.begin)?;
92    let after_begin = begin_idx + markers.begin.len();
93    let end_idx = content[after_begin..].find(&markers.end)?;
94    let absolute_end = after_begin + end_idx + markers.end.len();
95    // Include trailing newline if present.
96    let end_with_newline = if content[absolute_end..].starts_with('\n') {
97        absolute_end + 1
98    } else {
99        absolute_end
100    };
101    Some((begin_idx, end_with_newline))
102}
103
104/// Check whether a managed block is present.
105pub fn has_block(content: &str, markers: &BlockMarkers) -> bool {
106    find_block(content, markers).is_some()
107}
108
109/// Build a complete block string from markers and body content.
110///
111/// The body should NOT include the markers — they are added automatically.
112/// A trailing newline is ensured on the body.
113pub fn build_block(markers: &BlockMarkers, body: &str) -> String {
114    let mut block = String::new();
115    block.push_str(&markers.begin);
116    block.push('\n');
117    block.push_str(body);
118    if !body.ends_with('\n') {
119        block.push('\n');
120    }
121    block.push_str(&markers.end);
122    block
123}
124
125/// Insert or replace a managed block in the content.
126///
127/// If the block already exists, it is replaced. Otherwise, it is appended
128/// with a blank separator line.
129pub fn upsert_block(content: &str, markers: &BlockMarkers, block: &str) -> String {
130    if let Some((start, end)) = find_block(content, markers) {
131        // Replace existing block.
132        let mut result = String::with_capacity(content.len());
133        result.push_str(&content[..start]);
134        result.push_str(block);
135        if !block.ends_with('\n') {
136            result.push('\n');
137        }
138        result.push_str(&content[end..]);
139        result
140    } else {
141        // Append with blank separator.
142        let mut result = content.to_string();
143        if !result.is_empty() && !result.ends_with('\n') {
144            result.push('\n');
145        }
146        if !result.is_empty() && !result.ends_with("\n\n") {
147            result.push('\n');
148        }
149        result.push_str(block);
150        if !block.ends_with('\n') {
151            result.push('\n');
152        }
153        result
154    }
155}
156
157/// Remove a managed block from the content.
158///
159/// Returns the content with the block removed and excessive blank lines
160/// cleaned up. Returns unchanged content if the block is not found.
161pub fn remove_block(content: &str, markers: &BlockMarkers) -> (String, BlockRemoveResult) {
162    let Some((start, end)) = find_block(content, markers) else {
163        return (content.to_string(), BlockRemoveResult::NotPresent);
164    };
165
166    let mut result = String::with_capacity(content.len());
167    result.push_str(&content[..start]);
168    result.push_str(&content[end..]);
169
170    // Clean up double blank lines left by removal.
171    while result.contains("\n\n\n") {
172        result = result.replace("\n\n\n", "\n\n");
173    }
174
175    // Trim trailing whitespace but keep one final newline.
176    let trimmed = result.trim_end();
177    let mut final_result = trimmed.to_string();
178    if !final_result.is_empty() {
179        final_result.push('\n');
180    }
181
182    (final_result, BlockRemoveResult::Removed)
183}
184
185/// Read a file, normalize CRLF to LF, and return the content.
186///
187/// Returns `Ok(None)` if the file does not exist.
188pub fn read_config_file(path: &Path) -> std::io::Result<Option<String>> {
189    match std::fs::read_to_string(path) {
190        Ok(content) => Ok(Some(content.replace("\r\n", "\n"))),
191        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
192        Err(e) => Err(e),
193    }
194}
195
196/// Write a config file, creating parent directories if needed.
197pub fn write_config_file(path: &Path, content: &str) -> std::io::Result<()> {
198    if let Some(parent) = path.parent() {
199        std::fs::create_dir_all(parent)?;
200    }
201    std::fs::write(path, content)?;
202    #[cfg(unix)]
203    {
204        use std::os::unix::fs::PermissionsExt;
205        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
206    }
207    Ok(())
208}
209
210/// Convenience: install or replace a managed block in a config file.
211///
212/// Reads the file (creating it if missing), upserts the block, and writes back.
213pub fn install_block_in_file(
214    path: &Path,
215    markers: &BlockMarkers,
216    body: &str,
217) -> std::io::Result<BlockInstallResult> {
218    let content = read_config_file(path)?.unwrap_or_default();
219    let block = build_block(markers, body);
220
221    if let Some((start, end)) = find_block(&content, markers) {
222        let existing = &content[start..end];
223        let new_with_nl = if block.ends_with('\n') {
224            block.clone()
225        } else {
226            format!("{block}\n")
227        };
228        if existing == new_with_nl {
229            return Ok(BlockInstallResult::AlreadyPresent);
230        }
231    }
232
233    let result = upsert_block(&content, markers, &block);
234    write_config_file(path, &result)?;
235
236    if has_block(&content, markers) {
237        Ok(BlockInstallResult::Replaced)
238    } else {
239        Ok(BlockInstallResult::Installed)
240    }
241}
242
243/// Convenience: remove a managed block from a config file.
244///
245/// Returns `NotPresent` if the file doesn't exist or doesn't contain the block.
246pub fn remove_block_from_file(
247    path: &Path,
248    markers: &BlockMarkers,
249) -> std::io::Result<BlockRemoveResult> {
250    let Some(content) = read_config_file(path)? else {
251        return Ok(BlockRemoveResult::NotPresent);
252    };
253    let (result, status) = remove_block(&content, markers);
254    if status == BlockRemoveResult::Removed {
255        write_config_file(path, &result)?;
256    }
257    Ok(status)
258}
259
260#[cfg(test)]
261#[allow(clippy::unwrap_used, clippy::panic)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn standard_markers() {
267        let m = BlockMarkers::standard("sshenc");
268        assert_eq!(m.begin, "# BEGIN sshenc managed block -- do not edit");
269        assert_eq!(m.end, "# END sshenc managed block");
270    }
271
272    #[test]
273    fn markers_with_id() {
274        let m = BlockMarkers::with_id("awsenc", "production");
275        assert_eq!(m.begin, "# --- BEGIN awsenc managed (production) ---");
276        assert_eq!(m.end, "# --- END awsenc managed (production) ---");
277    }
278
279    #[test]
280    fn build_block_adds_markers() {
281        let m = BlockMarkers::standard("test");
282        let block = build_block(&m, "key = value\n");
283        assert_eq!(
284            block,
285            "# BEGIN test managed block -- do not edit\nkey = value\n# END test managed block"
286        );
287    }
288
289    #[test]
290    fn build_block_ensures_trailing_newline_on_body() {
291        let m = BlockMarkers::standard("test");
292        let block = build_block(&m, "key = value");
293        assert!(block.contains("key = value\n# END"));
294    }
295
296    #[test]
297    fn find_block_locates_markers() {
298        let m = BlockMarkers::standard("app");
299        let content = "before\n# BEGIN app managed block -- do not edit\nstuff\n# END app managed block\nafter\n";
300        let (start, end) = find_block(content, &m).unwrap();
301        assert_eq!(
302            &content[start..end],
303            "# BEGIN app managed block -- do not edit\nstuff\n# END app managed block\n"
304        );
305    }
306
307    #[test]
308    fn find_block_returns_none_when_missing() {
309        let m = BlockMarkers::standard("app");
310        assert!(find_block("no markers here", &m).is_none());
311    }
312
313    #[test]
314    fn find_block_returns_none_for_begin_without_end() {
315        let m = BlockMarkers::standard("app");
316        let content = "# BEGIN app managed block -- do not edit\nstuff\n";
317        assert!(find_block(content, &m).is_none());
318    }
319
320    #[test]
321    fn upsert_appends_to_empty() {
322        let m = BlockMarkers::standard("app");
323        let block = build_block(&m, "content\n");
324        let result = upsert_block("", &m, &block);
325        assert_eq!(result, format!("{block}\n"));
326    }
327
328    #[test]
329    fn upsert_appends_with_separator() {
330        let m = BlockMarkers::standard("app");
331        let block = build_block(&m, "content\n");
332        let result = upsert_block("existing\n", &m, &block);
333        assert!(result.starts_with("existing\n\n"));
334        assert!(result.contains("content\n"));
335    }
336
337    #[test]
338    fn upsert_replaces_existing() {
339        let m = BlockMarkers::standard("app");
340        let old = "before\n# BEGIN app managed block -- do not edit\nold\n# END app managed block\nafter\n";
341        let new_block = build_block(&m, "new content\n");
342        let result = upsert_block(old, &m, &new_block);
343        assert!(result.contains("new content"));
344        assert!(!result.contains("old"));
345        assert!(result.contains("before\n"));
346        assert!(result.contains("after\n"));
347    }
348
349    #[test]
350    fn remove_block_removes_and_cleans() {
351        let m = BlockMarkers::standard("app");
352        let content = "before\n\n# BEGIN app managed block -- do not edit\nstuff\n# END app managed block\n\nafter\n";
353        let (result, status) = remove_block(content, &m);
354        assert_eq!(status, BlockRemoveResult::Removed);
355        assert!(!result.contains("stuff"));
356        assert!(result.contains("before"));
357        assert!(result.contains("after"));
358        assert!(!result.contains("\n\n\n"));
359    }
360
361    #[test]
362    fn remove_block_not_present() {
363        let m = BlockMarkers::standard("app");
364        let (result, status) = remove_block("no block\n", &m);
365        assert_eq!(status, BlockRemoveResult::NotPresent);
366        assert_eq!(result, "no block\n");
367    }
368
369    #[test]
370    fn has_block_true_when_present() {
371        let m = BlockMarkers::standard("app");
372        let content = "# BEGIN app managed block -- do not edit\nx\n# END app managed block\n";
373        assert!(has_block(content, &m));
374    }
375
376    #[test]
377    fn has_block_false_when_absent() {
378        let m = BlockMarkers::standard("app");
379        assert!(!has_block("nothing here", &m));
380    }
381
382    #[test]
383    fn multiple_blocks_with_different_ids() {
384        let m1 = BlockMarkers::with_id("awsenc", "dev");
385        let m2 = BlockMarkers::with_id("awsenc", "prod");
386
387        let mut content = String::new();
388        let b1 = build_block(&m1, "dev config\n");
389        content = upsert_block(&content, &m1, &b1);
390        let b2 = build_block(&m2, "prod config\n");
391        content = upsert_block(&content, &m2, &b2);
392
393        assert!(has_block(&content, &m1));
394        assert!(has_block(&content, &m2));
395
396        let (content, _) = remove_block(&content, &m1);
397        assert!(!has_block(&content, &m1));
398        assert!(has_block(&content, &m2));
399    }
400
401    #[test]
402    fn upsert_preserves_content_around_block() {
403        let m = BlockMarkers::standard("app");
404        let existing = "[section1]\nkey1 = val1\n\n# BEGIN app managed block -- do not edit\nold\n# END app managed block\n\n[section2]\nkey2 = val2\n";
405        let new_block = build_block(&m, "new\n");
406        let result = upsert_block(existing, &m, &new_block);
407        assert!(result.contains("[section1]\nkey1 = val1"));
408        assert!(result.contains("[section2]\nkey2 = val2"));
409        assert!(result.contains("new\n"));
410        assert!(!result.contains("old"));
411    }
412
413    #[test]
414    fn read_config_file_normalizes_crlf() {
415        let dir = std::env::temp_dir().join(format!(
416            "enclaveapp-config-block-test-{}",
417            std::process::id()
418        ));
419        std::fs::create_dir_all(&dir).unwrap();
420        let path = dir.join("test.conf");
421        std::fs::write(&path, "line1\r\nline2\r\n").unwrap();
422        let content = read_config_file(&path).unwrap().unwrap();
423        assert_eq!(content, "line1\nline2\n");
424        std::fs::remove_dir_all(&dir).unwrap();
425    }
426
427    #[test]
428    fn read_config_file_returns_none_for_missing() {
429        let path = std::path::PathBuf::from("/nonexistent/path/to/file");
430        assert!(read_config_file(&path).unwrap().is_none());
431    }
432
433    #[test]
434    fn install_and_remove_file_round_trip() {
435        let dir = std::env::temp_dir().join(format!(
436            "enclaveapp-config-block-file-test-{}",
437            std::process::id()
438        ));
439        std::fs::create_dir_all(&dir).unwrap();
440        let path = dir.join("config");
441        std::fs::write(&path, "[existing]\nkey = value\n").unwrap();
442
443        let m = BlockMarkers::standard("test-app");
444        let result = install_block_in_file(&path, &m, "managed = true\n").unwrap();
445        assert_eq!(result, BlockInstallResult::Installed);
446
447        let content = std::fs::read_to_string(&path).unwrap();
448        assert!(content.contains("[existing]"));
449        assert!(content.contains("managed = true"));
450
451        // Install again with same content → AlreadyPresent
452        let result = install_block_in_file(&path, &m, "managed = true\n").unwrap();
453        assert_eq!(result, BlockInstallResult::AlreadyPresent);
454
455        // Install with different content → Replaced
456        let result = install_block_in_file(&path, &m, "managed = updated\n").unwrap();
457        assert_eq!(result, BlockInstallResult::Replaced);
458
459        let result = remove_block_from_file(&path, &m).unwrap();
460        assert_eq!(result, BlockRemoveResult::Removed);
461
462        let content = std::fs::read_to_string(&path).unwrap();
463        assert!(content.contains("[existing]"));
464        assert!(!content.contains("managed"));
465
466        std::fs::remove_dir_all(&dir).unwrap();
467    }
468
469    #[test]
470    fn install_block_creates_file_if_missing() {
471        let dir = std::env::temp_dir().join(format!(
472            "enclaveapp-config-block-create-test-{}",
473            std::process::id()
474        ));
475        drop(std::fs::remove_dir_all(&dir));
476        let path = dir.join("subdir").join("new-config");
477
478        let m = BlockMarkers::standard("test");
479        let result = install_block_in_file(&path, &m, "content\n").unwrap();
480        assert_eq!(result, BlockInstallResult::Installed);
481        assert!(path.exists());
482
483        std::fs::remove_dir_all(&dir).unwrap();
484    }
485
486    #[test]
487    fn custom_markers_exact_strings() {
488        let m = BlockMarkers::custom("##START", "##END");
489        assert_eq!(m.begin, "##START");
490        assert_eq!(m.end, "##END");
491    }
492
493    #[test]
494    fn custom_markers_used_in_find_block() {
495        let m = BlockMarkers::custom("// MANAGED START", "// MANAGED END");
496        let content = "code before\n// MANAGED START\nmanaged code\n// MANAGED END\ncode after\n";
497        assert!(find_block(content, &m).is_some());
498    }
499
500    #[test]
501    fn custom_markers_used_in_build_and_upsert() {
502        let m = BlockMarkers::custom("/* managed-start */", "/* managed-end */");
503        let block = build_block(&m, "content = 42;\n");
504        assert!(block.starts_with("/* managed-start */"));
505        assert!(block.ends_with("/* managed-end */"));
506        let result = upsert_block("", &m, &block);
507        assert!(has_block(&result, &m));
508    }
509
510    #[test]
511    fn build_block_body_already_has_trailing_newline() {
512        let m = BlockMarkers::standard("app");
513        let block = build_block(&m, "body line\n");
514        // Should not double the newline
515        assert!(!block.contains("\n\n# END"));
516        assert!(block.contains("body line\n# END"));
517    }
518
519    #[test]
520    fn build_block_empty_body() {
521        let m = BlockMarkers::standard("app");
522        let block = build_block(&m, "");
523        // Empty body still gets a newline before the end marker
524        assert!(block.contains("# BEGIN app managed block -- do not edit\n\n# END"));
525    }
526
527    #[test]
528    fn find_block_no_trailing_newline_at_end_of_string() {
529        let m = BlockMarkers::standard("app");
530        // End marker is at the very end of content with no trailing newline.
531        let content = "# BEGIN app managed block -- do not edit\nstuff\n# END app managed block";
532        let result = find_block(content, &m);
533        assert!(result.is_some());
534        let (start, end) = result.unwrap();
535        // No newline to consume
536        assert_eq!(end, content.len());
537        assert_eq!(
538            &content[start..end],
539            "# BEGIN app managed block -- do not edit\nstuff\n# END app managed block"
540        );
541    }
542
543    #[test]
544    fn remove_block_at_start_of_content() {
545        let m = BlockMarkers::standard("app");
546        let content = "# BEGIN app managed block -- do not edit\nmanaged\n# END app managed block\n\nafter content\n";
547        let (result, status) = remove_block(content, &m);
548        assert_eq!(status, BlockRemoveResult::Removed);
549        assert!(!result.contains("managed"));
550        assert!(result.contains("after content"));
551    }
552
553    #[test]
554    fn remove_block_at_end_of_content() {
555        let m = BlockMarkers::standard("app");
556        let content = "before content\n\n# BEGIN app managed block -- do not edit\nmanaged\n# END app managed block\n";
557        let (result, status) = remove_block(content, &m);
558        assert_eq!(status, BlockRemoveResult::Removed);
559        assert!(!result.contains("managed"));
560        assert!(result.contains("before content"));
561    }
562
563    #[test]
564    fn remove_block_leaves_empty_string_when_only_content() {
565        let m = BlockMarkers::standard("app");
566        let content = "# BEGIN app managed block -- do not edit\nonly\n# END app managed block\n";
567        let (result, status) = remove_block(content, &m);
568        assert_eq!(status, BlockRemoveResult::Removed);
569        assert!(result.is_empty());
570    }
571
572    #[test]
573    fn upsert_content_already_ending_with_double_newline() {
574        let m = BlockMarkers::standard("app");
575        let block = build_block(&m, "new\n");
576        // Content already ends with two newlines → should not add a third
577        let result = upsert_block("existing\n\n", &m, &block);
578        assert!(!result.contains("\n\n\n"));
579    }
580
581    #[test]
582    fn remove_block_from_file_missing_file_is_not_present() {
583        let path = Path::new("/nonexistent/absolutely/missing.conf");
584        let m = BlockMarkers::standard("app");
585        let result = remove_block_from_file(path, &m).unwrap();
586        assert_eq!(result, BlockRemoveResult::NotPresent);
587    }
588
589    #[test]
590    fn read_config_file_empty_file_returns_some_empty_string() {
591        let dir = std::env::temp_dir().join(format!(
592            "enclaveapp-config-block-empty-test-{}",
593            std::process::id()
594        ));
595        std::fs::create_dir_all(&dir).unwrap();
596        let path = dir.join("empty.conf");
597        std::fs::write(&path, "").unwrap();
598        let content = read_config_file(&path).unwrap();
599        assert_eq!(content, Some(String::new()));
600        std::fs::remove_dir_all(&dir).unwrap();
601    }
602
603    #[test]
604    fn has_block_false_with_only_begin_no_end() {
605        let m = BlockMarkers::standard("app");
606        let content = "# BEGIN app managed block -- do not edit\nstuff but no end";
607        assert!(!has_block(content, &m));
608    }
609
610    #[test]
611    fn markers_with_id_blocks_distinguish_by_id() {
612        // A block with id "foo" should not be found when searching for id "bar"
613        let m_foo = BlockMarkers::with_id("app", "foo");
614        let m_bar = BlockMarkers::with_id("app", "bar");
615        let content =
616            "# --- BEGIN app managed (foo) ---\ncontent\n# --- END app managed (foo) ---\n";
617        assert!(has_block(content, &m_foo));
618        assert!(!has_block(content, &m_bar));
619    }
620
621    #[cfg(unix)]
622    #[test]
623    fn write_config_file_sets_permissions() {
624        let dir = std::env::temp_dir().join(format!(
625            "enclaveapp-config-block-perms-test-{}",
626            std::process::id()
627        ));
628        std::fs::create_dir_all(&dir).unwrap();
629        let path = dir.join("restricted");
630        write_config_file(&path, "secret\n").unwrap();
631
632        use std::os::unix::fs::PermissionsExt;
633        let perms = std::fs::metadata(&path).unwrap().permissions();
634        assert_eq!(perms.mode() & 0o777, 0o600);
635
636        std::fs::remove_dir_all(&dir).unwrap();
637    }
638}