Skip to main content

braze_sync/fs/
mod.rs

1//! Local filesystem I/O for braze-sync resource files.
2//!
3//! Each resource type has its own submodule with concrete reader / writer
4//! functions. The functions take a root directory and a domain value and
5//! have **no awareness of the config system** — the CLI layer is
6//! responsible for joining the config directory with
7//! `resources.<kind>.path` to compute the root. This keeps `fs/` standalone
8//! testable and avoids a `fs/` ↔ `config/` cycle.
9//!
10//! See IMPLEMENTATION.md §5, §9.
11
12pub mod catalog_io;
13pub mod content_block_io;
14pub mod custom_attribute_io;
15pub mod email_template_io;
16pub mod frontmatter;
17pub mod tag_io;
18
19use crate::error::{Error, Result};
20use std::path::{Path, PathBuf};
21
22/// Try to open a resource root directory for reading.
23///
24/// Returns `Ok(None)` when the path does not exist (a valid state for a
25/// fresh project), `Err(InvalidFormat)` when the path is a file, or
26/// `Ok(Some(ReadDir))` on success.
27pub(crate) fn try_read_resource_dir(
28    root: &Path,
29    kind_label: &str,
30) -> Result<Option<std::fs::ReadDir>> {
31    match std::fs::read_dir(root) {
32        Ok(rd) => Ok(Some(rd)),
33        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
34        Err(e) => {
35            if root.is_file() {
36                return Err(Error::InvalidFormat {
37                    path: root.to_path_buf(),
38                    message: format!("expected a directory for the {kind_label} root"),
39                });
40            }
41            Err(e.into())
42        }
43    }
44}
45
46/// Reject names that would escape the resource root or otherwise
47/// confuse the on-disk layout. Used by every resource writer as a last
48/// line of defence on top of the validate command's pattern check.
49pub(crate) fn validate_resource_name(kind_label: &str, name: &str) -> Result<()> {
50    let bad = name.is_empty()
51        || name == "."
52        || name == ".."
53        || name.contains('/')
54        || name.contains('\\')
55        || name.contains('\0');
56    if bad {
57        return Err(Error::InvalidFormat {
58            path: PathBuf::from(name),
59            message: format!("{kind_label} name '{name}' contains invalid characters"),
60        });
61    }
62    Ok(())
63}
64
65/// Write `contents` to `path` via write-to-temp-then-rename so readers
66/// never see a partially-written file. Creates parent directories as needed.
67///
68/// The temp file is fsynced before the rename to ensure data reaches stable
69/// storage even on a crash between write and rename. The temp name includes
70/// the process ID to avoid collisions if two braze-sync processes write to
71/// the same workspace concurrently. Same-directory rename guarantees the
72/// operation does not cross filesystem boundaries.
73pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
74    use std::io::Write;
75
76    let parent = path.parent().unwrap_or_else(|| Path::new("."));
77    std::fs::create_dir_all(parent)?;
78    let file_name = path.file_name().ok_or_else(|| Error::InvalidFormat {
79        path: path.to_path_buf(),
80        message: "atomic write target has no file name".into(),
81    })?;
82
83    let mut tmp_name = file_name.to_os_string();
84    tmp_name.push(format!(".{}.tmp", std::process::id()));
85    let tmp_path = parent.join(tmp_name);
86
87    let mut file = std::fs::File::create(&tmp_path)?;
88    if let Err(e) = file.write_all(contents).and_then(|_| file.sync_all()) {
89        drop(file);
90        let _ = std::fs::remove_file(&tmp_path);
91        return Err(e.into());
92    }
93    drop(file);
94
95    if let Err(e) = std::fs::rename(&tmp_path, path) {
96        let _ = std::fs::remove_file(&tmp_path);
97        return Err(e.into());
98    }
99    Ok(())
100}