Skip to main content

soma_schema/
migration.rs

1use std::path::PathBuf;
2
3use sha2::{Digest, Sha256};
4
5use crate::error::Result;
6
7/// The separator line (trimmed) that splits UP from DOWN in a migration file.
8const DOWN_SEPARATOR: &str = "-- DOWN ==";
9
10/// Chunk size for streaming SHA-256 computation.
11const CHECKSUM_CHUNK: usize = 8192;
12
13/// Read a migration file, compute its SHA-256 checksum, and return both together.
14///
15/// Returning (checksum, raw_content) atomically from a single read prevents TOCTOU:
16/// the checksum and the SQL content are always from the same bytes.
17pub(crate) fn read_and_checksum(path: &std::path::Path) -> Result<(String, String)> {
18    let raw = std::fs::read_to_string(path)?;
19    let mut hasher = Sha256::new();
20    for chunk in raw.as_bytes().chunks(CHECKSUM_CHUNK) {
21        hasher.update(chunk);
22    }
23    let hex: String = hasher
24        .finalize()
25        .iter()
26        .map(|b| format!("{b:02x}"))
27        .collect();
28    Ok((hex, raw))
29}
30
31/// Split raw SQL text into (up_sql, down_sql).
32/// Splits on the first line that is exactly `-- DOWN ==` (trimmed).
33fn split_up_down(raw: &str) -> (String, Option<String>) {
34    let mut up_lines: Vec<&str> = Vec::new();
35    let mut down_lines: Vec<&str> = Vec::new();
36    let mut in_down = false;
37    for line in raw.lines() {
38        if !in_down && line.trim() == DOWN_SEPARATOR {
39            in_down = true;
40            continue;
41        }
42        if in_down {
43            down_lines.push(line);
44        } else {
45            up_lines.push(line);
46        }
47    }
48    let up_sql = up_lines.join("\n");
49    let down_sql = if in_down {
50        Some(down_lines.join("\n"))
51    } else {
52        None
53    };
54    (up_sql, down_sql)
55}
56
57/// Lightweight migration metadata — SQL content is stored from the initial read.
58/// The single file read that computed the checksum also supplies the raw SQL,
59/// eliminating any TOCTOU window between checksum computation and execution.
60#[derive(Debug)]
61pub struct Migration {
62    pub version: u32,
63    pub file: String,
64    /// Human-readable name derived from the filename (strip .sql suffix).
65    pub name: String,
66    /// Lowercase hex SHA-256 of the FULL raw file content.
67    pub checksum: String,
68    /// Authored date from the manifest entry.
69    pub created: Option<String>,
70    /// Author from the manifest entry.
71    pub author: Option<String>,
72    pub why: Option<String>,
73    /// Raw file content (from the same read that produced `checksum`).
74    raw: String,
75}
76
77impl Migration {
78    /// Build a Migration from a single atomic file read.
79    /// `checksum` and `raw` must come from the same `read_and_checksum` call.
80    pub(crate) fn from_metadata(
81        version: u32,
82        file: &str,
83        checksum: String,
84        raw: String,
85        created: Option<&str>,
86        author: Option<&str>,
87        why: Option<&str>,
88    ) -> Self {
89        let name = file.strip_suffix(".sql").unwrap_or(file).to_owned();
90        Self {
91            version,
92            file: file.to_owned(),
93            name,
94            checksum,
95            raw,
96            created: created.map(str::to_owned),
97            author: author.map(str::to_owned),
98            why: why.map(str::to_owned),
99        }
100    }
101
102    /// Return the UP SQL section from the stored raw content.
103    pub(crate) fn read_up(&self) -> (String, Option<String>) {
104        split_up_down(&self.raw)
105    }
106
107    /// Return the DOWN SQL section from the stored raw content.
108    pub(crate) fn read_down(&self) -> Option<String> {
109        split_up_down(&self.raw).1
110    }
111
112    /// The UP section of this migration's SQL (everything before `-- DOWN ==`).
113    ///
114    /// Returns an owned `String` because the split is computed on demand from the
115    /// stored raw content; there is no pre-split buffer to borrow from.
116    pub fn up(&self) -> String {
117        split_up_down(&self.raw).0
118    }
119
120    /// The DOWN section of this migration's SQL (everything after `-- DOWN ==`),
121    /// or `None` if the file has no DOWN section.
122    pub fn down(&self) -> Option<String> {
123        split_up_down(&self.raw).1
124    }
125}
126
127/// A file from `00_setup/`, run unconditionally and untracked.
128/// Content is loaded lazily at run time.
129#[derive(Debug)]
130pub struct SetupFile {
131    pub name: String,
132    pub(crate) path: PathBuf,
133}
134
135impl SetupFile {
136    pub(crate) fn read_sql(&self) -> Result<String> {
137        Ok(std::fs::read_to_string(&self.path)?)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::io::Write as _;
145
146    fn write_temp(content: &str) -> (tempfile::NamedTempFile, std::path::PathBuf) {
147        let mut f = tempfile::NamedTempFile::new().unwrap();
148        f.write_all(content.as_bytes()).unwrap();
149        let p = f.path().to_path_buf();
150        (f, p)
151    }
152
153    #[test]
154    fn split_with_down() {
155        let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
156        let (up, down) = split_up_down(raw);
157        assert_eq!(up, "CREATE TABLE t (id INT);");
158        assert_eq!(down.as_deref(), Some("DROP TABLE t;"));
159    }
160
161    #[test]
162    fn split_without_down() {
163        let raw = "CREATE TABLE t (id INT);";
164        let (up, down) = split_up_down(raw);
165        assert_eq!(up, "CREATE TABLE t (id INT);");
166        assert!(down.is_none());
167    }
168
169    #[test]
170    fn separator_must_be_exact_trim() {
171        // Extra text after separator => NOT treated as separator
172        let raw = "UP;\n-- DOWN == extra\nDOWN;";
173        let (_, down) = split_up_down(raw);
174        assert!(down.is_none(), "should not split on partial separator");
175    }
176
177    #[test]
178    fn checksum_is_of_full_file() {
179        use sha2::Digest;
180        let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
181        let (_tmp, path) = write_temp(raw);
182        let (got_checksum, got_raw) = read_and_checksum(&path).unwrap();
183        assert_eq!(got_raw, raw);
184
185        let mut h = Sha256::new();
186        h.update(raw.as_bytes());
187        let expected: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
188        assert_eq!(got_checksum, expected);
189    }
190
191    #[test]
192    fn read_up_and_down_from_migration() {
193        let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
194        let (_tmp, path) = write_temp(raw);
195        let (checksum, file_raw) = read_and_checksum(&path).unwrap();
196        let m = Migration::from_metadata(
197            1,
198            "20260101_01_init.sql",
199            checksum,
200            file_raw,
201            None,
202            None,
203            None,
204        );
205        drop(path); // no longer needed after read_and_checksum
206        let (up, down) = m.read_up();
207        assert_eq!(up, "CREATE TABLE t (id INT);");
208        assert_eq!(down.as_deref(), Some("DROP TABLE t;"));
209        assert_eq!(m.read_down().as_deref(), Some("DROP TABLE t;"));
210    }
211
212    #[test]
213    fn read_up_without_down() {
214        let raw = "CREATE TABLE t (id INT);";
215        let (_tmp, path) = write_temp(raw);
216        let (checksum, file_raw) = read_and_checksum(&path).unwrap();
217        let m = Migration::from_metadata(
218            1,
219            "20260101_01_init.sql",
220            checksum,
221            file_raw,
222            None,
223            None,
224            None,
225        );
226        drop(path); // no longer needed after read_and_checksum
227        let (up, down) = m.read_up();
228        assert_eq!(up, "CREATE TABLE t (id INT);");
229        assert!(down.is_none());
230        assert!(m.read_down().is_none());
231    }
232
233    #[test]
234    fn public_up_and_down_with_separator() {
235        let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
236        let (_tmp, path) = write_temp(raw);
237        let (checksum, file_raw) = read_and_checksum(&path).unwrap();
238        let m = Migration::from_metadata(
239            1,
240            "20260101_01_init.sql",
241            checksum,
242            file_raw,
243            None,
244            None,
245            None,
246        );
247        assert_eq!(m.up(), "CREATE TABLE t (id INT);");
248        assert_eq!(m.down().as_deref(), Some("DROP TABLE t;"));
249    }
250
251    #[test]
252    fn public_up_and_down_without_separator() {
253        let raw = "CREATE TABLE t (id INT);";
254        let (_tmp, path) = write_temp(raw);
255        let (checksum, file_raw) = read_and_checksum(&path).unwrap();
256        let m = Migration::from_metadata(
257            1,
258            "20260101_01_init.sql",
259            checksum,
260            file_raw,
261            None,
262            None,
263            None,
264        );
265        assert_eq!(m.up(), "CREATE TABLE t (id INT);");
266        assert!(m.down().is_none());
267    }
268}