Skip to main content

vmdk/
ddb.rs

1//! VMDK disk database (`ddb.*`) — the descriptor's metadata namespace.
2//!
3//! `VMware` writes a `# The Disk Data Base` section of `ddb.<key> = "<value>"` pairs
4//! at the end of the descriptor: virtual geometry, controller type, VM hardware /
5//! tools versions, disk UUID, long content ID, thin-provisioning, and the
6//! descriptor text encoding. Both `qemu-img` and libvmdk parse the descriptor but
7//! **discard every `ddb.*` value**; surfacing them is high-value forensic metadata
8//! (image dating, controller provenance, cross-snapshot disk identity).
9//!
10//! Source: libvmdk VMDK format spec — "The disk database"
11//!   <https://github.com/libyal/libvmdk/blob/main/documentation/VMware%20Virtual%20Disk%20Format%20(VMDK).asciidoc>
12
13/// Virtual disk CHS geometry from `ddb.geometry.{cylinders,heads,sectors}`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub struct DiskGeometry {
17    pub cylinders: u32,
18    pub heads: u32,
19    pub sectors: u32,
20}
21
22impl DiskGeometry {
23    /// Total sectors implied by the CHS geometry (`cylinders * heads * sectors`).
24    #[must_use]
25    pub fn chs_sectors(&self) -> u64 {
26        u64::from(self.cylinders) * u64::from(self.heads) * u64::from(self.sectors)
27    }
28}
29
30/// Parsed `ddb.*` disk database from a VMDK descriptor.
31///
32/// Typed access to the common keys plus a raw key/value list for any others.
33/// All fields are `None`/empty when the descriptor carries no disk database
34/// (e.g. a snapshot delta, whose ddb section is stripped).
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub struct DiskDatabase {
38    /// `ddb.adapterType` — virtual controller (`ide`/`buslogic`/`lsilogic`/`lsisas1068`/`pvscsi`/`legacyESX`).
39    pub adapter_type: Option<String>,
40    /// `ddb.geometry.{cylinders,heads,sectors}` — present only when all three exist.
41    pub geometry: Option<DiskGeometry>,
42    /// `ddb.geometry.bios{Cylinders,Heads,Sectors}` — BIOS-reported geometry.
43    pub bios_geometry: Option<DiskGeometry>,
44    /// `ddb.virtualHWVersion` — VM hardware version (dates the creating platform).
45    pub virtual_hw_version: Option<String>,
46    /// `ddb.toolsVersion` — installed `VMware` Tools build.
47    pub tools_version: Option<String>,
48    /// `ddb.uuid` — disk UUID (space-separated hex bytes as written).
49    pub uuid: Option<String>,
50    /// `ddb.longContentID` — 128-bit content ID (used when `CID == 0xFFFFFFFE`).
51    pub long_content_id: Option<String>,
52    /// `ddb.thinProvisioned` — thin (`true`) vs thick (`false`).
53    pub thin_provisioned: Option<bool>,
54    /// `ddb.encoding` — descriptor text encoding (e.g. `UTF-8`, `windows-1252`).
55    pub encoding: Option<String>,
56    /// Every `ddb.*` key/value as written, including ones without a typed field.
57    pub entries: Vec<(String, String)>,
58}
59
60impl DiskDatabase {
61    /// Parse the `ddb.*` lines from a descriptor's text.
62    #[must_use]
63    pub fn parse(descriptor_text: &str) -> Self {
64        let mut db = DiskDatabase::default();
65        let (mut cyl, mut head, mut sect) = (None, None, None);
66        let (mut bcyl, mut bhead, mut bsect) = (None, None, None);
67
68        for line in descriptor_text.lines() {
69            let line = line.trim();
70            let Some(rest) = line.strip_prefix("ddb.") else {
71                continue;
72            };
73            // `ddb.<key> = "<value>"` (value may or may not be quoted).
74            let Some((key, value)) = rest.split_once('=') else {
75                continue;
76            };
77            let key = key.trim();
78            let value = value.trim().trim_matches('"').to_owned();
79            let full_key = format!("ddb.{key}");
80            db.entries.push((full_key, value.clone()));
81
82            match key {
83                "adapterType" => db.adapter_type = Some(value),
84                "virtualHWVersion" => db.virtual_hw_version = Some(value),
85                "toolsVersion" => db.tools_version = Some(value),
86                "uuid" => db.uuid = Some(value),
87                "longContentID" => db.long_content_id = Some(value),
88                "encoding" => db.encoding = Some(value),
89                "thinProvisioned" => db.thin_provisioned = Some(value.trim() == "1"),
90                "geometry.cylinders" => cyl = value.parse().ok(),
91                "geometry.heads" => head = value.parse().ok(),
92                "geometry.sectors" => sect = value.parse().ok(),
93                "geometry.biosCylinders" => bcyl = value.parse().ok(),
94                "geometry.biosHeads" => bhead = value.parse().ok(),
95                "geometry.biosSectors" => bsect = value.parse().ok(),
96                _ => {}
97            }
98        }
99
100        if let (Some(cylinders), Some(heads), Some(sectors)) = (cyl, head, sect) {
101            db.geometry = Some(DiskGeometry {
102                cylinders,
103                heads,
104                sectors,
105            });
106        }
107        if let (Some(cylinders), Some(heads), Some(sectors)) = (bcyl, bhead, bsect) {
108            db.bios_geometry = Some(DiskGeometry {
109                cylinders,
110                heads,
111                sectors,
112            });
113        }
114        db
115    }
116
117    /// `true` when the descriptor carried no `ddb.*` entries at all.
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.entries.is_empty()
121    }
122
123    /// Raw value of a `ddb.*` key as written (full key, e.g. `"ddb.adapterType"`).
124    #[must_use]
125    pub fn get(&self, key: &str) -> Option<&str> {
126        self.entries
127            .iter()
128            .find(|(k, _)| k == key)
129            .map(|(_, v)| v.as_str())
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    const FULL: &str = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n\nddb.adapterType = \"lsilogic\"\nddb.geometry.cylinders = \"16383\"\nddb.geometry.heads = \"16\"\nddb.geometry.sectors = \"63\"\nddb.virtualHWVersion = \"7\"\nddb.toolsVersion = \"10338\"\nddb.uuid = \"60 00 C2 97 1a 2b 3c 4d-5e 6f 70 81 92 a3 b4 c5\"\nddb.longContentID = \"deadbeefcafef00d1122334455667788\"\nddb.thinProvisioned = \"1\"\nddb.encoding = \"UTF-8\"\n";
138
139    #[test]
140    fn parses_adapter_type_and_versions() {
141        let db = DiskDatabase::parse(FULL);
142        assert_eq!(db.adapter_type.as_deref(), Some("lsilogic"));
143        assert_eq!(db.virtual_hw_version.as_deref(), Some("7"));
144        assert_eq!(db.tools_version.as_deref(), Some("10338"));
145        assert_eq!(db.encoding.as_deref(), Some("UTF-8"));
146    }
147
148    #[test]
149    fn parses_geometry() {
150        let db = DiskDatabase::parse(FULL);
151        let g = db.geometry.expect("geometry present");
152        assert_eq!(g.cylinders, 16383);
153        assert_eq!(g.heads, 16);
154        assert_eq!(g.sectors, 63);
155        // CHS-reported sector count.
156        assert_eq!(g.chs_sectors(), 16383 * 16 * 63);
157    }
158
159    #[test]
160    fn parses_uuid_thin_and_long_content_id() {
161        let db = DiskDatabase::parse(FULL);
162        assert_eq!(
163            db.uuid.as_deref(),
164            Some("60 00 C2 97 1a 2b 3c 4d-5e 6f 70 81 92 a3 b4 c5")
165        );
166        assert_eq!(
167            db.long_content_id.as_deref(),
168            Some("deadbeefcafef00d1122334455667788")
169        );
170        assert_eq!(db.thin_provisioned, Some(true));
171    }
172
173    #[test]
174    fn empty_when_no_ddb_section() {
175        let db = DiskDatabase::parse(
176            "# Disk DescriptorFile\nversion=1\ncreateType=\"monolithicFlat\"\n",
177        );
178        assert!(db.is_empty());
179        assert_eq!(db.adapter_type, None);
180        assert_eq!(db.geometry, None);
181        assert_eq!(db.thin_provisioned, None);
182    }
183
184    #[test]
185    fn unknown_ddb_keys_are_retained() {
186        let db = DiskDatabase::parse("ddb.somethingNew = \"42\"\nddb.adapterType = \"ide\"\n");
187        assert_eq!(db.adapter_type.as_deref(), Some("ide"));
188        assert_eq!(db.get("ddb.somethingNew"), Some("42"));
189        assert!(!db.is_empty());
190    }
191
192    #[test]
193    fn thin_provisioned_zero_is_false() {
194        let db = DiskDatabase::parse("ddb.thinProvisioned = \"0\"\n");
195        assert_eq!(db.thin_provisioned, Some(false));
196    }
197
198    #[test]
199    fn parses_bios_geometry() {
200        let db = DiskDatabase::parse(
201            "ddb.geometry.biosCylinders = \"100\"\nddb.geometry.biosHeads = \"8\"\nddb.geometry.biosSectors = \"32\"\n",
202        );
203        let g = db.bios_geometry.expect("bios geometry present");
204        assert_eq!(g.cylinders, 100);
205        assert_eq!(g.heads, 8);
206        assert_eq!(g.sectors, 32);
207    }
208
209    #[test]
210    fn ddb_line_without_equals_is_skipped() {
211        // A malformed `ddb.` line with no `=` is skipped, not an error.
212        let db = DiskDatabase::parse("ddb.brokenline\nddb.adapterType = \"ide\"\n");
213        assert_eq!(db.adapter_type.as_deref(), Some("ide"));
214    }
215
216    #[test]
217    fn partial_geometry_is_ignored() {
218        // Geometry requires all three of cylinders/heads/sectors.
219        let db =
220            DiskDatabase::parse("ddb.geometry.cylinders = \"100\"\nddb.geometry.heads = \"4\"\n");
221        assert_eq!(db.geometry, None);
222    }
223}