wxtla 0.3.1

Wired eXploring Target Layer Accessor
Documentation
//! PDI descriptor parsing.

use roxmltree::{Document, Node};

use crate::{Error, Result};

const FIXED_SECTOR_SIZE: u64 = 512;
const NIL_GUID: &str = "00000000-0000-0000-0000-000000000000";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdiDescriptorImageType {
  Plain,
  Compressed,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PdiDescriptorImage {
  pub snapshot_identifier: String,
  pub image_type: PdiDescriptorImageType,
  pub file_name: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PdiStorageExtent {
  pub start_sector: u64,
  pub end_sector: u64,
  pub block_size_sectors: u32,
  pub images: Vec<PdiDescriptorImage>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PdiSnapshot {
  pub identifier: String,
  pub parent_identifier: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PdiDescriptor {
  pub disk_size_sectors: u64,
  pub logical_sector_size: u32,
  pub physical_sector_size: u32,
  pub extents: Vec<PdiStorageExtent>,
  pub snapshots: Vec<PdiSnapshot>,
}

impl PdiDescriptor {
  pub fn from_xml(text: &str) -> Result<Self> {
    let document = Document::parse(text).map_err(|error| {
      Error::invalid_format(format!("unable to parse pdi descriptor XML: {error}"))
    })?;
    let root = document.root_element();
    if root.tag_name().name() != "Parallels_disk_image" {
      return Err(Error::invalid_format(
        "pdi descriptor root element must be Parallels_disk_image".to_string(),
      ));
    }

    let version = root
      .attribute("Version")
      .map(str::trim)
      .ok_or_else(|| Error::invalid_format("pdi descriptor is missing the Version attribute"))?;
    if version != "1.0" {
      return Err(Error::invalid_format(format!(
        "unsupported pdi descriptor version: {}",
        version
      )));
    }

    let disk_parameters = child_element(root, "Disk_Parameters")?;
    let logical_sector_size =
      optional_child_number(disk_parameters, "LogicSectorSize")?.unwrap_or(512);
    if logical_sector_size == 0 {
      return Err(Error::invalid_format(
        "pdi logical sector size must be non-zero".to_string(),
      ));
    }
    let physical_sector_size =
      optional_child_number(disk_parameters, "PhysicalSectorSize")?.unwrap_or(4096);
    if physical_sector_size == 0 {
      return Err(Error::invalid_format(
        "pdi physical sector size must be non-zero".to_string(),
      ));
    }

    let disk_size_sectors = child_number(disk_parameters, "Disk_size")?;
    if disk_size_sectors == 0 {
      return Err(Error::invalid_format(
        "pdi disk size must be non-zero".to_string(),
      ));
    }

    let storage_data = child_element(root, "StorageData")?;
    let storages = child_elements(storage_data, "Storage");
    let mut extents = Vec::with_capacity(storages.len());
    for storage in storages {
      let start_sector = child_number(storage, "Start")?;
      let end_sector = child_number(storage, "End")?;
      let block_size_sectors = child_number(storage, "Blocksize")?;
      if start_sector >= end_sector {
        return Err(Error::invalid_format(format!(
          "pdi storage start sector {} must be smaller than end sector {}",
          start_sector, end_sector
        )));
      }
      if block_size_sectors == 0 {
        return Err(Error::invalid_format(
          "pdi storage block size must be non-zero".to_string(),
        ));
      }

      let image_nodes = child_elements(storage, "Image");
      let mut images = Vec::with_capacity(image_nodes.len());
      for image in image_nodes {
        let file_name = child_text(image, "File")?;
        if file_name.is_empty() {
          return Err(Error::invalid_format(
            "pdi storage image file name must not be empty".to_string(),
          ));
        }
        let snapshot_identifier = normalize_guid(child_text(image, "GUID")?)?;
        let image_type = match child_text(image, "Type")? {
          "Compressed" => PdiDescriptorImageType::Compressed,
          "Plain" => PdiDescriptorImageType::Plain,
          other => {
            return Err(Error::invalid_format(format!(
              "unsupported pdi storage image type: {other}"
            )));
          }
        };

        images.push(PdiDescriptorImage {
          snapshot_identifier,
          image_type,
          file_name: file_name.to_string(),
        });
      }
      if images.is_empty() {
        return Err(Error::invalid_format(
          "pdi storage extents must contain at least one image".to_string(),
        ));
      }

      extents.push(PdiStorageExtent {
        start_sector,
        end_sector,
        block_size_sectors,
        images,
      });
    }
    if extents.is_empty() {
      return Err(Error::invalid_format(
        "pdi descriptor must contain storage extents".to_string(),
      ));
    }

    let snapshots_node = child_element(root, "Snapshots")?;
    let shot_nodes = child_elements(snapshots_node, "Shot");
    let mut snapshots = Vec::with_capacity(shot_nodes.len());
    for shot in shot_nodes {
      let identifier = normalize_guid(child_text(shot, "GUID")?)?;
      let parent_identifier = match normalize_guid(child_text(shot, "ParentGUID")?)? {
        guid if guid == NIL_GUID => None,
        guid => Some(guid),
      };
      snapshots.push(PdiSnapshot {
        identifier,
        parent_identifier,
      });
    }

    Ok(Self {
      disk_size_sectors,
      logical_sector_size,
      physical_sector_size,
      extents,
      snapshots,
    })
  }

  pub fn media_size(&self) -> Result<u64> {
    self
      .disk_size_sectors
      .checked_mul(FIXED_SECTOR_SIZE)
      .ok_or_else(|| Error::invalid_range("pdi media size overflow"))
  }
}

fn child_element<'a, 'input>(parent: Node<'a, 'input>, name: &str) -> Result<Node<'a, 'input>> {
  parent
    .children()
    .find(|child| child.is_element() && child.tag_name().name() == name)
    .ok_or_else(|| Error::invalid_format(format!("pdi descriptor is missing {name}")))
}

fn child_elements<'a, 'input>(parent: Node<'a, 'input>, name: &str) -> Vec<Node<'a, 'input>> {
  parent
    .children()
    .filter(|child| child.is_element() && child.tag_name().name() == name)
    .collect()
}

fn child_text<'a, 'input>(parent: Node<'a, 'input>, name: &str) -> Result<&'a str> {
  let child = child_element(parent, name)?;
  child
    .text()
    .map(str::trim)
    .ok_or_else(|| Error::invalid_format(format!("pdi descriptor {name} is missing text")))
}

fn child_number<T>(parent: Node<'_, '_>, name: &str) -> Result<T>
where
  T: std::str::FromStr,
  T::Err: std::fmt::Display, {
  child_text(parent, name)?
    .parse::<T>()
    .map_err(|error| Error::invalid_format(format!("invalid pdi descriptor {name} value: {error}")))
}

fn optional_child_number<T>(parent: Node<'_, '_>, name: &str) -> Result<Option<T>>
where
  T: std::str::FromStr,
  T::Err: std::fmt::Display, {
  let Some(child) = parent
    .children()
    .find(|child| child.is_element() && child.tag_name().name() == name)
  else {
    return Ok(None);
  };
  let text = child
    .text()
    .map(str::trim)
    .ok_or_else(|| Error::invalid_format(format!("pdi descriptor {name} is missing text")))?;
  text
    .parse::<T>()
    .map(Some)
    .map_err(|error| Error::invalid_format(format!("invalid pdi descriptor {name} value: {error}")))
}

fn normalize_guid(value: &str) -> Result<String> {
  let value = value.trim();
  let value = value
    .strip_prefix('{')
    .and_then(|inner| inner.strip_suffix('}'))
    .unwrap_or(value)
    .to_ascii_lowercase();
  let mut parts = value.split('-');
  let Some(a) = parts.next() else {
    return Err(Error::invalid_format(
      "pdi guid must not be empty".to_string(),
    ));
  };
  let Some(b) = parts.next() else {
    return Err(Error::invalid_format(format!("invalid pdi guid: {value}")));
  };
  let Some(c) = parts.next() else {
    return Err(Error::invalid_format(format!("invalid pdi guid: {value}")));
  };
  let Some(d) = parts.next() else {
    return Err(Error::invalid_format(format!("invalid pdi guid: {value}")));
  };
  let Some(e) = parts.next() else {
    return Err(Error::invalid_format(format!("invalid pdi guid: {value}")));
  };
  if parts.next().is_some()
    || a.len() != 8
    || b.len() != 4
    || c.len() != 4
    || d.len() != 4
    || e.len() != 12
    || !value
      .bytes()
      .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
  {
    return Err(Error::invalid_format(format!("invalid pdi guid: {value}")));
  }
  Ok(value)
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn parses_descriptor_fields() {
    let descriptor = PdiDescriptor::from_xml(
      "<?xml version='1.0' encoding='UTF-8'?><Parallels_disk_image Version=\"1.0\"><Disk_Parameters><Disk_size>8</Disk_size><PhysicalSectorSize>4096</PhysicalSectorSize><LogicSectorSize>512</LogicSectorSize></Disk_Parameters><StorageData><Storage><Start>0</Start><End>8</End><Blocksize>2</Blocksize><Image><GUID>{11111111-1111-1111-1111-111111111111}</GUID><Type>Compressed</Type><File>disk.hds</File></Image></Storage></StorageData><Snapshots><Shot><GUID>{11111111-1111-1111-1111-111111111111}</GUID><ParentGUID>{00000000-0000-0000-0000-000000000000}</ParentGUID></Shot></Snapshots></Parallels_disk_image>",
    )
    .unwrap();

    assert_eq!(descriptor.disk_size_sectors, 8);
    assert_eq!(descriptor.logical_sector_size, 512);
    assert_eq!(descriptor.physical_sector_size, 4096);
    assert_eq!(descriptor.extents.len(), 1);
    assert_eq!(descriptor.snapshots.len(), 1);
    assert_eq!(descriptor.extents[0].images[0].file_name, "disk.hds");
    assert_eq!(
      descriptor.extents[0].images[0].image_type,
      PdiDescriptorImageType::Compressed
    );
  }

  #[test]
  fn rejects_invalid_snapshot_guids() {
    let result = PdiDescriptor::from_xml(
      "<?xml version='1.0' encoding='UTF-8'?><Parallels_disk_image Version=\"1.0\"><Disk_Parameters><Disk_size>8</Disk_size></Disk_Parameters><StorageData><Storage><Start>0</Start><End>8</End><Blocksize>2</Blocksize><Image><GUID>{11111111-1111-1111-1111-111111111111}</GUID><Type>Plain</Type><File>disk.hds</File></Image></Storage></StorageData><Snapshots><Shot><GUID>bad</GUID><ParentGUID>{00000000-0000-0000-0000-000000000000}</ParentGUID></Shot></Snapshots></Parallels_disk_image>",
    );

    assert!(matches!(result, Err(Error::InvalidFormat(_))));
  }
}