From acdec5486edc33f816436a3d59ffe5a59c320223 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 20 Sep 2026 05:31:26 -0700 Subject: [PATCH 1/3] refactor(dstack-volume): read the veritysetup root hash through one grammar --- .../dstack-volume/src/bin/dstack-volume.rs | 10 +-- dstack/crates/dstack-volume/src/lib.rs | 66 +++++++++++++++++++ dstack/crates/dstack-volume/src/volume.rs | 18 +---- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/dstack/crates/dstack-volume/src/bin/dstack-volume.rs b/dstack/crates/dstack-volume/src/bin/dstack-volume.rs index 367fc33fc..0e2d8f251 100644 --- a/dstack/crates/dstack-volume/src/bin/dstack-volume.rs +++ b/dstack/crates/dstack-volume/src/bin/dstack-volume.rs @@ -431,15 +431,7 @@ fn unescape_mountinfo(value: &[u8]) -> Vec { fn mapping_root(mapper_name: &str) -> Result { let status = run_fun!(veritysetup status $mapper_name)?; - status - .lines() - .find_map(|line| { - let line = line.trim(); - line.strip_prefix("root hash:") - .or_else(|| line.strip_prefix("Root hash:")) - }) - .map(|root| root.trim().to_string()) - .context("verity mapping status has no root hash") + dstack_volume::parse_verity_root_hash(&status).context("verity mapping status has no root hash") } #[cfg(test)] diff --git a/dstack/crates/dstack-volume/src/lib.rs b/dstack/crates/dstack-volume/src/lib.rs index 5a8aa5f28..cee5fda9b 100644 --- a/dstack/crates/dstack-volume/src/lib.rs +++ b/dstack/crates/dstack-volume/src/lib.rs @@ -23,6 +23,72 @@ pub mod volume_format; pub use volume::Compression; +/// Read the root hash out of `veritysetup` output. +/// +/// One grammar for both commands this project reads. `veritysetup format` +/// prints `Root hash:\t` flush left; `veritysetup status` prints +/// ` root hash:\t` indented and lower-cased. Two parsers, one per +/// command, is how a later cryptsetup release gets to break one of them +/// silently -- so accept both spellings in one place and require what follows +/// to actually be a hash. +pub fn parse_verity_root_hash(output: &str) -> Option { + output + .lines() + .filter_map(|line| { + let line = line.trim(); + line.strip_prefix("Root hash:") + .or_else(|| line.strip_prefix("root hash:")) + }) + .map(str::trim) + .find(|root| !root.is_empty() && root.bytes().all(|byte| byte.is_ascii_hexdigit())) + .map(str::to_string) +} + +#[cfg(test)] +mod root_hash_tests { + use super::parse_verity_root_hash; + + /// Captured from `veritysetup 2.7.0`: `format` writes the summary flush + /// left with a capital R, `status` writes it indented and lower-cased. + #[test] + fn both_veritysetup_spellings_parse() { + const ROOT: &str = "e85f5e7498a2b6f3ef05d0f612cfbec7f7bff7c2cbd64ffdebb972ab54606f89"; + let format_output = format!( + "VERITY header information for hash.img\n\ + UUID: \taa7082ce-3622-4ce5-a875-9cc88edf35a7\n\ + Hash algorithm: \tsha256\n\ + Salt: \t00\n\ + Root hash: \t{ROOT}\n\ + Hash device size: \t8192 [bytes]\n" + ); + let status_output = format!( + "/dev/mapper/dstack-verity0 is active.\n\ + \ttype: VERITY\n\ + \tstatus: verified\n\ + \thash name: sha256\n\ + \troot hash: {ROOT}\n" + ); + assert_eq!( + parse_verity_root_hash(&format_output).as_deref(), + Some(ROOT) + ); + assert_eq!( + parse_verity_root_hash(&status_output).as_deref(), + Some(ROOT) + ); + } + + /// A line that announces a root hash and then does not carry one is not a + /// root hash. Returning it would feed `veritysetup open` a bad argument + /// and blame the volume for it. + #[test] + fn a_label_without_a_hash_is_not_a_root_hash() { + assert_eq!(parse_verity_root_hash("Root hash:\n"), None); + assert_eq!(parse_verity_root_hash("root hash: (none)\n"), None); + assert_eq!(parse_verity_root_hash("no root hash here\n"), None); + } +} + /// A fixed dm-verity salt. /// /// The root is a function of the squashfs bytes and this salt, so keeping the diff --git a/dstack/crates/dstack-volume/src/volume.rs b/dstack/crates/dstack-volume/src/volume.rs index 6c8175788..604e86ac1 100644 --- a/dstack/crates/dstack-volume/src/volume.rs +++ b/dstack/crates/dstack-volume/src/volume.rs @@ -146,8 +146,8 @@ fn seal_data_image( $data_path $hash_path ) .context("running veritysetup format")?; - let verity_root = - parse_root_hash(&out).context("could not find the root hash in veritysetup output")?; + let verity_root = crate::parse_verity_root_hash(&out) + .context("could not find the root hash in veritysetup output")?; // Wrap the two blobs in a deterministic GPT disk image. Partition 1 is the // generic volume envelope, partition 2 is data, and partition 3 is verity. @@ -383,13 +383,6 @@ fn copy_into(src: &Path, out: &mut fs::File, offset: u64) -> Result<()> { Ok(()) } -fn parse_root_hash(output: &str) -> Option { - output - .lines() - .find_map(|l| l.strip_prefix("Root hash:")) - .map(|v| v.trim().to_string()) -} - fn require_tool(name: &str) -> Result<()> { let present = run_cmd!(which $name >/dev/null 2>&1).is_ok(); if !present { @@ -402,13 +395,6 @@ fn require_tool(name: &str) -> Result<()> { mod tests { use super::*; - #[test] - fn parses_veritysetup_root() { - let sample = "VERITY header information for x\nUUID: \nHash type: 1\n\ - Data blocks: 10\nRoot hash: abc123def\n"; - assert_eq!(parse_root_hash(sample).as_deref(), Some("abc123def")); - } - #[test] fn uuid_is_deterministic_and_content_specific() { use std::io::Write; From 492f162cca8fdaa1420104520ba7137ebccd8750 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 24 Sep 2026 02:03:46 -0700 Subject: [PATCH 2/3] refactor(dstack-volume): trim the root hash parser comments and tests --- dstack/crates/dstack-volume/src/lib.rs | 45 ++++---------------------- 1 file changed, 6 insertions(+), 39 deletions(-) diff --git a/dstack/crates/dstack-volume/src/lib.rs b/dstack/crates/dstack-volume/src/lib.rs index cee5fda9b..81786ca51 100644 --- a/dstack/crates/dstack-volume/src/lib.rs +++ b/dstack/crates/dstack-volume/src/lib.rs @@ -23,14 +23,8 @@ pub mod volume_format; pub use volume::Compression; -/// Read the root hash out of `veritysetup` output. -/// -/// One grammar for both commands this project reads. `veritysetup format` -/// prints `Root hash:\t` flush left; `veritysetup status` prints -/// ` root hash:\t` indented and lower-cased. Two parsers, one per -/// command, is how a later cryptsetup release gets to break one of them -/// silently -- so accept both spellings in one place and require what follows -/// to actually be a hash. +/// Read the root hash from `veritysetup format` (`Root hash:`) or +/// `veritysetup status` (indented `root hash:`) output. pub fn parse_verity_root_hash(output: &str) -> Option { output .lines() @@ -48,44 +42,17 @@ pub fn parse_verity_root_hash(output: &str) -> Option { mod root_hash_tests { use super::parse_verity_root_hash; - /// Captured from `veritysetup 2.7.0`: `format` writes the summary flush - /// left with a capital R, `status` writes it indented and lower-cased. #[test] fn both_veritysetup_spellings_parse() { - const ROOT: &str = "e85f5e7498a2b6f3ef05d0f612cfbec7f7bff7c2cbd64ffdebb972ab54606f89"; - let format_output = format!( - "VERITY header information for hash.img\n\ - UUID: \taa7082ce-3622-4ce5-a875-9cc88edf35a7\n\ - Hash algorithm: \tsha256\n\ - Salt: \t00\n\ - Root hash: \t{ROOT}\n\ - Hash device size: \t8192 [bytes]\n" - ); - let status_output = format!( - "/dev/mapper/dstack-verity0 is active.\n\ - \ttype: VERITY\n\ - \tstatus: verified\n\ - \thash name: sha256\n\ - \troot hash: {ROOT}\n" - ); assert_eq!( - parse_verity_root_hash(&format_output).as_deref(), - Some(ROOT) + parse_verity_root_hash("Salt:\t00\nRoot hash: \tabc123\n").as_deref(), + Some("abc123") ); assert_eq!( - parse_verity_root_hash(&status_output).as_deref(), - Some(ROOT) + parse_verity_root_hash(" type: VERITY\n root hash: abc123\n").as_deref(), + Some("abc123") ); - } - - /// A line that announces a root hash and then does not carry one is not a - /// root hash. Returning it would feed `veritysetup open` a bad argument - /// and blame the volume for it. - #[test] - fn a_label_without_a_hash_is_not_a_root_hash() { assert_eq!(parse_verity_root_hash("Root hash:\n"), None); - assert_eq!(parse_verity_root_hash("root hash: (none)\n"), None); - assert_eq!(parse_verity_root_hash("no root hash here\n"), None); } } From cc7d25630c7bb2ef73f9d4c8553f01c573f336bf Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 24 Sep 2026 07:15:54 -0700 Subject: [PATCH 3/3] refactor(dstack-volume): keep the root hash tests at the end of the file --- dstack/crates/dstack-volume/src/lib.rs | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/dstack/crates/dstack-volume/src/lib.rs b/dstack/crates/dstack-volume/src/lib.rs index 81786ca51..295415b86 100644 --- a/dstack/crates/dstack-volume/src/lib.rs +++ b/dstack/crates/dstack-volume/src/lib.rs @@ -38,24 +38,6 @@ pub fn parse_verity_root_hash(output: &str) -> Option { .map(str::to_string) } -#[cfg(test)] -mod root_hash_tests { - use super::parse_verity_root_hash; - - #[test] - fn both_veritysetup_spellings_parse() { - assert_eq!( - parse_verity_root_hash("Salt:\t00\nRoot hash: \tabc123\n").as_deref(), - Some("abc123") - ); - assert_eq!( - parse_verity_root_hash(" type: VERITY\n root hash: abc123\n").as_deref(), - Some("abc123") - ); - assert_eq!(parse_verity_root_hash("Root hash:\n"), None); - } -} - /// A fixed dm-verity salt. /// /// The root is a function of the squashfs bytes and this salt, so keeping the @@ -126,3 +108,21 @@ async fn verity_fs_image(fs_image: PathBuf, output: PathBuf) -> Result