runtime-rs: Add dm-verity utility functions to gpt_disk module

When containerd creates dm-verity-protected EROFS layers, it stores
the root hash and parameters as OCI annotations — but the format
does not directly map to the kernel dm-verity table that the guest
agent needs to construct.

Bridge this gap with functions that parse containerd's dm-verity
annotation JSON, detect whether a v1 superblock is embedded at the
hash offset (to extract the salt automatically rather than relying
on containerd's hardcoded default), and produce the X-kata.dmverity.*
storage options the agent expects.

This keeps all dm-verity metadata translation on the host side, so
the agent can consume a flat list of options without understanding
the containerd annotation schema.

Signed-off-by: Alex Lyn <alex.lyn@antgroup.com>
This commit is contained in:
Alex Lyn
2026-05-26 19:27:14 +08:00
parent 499fefd972
commit 3051b8d11a

View File

@@ -21,7 +21,9 @@ use anyhow::{anyhow, Context, Result};
use crc::Crc;
use gpt::{disk::LogicalBlockSize, mbr::ProtectiveMBR, partition_types, GptConfig};
use scopeguard;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use std::convert::TryInto;
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
@@ -52,6 +54,17 @@ const CRC_32: Crc<u32> = Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
const GPT_META_HEAD_IMG: &str = "gpt_meta_head.img";
/// Temporary full GPT image used to synthesize head metadata
const GPT_META_FULL_IMG: &str = "gpt_meta_full.img";
/// Annotation key used by containerd to specify dm-verity metadata path
const X_CONTAINERD_DMVERITY: &str = "X-containerd.dmverity";
/// dm-verity v1 superblock magic number: the ASCII string "verity" followed by two NUL bytes.
const DM_VERITY_MAGIC: [u8; 8] = [0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x00, 0x00];
/// dm-verity superblock version that we support
const DM_VERITY_SB_VERSION: u32 = 1;
/// Default dm-verity salt used by containerd's erofs snapshotter.
const CONTAINERD_DEFAULT_DMVERITY_SALT: &str =
"0000000000000000000000000000000000000000000000000000000000000000";
/// Maximum expected size of dm-verity metadata JSON file (arbitrary limit to prevent abuse)
const MAX_DMVERITY_METADATA_SIZE: u64 = 65536;
/// Represents a read-only EROFS layer to be placed in a GPT partition
#[derive(Debug, Clone)]
@@ -100,6 +113,202 @@ pub struct GptMetadataFiles {
/// Paths to generated padding files (between partitions)
pub pad_paths: Vec<PathBuf>,
}
/// dm-verity metadata structure matching containerd's JSON format
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DmVerityMetadata {
/// Root hash of the dm-verity tree
pub roothash: String,
/// Offset in bytes where the hash data starts
pub hashoffset: u64,
/// Salt value for dm-verity (hex-encoded, optional).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub salt: Option<String>,
}
/// Parse dm-verity metadata from a JSON file
/// The metadata path looks like: "/var/lib/containerd/.../layer.erofs.dmverity"
/// and the file is expected to be in the format produced by containerd's dm-verity implementation, e.g.:
/// ```json
/// {
/// "roothash": "abcdef1234567890...",
/// "hashoffset": 12345678,
/// }```
pub fn parse_dmverity_metadata_file(path: &str) -> Result<DmVerityMetadata> {
let file_meta = fs::metadata(path).context("failed to stat dm-verity metadata file")?;
if file_meta.len() > MAX_DMVERITY_METADATA_SIZE {
return Err(anyhow!(
"dm-verity metadata file too large: {} bytes (max {})",
file_meta.len(),
MAX_DMVERITY_METADATA_SIZE
));
}
let content = fs::read_to_string(path).context("failed to read dm-verity metadata file")?;
let meta: DmVerityMetadata =
serde_json::from_str(&content).context("failed to parse dm-verity metadata JSON")?;
Ok(meta)
}
/// Extract dm-verity metadata path from mount annotations
pub fn extract_dmverity_annotation(
annotations: &std::collections::HashMap<String, String>,
) -> Option<&str> {
annotations.get(X_CONTAINERD_DMVERITY).map(|s| s.as_str())
}
/// Check whether a dm-verity v1 superblock exists at the given offset in an EROFS image file.
///
/// The dm-verity v1 superblock starts with the ASCII signature "verity\0\0".
/// This is critical for correctly computing `hash_start_block` in the dm-verity table:
/// - With superblock: hash tree starts after the superblock (hashoffset + superblock_size)
/// - Without superblock: hash tree starts directly at hashoffset
fn has_verity_superblock(erofs_path: &str, hashoffset: u64) -> bool {
let mut file = match fs::File::open(erofs_path) {
Ok(f) => f,
Err(_) => return false,
};
if file.seek(SeekFrom::Start(hashoffset)).is_err() {
return false;
}
let mut magic = [0u8; 8];
if file.read_exact(&mut magic).is_err() {
return false;
}
// dm-verity v1 superblock signature: "verity\0\0" (ASCII, 8 bytes)
magic == DM_VERITY_MAGIC
}
/// Read the salt from a dm-verity v1 superblock embedded in an EROFS image file.
///
/// The dm-verity v1 superblock is located at `hashoffset` bytes from the start
/// of the file. Its layout matches the go-dmverity `Superblock` struct
/// (see `pkg/verity/superblock.go`), serialized in little-endian:
///
/// Offset Size Field
/// 0 8 Signature ("verity\0\0")
/// 8 4 Version (uint32, must be 1)
/// 12 4 HashType (uint32, 0 or 1)
/// 16 16 UUID
/// 32 32 Algorithm (null-terminated, e.g. "sha256")
/// 64 4 DataBlockSize (uint32)
/// 68 4 HashBlockSize (uint32)
/// 72 8 DataBlocks (uint64)
/// 80 2 SaltSize (uint16)
/// 82 6 Pad1
/// 88 256 Salt
/// 344 168 Pad2
/// Total: 512 bytes
///
/// We only need to extract the salt. If the superblock cannot be read or
/// parsed, we return None and the agent will use its default salt.
fn read_verity_superblock_salt(erofs_path: &str, hashoffset: u64) -> Option<String> {
let mut file = fs::File::open(erofs_path).ok()?;
file.seek(SeekFrom::Start(hashoffset)).ok()?;
// Read the fixed portion of the superblock up to and including salt_size
// We need bytes 0..82 to get the salt_size field
let mut sb_header = [0u8; 88];
file.read_exact(&mut sb_header).ok()?;
// Verify magic
if sb_header[0..8] != DM_VERITY_MAGIC {
return None;
}
// Verify version
let version = u32::from_le_bytes(sb_header[8..12].try_into().ok()?);
if version != DM_VERITY_SB_VERSION {
return None;
}
// salt_size is at offset 80 (uint16, LE)
let salt_size = u16::from_le_bytes(sb_header[80..82].try_into().ok()?) as usize;
if salt_size == 0 {
// No salt used
return None;
}
if salt_size > 256 {
return None;
}
// Read salt bytes (starts at offset 88 in the superblock, after Pad1)
// We already read up to offset 82 (sb_header is 88 bytes), so we need
// to skip 88 - 82 = 6 bytes of Pad1, then read salt_size bytes.
// Actually, sb_header is 88 bytes which includes the 6 bytes of Pad1
// (offset 82..88). So the file cursor is now at offset 88, which is
// exactly where the salt starts. We can read directly.
let mut salt_bytes = vec![0u8; salt_size];
file.read_exact(&mut salt_bytes).ok()?;
// Encode as lowercase hex string
let salt_hex: String = salt_bytes.iter().map(|b| format!("{:02x}", b)).collect();
Some(salt_hex)
}
/// Generate dm-verity storage options for kata-agent
pub fn generate_dmverity_options(
metadata: &DmVerityMetadata,
erofs_path: Option<&str>,
) -> Vec<String> {
// Auto-detect whether a dm-verity v1 superblock exists at hashoffset.
let no_superblock = if let Some(path) = erofs_path {
!has_verity_superblock(path, metadata.hashoffset)
} else {
// Cannot detect without the file path; default to no-superblock=false
// which matches the containerd erofs snapshotter default behavior
false
};
let mut options = vec![
"X-kata.dmverity-enabled=true".to_string(),
format!("X-kata.dmverity.roothash={}", metadata.roothash),
format!("X-kata.dmverity.hashoffset={}", metadata.hashoffset),
format!("X-kata.dmverity.no-superblock={}", no_superblock),
];
let salt_resolved = if let Some(ref salt_hex) = metadata.salt {
// Priority 1: salt from .dmverity JSON metadata
info!(
sl!(),
"Using dm-verity salt from .dmverity metadata: {} ({} bytes)",
salt_hex,
salt_hex.len() / 2
);
Some(salt_hex.clone())
} else if let Some(path) = erofs_path {
if !no_superblock {
// Priority 2: read salt from superblock (only when superblock exists)
read_verity_superblock_salt(path, metadata.hashoffset).or_else(|| {
info!(
sl!(),
"Failed to read dm-verity salt from superblock, using containerd default salt (32 zero bytes)"
);
Some(CONTAINERD_DEFAULT_DMVERITY_SALT.to_string())
})
} else {
// Priority 3: NoSuperblock mode — use containerd's default salt
Some(CONTAINERD_DEFAULT_DMVERITY_SALT.to_string())
}
} else {
// No EROFS path provided and no salt in metadata — fall back to containerd default
info!(
sl!(),
"No EROFS path for salt detection, using containerd default salt (32 zero bytes)"
);
Some(CONTAINERD_DEFAULT_DMVERITY_SALT.to_string())
};
if let Some(salt) = salt_resolved {
options.push(format!("X-kata.dmverity.salt={}", salt));
}
options
}
/// Extract snapshot ID from a source path
///