mrc0mmand / rpms / libguestfs

Forked from rpms/libguestfs 3 years ago
Clone
Blob Blame History Raw
From 79c0796259820d1ee49bf442392bc3cc5d82322d Mon Sep 17 00:00:00 2001
From: "Richard W.M. Jones" <rjones@redhat.com>
Date: Mon, 10 Apr 2017 15:24:26 +0100
Subject: [PATCH] v2v: Implement -i vmx to read VMware vmx files directly
 (RHBZ#1441197).

This is a mostly complete implementation of a VMX parser and input
class for virt-v2v.  It parses the name, memory size, CPU topology,
firmware, video, sound, hard disks, removable disks and network
interfaces from the VMX file.  It only omits support for floppies and
SCSI CD-ROMs.

The input class is split into two major parts: a generic VMX file
parser (Parse_vmx), and the Input_vmx module which translates the VMX
tree into the source device model.

This also contains tests.  There are simple unit tests of the
Parse_vmx module, and also some more complete parsing tests taken from
real guests.

(cherry picked from commit ca40078cdda9167d4658ddfe24c828c7ee76be37)
---
 v2v/Makefile.am               |  15 ++
 v2v/cmdline.ml                |  12 +-
 v2v/input_vmx.ml              | 349 ++++++++++++++++++++++++++++++++++++++
 v2v/input_vmx.mli             |  22 +++
 v2v/name_from_disk.ml         |   2 +-
 v2v/parse_vmx.ml              | 381 ++++++++++++++++++++++++++++++++++++++++++
 v2v/parse_vmx.mli             |  89 ++++++++++
 v2v/test-v2v-i-vmx-1.expected |  42 +++++
 v2v/test-v2v-i-vmx-1.vmx      | 172 +++++++++++++++++++
 v2v/test-v2v-i-vmx-2.expected |  22 +++
 v2v/test-v2v-i-vmx-2.vmx      |  84 ++++++++++
 v2v/test-v2v-i-vmx-3.expected |  22 +++
 v2v/test-v2v-i-vmx-3.vmx      |  91 ++++++++++
 v2v/test-v2v-i-vmx-4.expected |  22 +++
 v2v/test-v2v-i-vmx-4.vmx      |  88 ++++++++++
 v2v/test-v2v-i-vmx.sh         |  48 ++++++
 v2v/v2v_unit_tests.ml         | 143 ++++++++++++++++
 v2v/virt-v2v.pod              |  72 +++++++-
 18 files changed, 1667 insertions(+), 9 deletions(-)
 create mode 100644 v2v/input_vmx.ml
 create mode 100644 v2v/input_vmx.mli
 create mode 100644 v2v/parse_vmx.ml
 create mode 100644 v2v/parse_vmx.mli
 create mode 100644 v2v/test-v2v-i-vmx-1.expected
 create mode 100644 v2v/test-v2v-i-vmx-1.vmx
 create mode 100644 v2v/test-v2v-i-vmx-2.expected
 create mode 100644 v2v/test-v2v-i-vmx-2.vmx
 create mode 100644 v2v/test-v2v-i-vmx-3.expected
 create mode 100644 v2v/test-v2v-i-vmx-3.vmx
 create mode 100644 v2v/test-v2v-i-vmx-4.expected
 create mode 100644 v2v/test-v2v-i-vmx-4.vmx
 create mode 100755 v2v/test-v2v-i-vmx.sh

diff --git a/v2v/Makefile.am b/v2v/Makefile.am
index 2974064..0df759e 100644
--- a/v2v/Makefile.am
+++ b/v2v/Makefile.am
@@ -38,6 +38,7 @@ SOURCES_MLI = \
 	input_libvirt_xen_ssh.mli \
 	input_libvirtxml.mli \
 	input_ova.mli \
+	input_vmx.mli \
 	inspect_source.mli \
 	libvirt_utils.mli \
 	linux.mli \
@@ -55,6 +56,7 @@ SOURCES_MLI = \
 	OVF.mli \
 	parse_ovf_from_ova.mli \
 	parse_libvirt_xml.mli \
+	parse_vmx.mli \
 	qemu_command.mli \
 	target_bus_assignment.mli \
 	types.mli \
@@ -80,6 +82,7 @@ SOURCES_ML = \
 	windows_virtio.ml \
 	modules_list.ml \
 	input_disk.ml \
+	parse_vmx.ml \
 	parse_libvirt_xml.ml \
 	create_libvirt_xml.ml \
 	qemu_command.ml \
@@ -89,6 +92,7 @@ SOURCES_ML = \
 	input_libvirt_xen_ssh.ml \
 	input_libvirt.ml \
 	input_ova.ml \
+	input_vmx.ml \
 	linux_bootloaders.ml \
 	linux_kernels.ml \
 	convert_linux.ml \
@@ -268,6 +272,7 @@ TESTS = \
 	test-v2v-i-ova-subfolders.sh \
 	test-v2v-i-ova-tar.sh \
 	test-v2v-i-ova-two-disks.sh \
+	test-v2v-i-vmx.sh \
 	test-v2v-bad-networks-and-bridges.sh
 
 if HAVE_LIBVIRT
@@ -411,6 +416,15 @@ EXTRA_DIST += \
 	test-v2v-i-ova.ovf \
 	test-v2v-i-ova.sh \
 	test-v2v-i-ova.xml \
+	test-v2v-i-vmx.sh \
+	test-v2v-i-vmx-1.expected \
+	test-v2v-i-vmx-2.expected \
+	test-v2v-i-vmx-3.expected \
+	test-v2v-i-vmx-4.expected \
+	test-v2v-i-vmx-1.vmx \
+	test-v2v-i-vmx-2.vmx \
+	test-v2v-i-vmx-3.vmx \
+	test-v2v-i-vmx-4.vmx \
 	test-v2v-machine-readable.sh \
 	test-v2v-networks-and-bridges-expected.xml \
 	test-v2v-networks-and-bridges.sh \
@@ -450,6 +464,7 @@ v2v_unit_tests_BOBJECTS = \
 	windows.cmo \
 	windows_virtio.cmo \
 	linux.cmo \
+	parse_vmx.cmo \
 	v2v_unit_tests.cmo
 v2v_unit_tests_XOBJECTS = $(v2v_unit_tests_BOBJECTS:.cmo=.cmx)
 
diff --git a/v2v/cmdline.ml b/v2v/cmdline.ml
index a2f132b..0850f91 100644
--- a/v2v/cmdline.ml
+++ b/v2v/cmdline.ml
@@ -86,6 +86,7 @@ let parse_cmdline () =
     | "libvirt" -> input_mode := `Libvirt
     | "libvirtxml" -> input_mode := `LibvirtXML
     | "ova" -> input_mode := `OVA
+    | "vmx" -> input_mode := `VMX
     | s ->
       error (f_"unknown -i option: %s") s
   in
@@ -331,7 +332,16 @@ read the man page virt-v2v(1).
         | [filename] -> filename
         | _ ->
           error (f_"expecting an OVA file name on the command line") in
-      Input_ova.input_ova filename in
+      Input_ova.input_ova filename
+
+    | `VMX ->
+      (* -i vmx: Expecting an vmx filename. *)
+      let filename =
+        match args with
+        | [filename] -> filename
+        | _ ->
+          error (f_"expecting a VMX file name on the command line") in
+      Input_vmx.input_vmx filename in
 
   (* Prevent use of --in-place option in RHEL. *)
   if in_place then
diff --git a/v2v/input_vmx.ml b/v2v/input_vmx.ml
new file mode 100644
index 0000000..bb09f0b
--- /dev/null
+++ b/v2v/input_vmx.ml
@@ -0,0 +1,349 @@
+(* virt-v2v
+ * Copyright (C) 2017 Red Hat Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ *)
+
+open Printf
+open Scanf
+
+open Common_gettext.Gettext
+open Common_utils
+
+open Types
+open Utils
+open Name_from_disk
+
+external identity : 'a -> 'a = "%identity"
+
+let rec find_disks vmx vmx_filename =
+  find_scsi_disks vmx vmx_filename @ find_ide_disks vmx vmx_filename
+
+(* Find all SCSI hard disks.
+ *
+ * In the VMX file:
+ *   scsi0.virtualDev = "pvscsi"  # or may be "lsilogic" etc.
+ *   scsi0:0.deviceType = "scsi-hardDisk"
+ *   scsi0:0.fileName = "guest.vmdk"
+ *)
+and find_scsi_disks vmx vmx_filename =
+  let get_scsi_controller_target ns =
+    sscanf ns "scsi%d:%d" (fun c t -> c, t)
+  in
+  let is_scsi_controller_target ns =
+    try ignore (get_scsi_controller_target ns); true
+    with Scanf.Scan_failure _ | End_of_file | Failure _ -> false
+  in
+  let scsi_device_types = [ "scsi-harddisk" ] in
+  let scsi_controller = Source_SCSI in
+
+  find_hdds vmx vmx_filename
+            get_scsi_controller_target is_scsi_controller_target
+            scsi_device_types scsi_controller
+
+(* Find all IDE hard disks.
+ *
+ * In the VMX file:
+ *   ide0:0.deviceType = "ata-hardDisk"
+ *   ide0:0.fileName = "guest.vmdk"
+ *)
+and find_ide_disks vmx vmx_filename =
+  let get_ide_controller_target ns =
+    sscanf ns "ide%d:%d" (fun c t -> c, t)
+  in
+  let is_ide_controller_target ns =
+    try ignore (get_ide_controller_target ns); true
+    with Scanf.Scan_failure _ | End_of_file | Failure _ -> false
+  in
+  let ide_device_types = [ "ata-harddisk" ] in
+  let ide_controller = Source_IDE in
+
+  find_hdds vmx vmx_filename
+            get_ide_controller_target is_ide_controller_target
+            ide_device_types ide_controller
+
+and find_hdds vmx vmx_filename
+              get_controller_target is_controller_target
+              device_types controller =
+  (* Find namespaces matching '(ide|scsi)X:Y' with suitable deviceType. *)
+  let hdds =
+    Parse_vmx.select_namespaces (
+      function
+      | [ns] ->
+         (* Check the namespace is '(ide|scsi)X:Y' *)
+         if not (is_controller_target ns) then false
+         else (
+           (* Check the deviceType is one we are looking for. *)
+           match Parse_vmx.get_string vmx [ns; "deviceType"] with
+           | Some str ->
+              let str = String.lowercase_ascii str in
+              List.mem str device_types
+           | None -> false
+         )
+      | _ -> false
+    ) vmx in
+
+  (* Map the subset to a list of disks. *)
+  let hdds =
+    Parse_vmx.map (
+      fun path v ->
+        match path, v with
+        | [ns; "filename"], Some filename ->
+           let c, t = get_controller_target ns in
+           let s = { s_disk_id = (-1);
+                     s_qemu_uri = qemu_uri_of_filename vmx_filename filename;
+                     s_format = Some "vmdk";
+                     s_controller = Some controller } in
+           Some (c, t, s)
+        | _ -> None
+    ) hdds in
+  let hdds = filter_map identity hdds in
+
+  (* We don't have a way to return the controllers and targets, so
+   * just make sure the disks are sorted into order, since Parse_vmx
+   * won't return them in any particular order.
+   *)
+  let hdds = List.sort compare hdds in
+  let hdds = List.map (fun (_, _, source) -> source) hdds in
+
+  (* Set the s_disk_id field to an incrementing number. *)
+  let hdds = mapi (fun i source -> { source with s_disk_id = i }) hdds in
+
+  hdds
+
+(* The filename can be an absolute path, but is more often a
+ * path relative to the location of the vmx file.
+ *
+ * Note that we always end up with an absolute path, which is
+ * also useful because it means we won't have any paths that
+ * could be misinterpreted by qemu.
+ *)
+and qemu_uri_of_filename vmx_filename filename =
+  if not (Filename.is_relative filename) then
+    filename
+  else (
+    let dir = Filename.dirname (absolute_path vmx_filename) in
+    dir // filename
+  )
+
+(* Find all removable disks.
+ *
+ * In the VMX file:
+ *   ide1:0.deviceType = "cdrom-image"
+ *   ide1:0.fileName = "boot.iso"
+ *
+ * XXX This only supports IDE CD-ROMs, but we could support SCSI
+ * CD-ROMs and floppies in future.
+ *)
+and find_removables vmx =
+  let get_ide_controller_target ns =
+    sscanf ns "ide%d:%d" (fun c t -> c, t)
+  in
+  let is_ide_controller_target ns =
+    try ignore (get_ide_controller_target ns); true
+    with Scanf.Scan_failure _ | End_of_file | Failure _ -> false
+  in
+  let device_types = [ "atapi-cdrom";
+                       "cdrom-image"; "cdrom-raw" ] in
+
+  (* Find namespaces matching 'ideX:Y' with suitable deviceType. *)
+  let devs =
+    Parse_vmx.select_namespaces (
+      function
+      | [ns] ->
+         (* Check the namespace is 'ideX:Y' *)
+         if not (is_ide_controller_target ns) then false
+         else (
+           (* Check the deviceType is one we are looking for. *)
+           match Parse_vmx.get_string vmx [ns; "deviceType"] with
+           | Some str ->
+              let str = String.lowercase_ascii str in
+              List.mem str device_types
+           | None -> false
+         )
+      | _ -> false
+    ) vmx in
+
+  (* Map the subset to a list of CD-ROMs. *)
+  let devs =
+    Parse_vmx.map (
+      fun path v ->
+        match path, v with
+        | [ns], None ->
+           let c, t = get_ide_controller_target ns in
+           let s = { s_removable_type = CDROM;
+                     s_removable_controller = Some Source_IDE;
+                     s_removable_slot = Some (ide_slot c t) } in
+           Some s
+        | _ -> None
+    ) devs in
+  let devs = filter_map identity devs in
+
+  (* Sort by slot. *)
+  let devs =
+    List.sort
+      (fun { s_removable_slot = s1 } { s_removable_slot = s2 } ->
+        compare s1 s2)
+      devs in
+
+  devs
+
+and ide_slot c t =
+  (* Assuming the old master/slave arrangement. *)
+  c * 2 + t
+
+(* Find all ethernet cards.
+ *
+ * In the VMX file:
+ *   ethernet0.virtualDev = "vmxnet3"
+ *   ethernet0.networkName = "VM Network"
+ *   ethernet0.generatedAddress = "00:01:02:03:04:05"
+ *   ethernet0.connectionType = "bridged" # also: "custom", "nat" or not present
+ *)
+and find_nics vmx =
+  let get_ethernet_port ns =
+    sscanf ns "ethernet%d" (fun p -> p)
+  in
+  let is_ethernet_port ns =
+    try ignore (get_ethernet_port ns); true
+    with Scanf.Scan_failure _ | End_of_file | Failure _ -> false
+  in
+
+  (* Find namespaces matching 'ethernetX'. *)
+  let nics =
+    Parse_vmx.select_namespaces (
+      function
+      | [ns] -> is_ethernet_port ns
+      | _ -> false
+    ) vmx in
+
+  (* Map the subset to a list of NICs. *)
+  let nics =
+    Parse_vmx.map (
+      fun path v ->
+        match path, v with
+        | [ns], None ->
+           let port = get_ethernet_port ns in
+           let mac = Parse_vmx.get_string vmx [ns; "generatedAddress"] in
+           let model = Parse_vmx.get_string vmx [ns; "virtualDev"] in
+           let model =
+             match model with
+             | Some m when String.lowercase_ascii m = "e1000" ->
+                Some Source_e1000
+             | Some model ->
+                Some (Source_other_nic (String.lowercase_ascii model))
+             | None -> None in
+           let vnet = Parse_vmx.get_string vmx [ns; "networkName"] in
+           let vnet =
+             match vnet with
+             | Some vnet -> vnet
+             | None -> ns (* "ethernetX" *) in
+           let vnet_type =
+             match Parse_vmx.get_string vmx [ns; "connectionType"] with
+             | Some b when String.lowercase_ascii b = "bridged" ->
+                Bridge
+             | Some _ | None -> Network in
+           Some (port,
+                 { s_mac = mac; s_nic_model = model;
+                   s_vnet = vnet; s_vnet_orig = vnet;
+                   s_vnet_type = vnet_type })
+        | _ -> None
+    ) nics in
+  let nics = filter_map identity nics in
+
+  (* Sort by port. *)
+  let nics = List.sort compare nics in
+
+  let nics = List.map (fun (_, source) -> source) nics in
+  nics
+
+class input_vmx vmx_filename = object
+  inherit input
+
+  method as_options = "-i vmx " ^ vmx_filename
+
+  method source () =
+    (* Parse the VMX file. *)
+    let vmx = Parse_vmx.parse_file vmx_filename in
+
+    let name =
+      match Parse_vmx.get_string vmx ["displayName"] with
+      | None ->
+         warning (f_"no displayName key found in VMX file");
+         name_from_disk vmx_filename
+      | Some s -> s in
+
+    let memory_mb =
+      match Parse_vmx.get_int64 vmx ["memSize"] with
+      | None -> 32_L            (* default is really 32 MB! *)
+      | Some i -> i in
+    let memory = memory_mb *^ 1024L *^ 1024L in
+
+    let vcpu =
+      match Parse_vmx.get_int vmx ["numvcpus"] with
+      | None -> 1
+      | Some i -> i in
+
+    let firmware =
+      match Parse_vmx.get_string vmx ["firmware"] with
+      | None -> BIOS
+      | Some "efi" -> UEFI
+      (* Other values are not documented for this field ... *)
+      | Some fw ->
+         warning (f_"unknown firmware value '%s', assuming BIOS") fw;
+         BIOS in
+
+    let video =
+      if Parse_vmx.namespace_present vmx ["svga"] then
+        (* We could also parse svga.vramSize. *)
+        Some (Source_other_video "vmvga")
+      else
+        None in
+
+    let sound =
+      match Parse_vmx.get_string vmx ["sound"; "virtualDev"] with
+      | Some ("sb16") -> Some { s_sound_model = SB16 }
+      | Some ("es1371") -> Some { s_sound_model = ES1370 (* hmmm ... *) }
+      | Some "hdaudio" -> Some { s_sound_model = ICH6 (* intel-hda *) }
+      | Some model ->
+         warning (f_"unknown sound device '%s' ignored") model;
+         None
+      | None -> None in
+
+    let disks = find_disks vmx vmx_filename in
+    let removables = find_removables vmx in
+    let nics = find_nics vmx in
+
+    let source = {
+      s_hypervisor = VMware;
+      s_name = name;
+      s_orig_name = name;
+      s_memory = memory;
+      s_vcpu = vcpu;
+      s_features = [];
+      s_firmware = firmware;
+      s_display = None;
+      s_video = video;
+      s_sound = sound;
+      s_disks = disks;
+      s_removables = removables;
+      s_nics = nics;
+    } in
+
+    source
+end
+
+let input_vmx = new input_vmx
+let () = Modules_list.register_input_module "vmx"
diff --git a/v2v/input_vmx.mli b/v2v/input_vmx.mli
new file mode 100644
index 0000000..f236f87
--- /dev/null
+++ b/v2v/input_vmx.mli
@@ -0,0 +1,22 @@
+(* virt-v2v
+ * Copyright (C) 2017 Red Hat Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ *)
+
+(** [-i vmx] source. *)
+
+val input_vmx : string -> Types.input
+(** [input_vmx filename] sets up an input from vmware vmx file. *)
diff --git a/v2v/name_from_disk.ml b/v2v/name_from_disk.ml
index 82f0925..452d946 100644
--- a/v2v/name_from_disk.ml
+++ b/v2v/name_from_disk.ml
@@ -24,7 +24,7 @@ let name_from_disk disk =
   (* Remove the extension (or suffix), only if it's one usually
    * used for disk images. *)
   let suffixes = [
-    ".img"; ".ova"; ".qcow2"; ".raw"; ".vmdk";
+    ".img"; ".ova"; ".qcow2"; ".raw"; ".vmdk"; ".vmx";
     "-sda";
   ] in
   let rec loop = function
diff --git a/v2v/parse_vmx.ml b/v2v/parse_vmx.ml
new file mode 100644
index 0000000..33ec17d
--- /dev/null
+++ b/v2v/parse_vmx.ml
@@ -0,0 +1,381 @@
+(* virt-v2v
+ * Copyright (C) 2017 Red Hat Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ *)
+
+open Printf
+
+open Common_utils
+open Common_gettext.Gettext
+
+(* As far as I can tell the VMX format is totally unspecified.
+ * However libvirt has a useful selection of .vmx files in the
+ * sources which explore some of the darker regions of this
+ * format.
+ *
+ * So here are some facts about VMX derived from libvirt and
+ * other places:
+ *
+ * - Keys are compared case insensitively.  We assume here
+ *   that keys are 7-bit ASCII.
+ *
+ * - Multiple keys with the same name are not allowed.
+ *
+ * - Escaping in the value string is possible using a very weird
+ *   escape format: "|22" means the character '\x22'.  To write
+ *   a pipe character you must use "|7C".
+ *
+ * - Boolean values are written "TRUE", "FALSE", "True", "true", etc.
+ *   Because of the quotes they cannot be distinguished from strings.
+ *
+ * - Comments (#...) and blank lines are ignored.  Some files start
+ *   with a hash-bang path, but we ignore those as comments.  This
+ *   parser also ignores any other line which it doesn't understand,
+ *   but will print a warning.
+ *
+ * - Multi-line values are not permitted.
+ *
+ * - Keys are namespaced using dots, eg. scsi0:0.deviceType has
+ *   the namespace "scsi0:0" and the key name "deviceType".
+ *
+ * - Using namespace.present = "FALSE" means that all other keys
+ *   in and under the namespace are ignored.
+ *
+ * - You cannot have a namespace and a key with the same name, eg.
+ *   this is not allowed:
+ *     namespace = "some value"
+ *     namespace.foo = "another value"
+ *
+ * - The Hashicorp packer VMX writer considers some special keys
+ *   as not requiring any quotes around their values, but I'm
+ *   ignoring that for now.
+ *)
+
+(* This VMX file:
+ *
+ *   foo.a = "abc"
+ *   foo.b = "def"
+ *   foo.bar.c = "abc"
+ *   foo.bar.d = "def"
+ *
+ * would be represented by this structure:
+ *
+ *   "foo" => Namespace (             # "foo" is a namespace
+ *              "a" => Key "abc";     # "foo.a" is a key with value "abc"
+ *              "b" => Key "def";
+ *              "bar" => Namespace (  # "foo.bar" is another namespace
+ *                         "c" => Key "abc";
+ *                         "d" => Key "def";
+ *                       )
+ *            )
+ *   ‘( => )’s represent the StringMap type.
+ *)
+type t = key StringMap.t
+
+and key =
+  | Key of string
+  | Namespace of t
+
+let empty = StringMap.empty
+
+(* Compare two trees for equality. *)
+let rec equal vmx1 vmx2 =
+  let cmp k1 k2 =
+    match k1, k2 with
+    | Key v1, Key v2 -> v1 = v2
+    | Key _, Namespace _ -> false
+    | Namespace _, Key _ -> false
+    | Namespace vmx1, Namespace vmx2 -> equal vmx1 vmx2
+  in
+  StringMap.equal cmp vmx1 vmx2
+
+(* Higher-order functions. *)
+let rec select_namespaces pred vmx =
+  _select_namespaces [] pred vmx
+
+and _select_namespaces path pred vmx =
+  StringMap.fold (
+    fun k v new_vmx ->
+      let path = path @ [k] in
+      match v with
+      | Key _ -> new_vmx
+      | Namespace _ when pred path ->
+         StringMap.add k v new_vmx
+      | Namespace t ->
+         let t = _select_namespaces path pred t in
+         if not (equal t empty) then
+           StringMap.add k (Namespace t) new_vmx
+         else
+           new_vmx
+  ) vmx empty
+
+let rec map f vmx =
+  _map [] f vmx
+
+and _map path f vmx =
+  StringMap.fold (
+    fun k v r ->
+      let path = path @ [k] in
+      match v with
+      | Key v -> r @ [ f path (Some v) ]
+      | Namespace t -> r @ [ f path None ] @ _map path f t
+  ) vmx []
+
+let rec namespace_present vmx = function
+  | [] -> false
+  | [ns] ->
+     let ns = String.lowercase_ascii ns in
+     (try
+        let v = StringMap.find ns vmx in
+        match v with
+        | Key _ -> false
+        | Namespace _ -> true
+      with
+        Not_found -> false
+     )
+  | ns :: path ->
+     let ns = String.lowercase_ascii ns in
+     (try
+        let v = StringMap.find ns vmx in
+        match v with
+        | Key _ -> false
+        | Namespace vmx -> namespace_present vmx path
+      with
+        Not_found -> false
+     )
+
+(* Dump the vmx structure to [chan].  Used for debugging. *)
+let rec print chan indent vmx =
+  StringMap.iter (print_key chan indent) vmx
+
+and print_key chan indent k = function
+  | Key v ->
+     output_spaces chan indent;
+     fprintf chan "%s = \"%s\"\n" k v
+  | Namespace vmx ->
+     output_spaces chan indent;
+     fprintf chan "namespace '%s':\n" k;
+     print chan (indent+4) vmx
+
+(* As above, but creates a string instead. *)
+let rec to_string indent vmx =
+  StringMap.fold (fun k v str -> str ^ to_string_key indent k v) vmx ""
+
+and to_string_key indent k = function
+  | Key v ->
+     String.spaces indent ^ sprintf "%s = \"%s\"\n" k v
+  | Namespace vmx ->
+     String.spaces indent ^ sprintf "namespace '%s':\n" k ^
+       to_string (indent+4) vmx
+
+(* Access keys in the tree. *)
+let rec get_string vmx = function
+  | [] -> None
+  | [k] ->
+     let k = String.lowercase_ascii k in
+     (try
+        let v = StringMap.find k vmx in
+        match v with
+        | Key v -> Some v
+        | Namespace _ -> None
+      with Not_found -> None
+     )
+  | ns :: path ->
+     let ns = String.lowercase_ascii ns in
+     (try
+        let v = StringMap.find ns vmx in
+        match v with
+        | Key v -> None
+        | Namespace vmx -> get_string vmx path
+      with
+        Not_found -> None
+     )
+
+let get_int64 vmx path =
+  match get_string vmx path with
+  | None -> None
+  | Some i -> Some (Int64.of_string i)
+
+let get_int vmx path =
+  match get_string vmx path with
+  | None -> None
+  | Some i -> Some (int_of_string i)
+
+let rec get_bool vmx path =
+  match get_string vmx path with
+  | None -> None
+  | Some t -> Some (vmx_bool_of_string t)
+
+and vmx_bool_of_string t =
+  if String.lowercase_ascii t = "true" then true
+  else if String.lowercase_ascii t = "false" then false
+  else failwith "bool_of_string"
+
+(* Regular expression used to match key = "value" in VMX file. *)
+let rex = Str.regexp "^\\([^ \t=]+\\)[ \t]*=[ \t]*\"\\(.*\\)\"$"
+
+(* Remove the weird escapes used in value strings.  See description above. *)
+let remove_vmx_escapes str =
+  let len = String.length str in
+  let out = Bytes.make len '\000' in
+  let j = ref 0 in
+
+  let rec loop i =
+    if i >= len then ()
+    else (
+      let c = String.unsafe_get str i in
+      if i <= len-3 && c = '|' then (
+        let c1 = str.[i+1] and c2 = str.[i+2] in
+        if Char.isxdigit c1 && Char.isxdigit c2 then (
+          let x = Char.hexdigit c1 * 0x10 + Char.hexdigit c2 in
+          Bytes.set out !j (Char.chr x);
+          incr j;
+          loop (i+3)
+        )
+        else (
+          Bytes.set out !j c;
+          incr j;
+          loop (i+1)
+        )
+      )
+      else (
+        Bytes.set out !j c;
+        incr j;
+        loop (i+1)
+      )
+    )
+  in
+  loop 0;
+
+  (* Truncate the output string to its real size and return it
+   * as an immutable string.
+   *)
+  Bytes.sub_string out 0 !j
+
+(* Parsing. *)
+let rec parse_file vmx_filename =
+  (* Read the whole file as a list of lines. *)
+  let str = read_whole_file vmx_filename in
+  if verbose () then eprintf "VMX file:\n%s\n" str;
+  parse_string str
+
+and parse_string str =
+  let lines = String.nsplit "\n" str in
+
+  (* I've never seen any VMX file with CR-LF endings, and VMware
+   * itself is Linux-based, but to be on the safe side ...
+   *)
+  let lines = List.map (String.trimr ~test:((=) '\r')) lines in
+
+  (* Ignore blank lines and comments. *)
+  let lines = List.filter (
+    fun line ->
+      let line = String.triml line in
+      let len = String.length line in
+      len > 0 && line.[0] != '#'
+  ) lines in
+
+  (* Parse the lines into key = "value". *)
+  let lines = filter_map (
+    fun line ->
+      if Str.string_match rex line 0 then (
+        let key = Str.matched_group 1 line in
+        let key = String.lowercase_ascii key in
+        let value = Str.matched_group 2 line in
+        let value = remove_vmx_escapes value in
+        Some (key, value)
+      )
+      else (
+        warning (f_"vmx parser: cannot parse this line, ignoring: %s") line;
+        None
+      )
+  ) lines in
+
+  (* Split the keys into namespace paths. *)
+  let lines =
+    List.map (fun (key, value) -> String.nsplit "." key, value) lines in
+
+  (* Build a tree from the flat list and return it.  This is horribly
+   * inefficient, at least O(n²), possibly even O(n².log n).  Hope
+   * there are no large VMX files!  (XXX)
+   *)
+  let vmx =
+    List.fold_left (
+      fun vmx (path, value) -> insert vmx value path
+    ) empty lines in
+
+  (* If we're verbose, dump the parsed VMX for debugging purposes. *)
+  if verbose () then (
+    eprintf "parsed VMX tree:\n";
+    print stderr 0 vmx
+  );
+
+  (* Drop all present = "FALSE" namespaces. *)
+  let vmx = drop_not_present vmx in
+
+  vmx
+
+and insert vmx value = function
+  | [] -> assert false
+  | [k] ->
+     if StringMap.mem k vmx then (
+       warning (f_"vmx parser: duplicate key '%s' ignored") k;
+       vmx
+     ) else
+       StringMap.add k (Key value) vmx
+  | ns :: path ->
+     let v =
+       try
+         (match StringMap.find ns vmx with
+          | Namespace vmx -> Some vmx
+          | Key _ -> None
+         )
+       with Not_found -> None in
+     let v =
+       match v with
+       | None ->
+          (* Completely new namespace. *)
+          insert empty value path
+       | Some v ->
+          (* Insert the subkey into the previously created namespace. *)
+          insert v value path in
+     StringMap.add ns (Namespace v) vmx
+
+(* Find any "present" keys.  If we find present = "FALSE", then
+ * drop the containing namespace and all subkeys and subnamespaces.
+ *)
+and drop_not_present vmx =
+  StringMap.fold (
+    fun k v new_vmx ->
+      match v with
+      | Key _ ->
+         StringMap.add k v new_vmx
+      | Namespace vmx when contains_key_present_false vmx ->
+         (* drop this namespace and all sub-spaces *)
+         new_vmx
+      | Namespace v ->
+         (* recurse into sub-namespace and do the same check *)
+         let v = drop_not_present v in
+         StringMap.add k (Namespace v) new_vmx
+  ) vmx empty
+
+and contains_key_present_false vmx =
+  try
+    match StringMap.find "present" vmx with
+    | Key v when vmx_bool_of_string v = false -> true
+    | Key _ | Namespace _ -> false
+  with
+    Failure _ | Not_found -> false
diff --git a/v2v/parse_vmx.mli b/v2v/parse_vmx.mli
new file mode 100644
index 0000000..0e4f21f
--- /dev/null
+++ b/v2v/parse_vmx.mli
@@ -0,0 +1,89 @@
+(* virt-v2v
+ * Copyright (C) 2017 Red Hat Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ *)
+
+(** A simple parser for VMware [.vmx] files. *)
+
+type t
+
+val parse_file : string -> t
+(** [parse_file filename] parses a VMX file. *)
+
+val parse_string : string -> t
+(** [parse_string s] parses VMX from a string. *)
+
+val get_string : t -> string list -> string option
+(** Find a key and return it as a string.  If not present, returns [None].
+
+    Note that if [namespace.present = "FALSE"] is found in the file
+    then all keys in [namespace] and below it are ignored.  This
+    applies to all [get_*] functions. *)
+
+val get_int64 : t -> string list -> int64 option
+(** Find a key and return it as an [int64].
+    If not present, returns [None].
+
+    Raises [Failure _] if the key is present but was not parseable
+    as an integer. *)
+
+val get_int : t -> string list -> int option
+(** Find a key and return it as an [int].
+    If not present, returns [None].
+
+    Raises [Failure _] if the key is present but was not parseable
+    as an integer. *)
+
+val get_bool : t -> string list -> bool option
+(** Find a key and return it as a boolean.
+
+    You cannot return [namespace.present = "FALSE"] booleans this way.
+    They are processed by the parser and the namespace and anything
+    below it are removed from the tree.
+
+    Raises [Failure _] if the key is present but was not parseable
+    as a boolean. *)
+
+val namespace_present : t -> string list -> bool
+(** Returns true iff the namespace ({b note:} not key) is present. *)
+
+val select_namespaces : (string list -> bool) -> t -> t
+(** Filter the VMX file, selecting exactly namespaces (and their
+    keys) matching the predicate.  The predicate is a function which
+    is called on each {i namespace} path ({b note:} not on
+    namespace + key paths).  If the predicate matches a
+    namespace, then all sub-namespaces under that namespace are
+    selected implicitly. *)
+
+val map : (string list -> string option -> 'a) -> t -> 'a list
+(** Map all the entries in the VMX file into a list using the
+    map function.  The map function takes two arguments.  The
+    first is the path to the namespace or key, and the second
+    is the key value (or [None] if the path refers to a namespace). *)
+
+val equal : t -> t -> bool
+(** Compare two VMX files for equality.  This is mainly used for
+    testing the parser. *)
+
+val empty : t
+(** An empty VMX file. *)
+
+val print : out_channel -> int -> t -> unit
+(** [print chan indent] prints the VMX file to the output channel.
+    [indent] is the indentation applied to each line of output. *)
+
+val to_string : int -> t -> string
+(** Same as {!print} but it creates a printable (multiline) string. *)
diff --git a/v2v/test-v2v-i-vmx-1.expected b/v2v/test-v2v-i-vmx-1.expected
new file mode 100644
index 0000000..d32a299
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-1.expected
@@ -0,0 +1,42 @@
+[   0.0] Opening the source -i vmx test-v2v-i-vmx-1.vmx
+Source guest information (--print-source option):
+
+    source name: BZ1308535_21disks
+hypervisor type: vmware
+         memory: 2147483648 (bytes)
+       nr vCPUs: 1
+     CPU vendor: 
+      CPU model: 
+   CPU topology: sockets: - cores/socket: - threads/core: -
+   CPU features: 
+       firmware: bios
+        display: 
+          video: vmvga
+          sound: 
+disks:
+	/BZ1308535_21disks.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_1.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_2.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_3.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_4.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_5.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_6.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_7.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_8.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_9.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_10.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_11.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_12.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_13.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_14.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_15.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_16.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_17.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_18.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_19.vmdk (vmdk) [scsi]
+	/BZ1308535_21disks_20.vmdk (vmdk) [scsi]
+removable media:
+	CD-ROM [ide] in slot 2
+NICs:
+	Network "VM Network" mac: 00:0c:29:36:ef:31 [vmxnet3]
+
diff --git a/v2v/test-v2v-i-vmx-1.vmx b/v2v/test-v2v-i-vmx-1.vmx
new file mode 100644
index 0000000..3f2f060
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-1.vmx
@@ -0,0 +1,172 @@
+.encoding = "UTF-8"
+config.version = "8"
+virtualHW.version = "8"
+nvram = "BZ1308535_21disks.nvram"
+pciBridge0.present = "TRUE"
+svga.present = "TRUE"
+pciBridge4.present = "TRUE"
+pciBridge4.virtualDev = "pcieRootPort"
+pciBridge4.functions = "8"
+pciBridge5.present = "TRUE"
+pciBridge5.virtualDev = "pcieRootPort"
+pciBridge5.functions = "8"
+pciBridge6.present = "TRUE"
+pciBridge6.virtualDev = "pcieRootPort"
+pciBridge6.functions = "8"
+pciBridge7.present = "TRUE"
+pciBridge7.virtualDev = "pcieRootPort"
+pciBridge7.functions = "8"
+vmci0.present = "TRUE"
+hpet0.present = "TRUE"
+displayName = "BZ1308535_21disks"
+extendedConfigFile = "BZ1308535_21disks.vmxf"
+virtualHW.productCompatibility = "hosted"
+memSize = "2048"
+sched.cpu.units = "mhz"
+powerType.powerOff = "soft"
+powerType.suspend = "hard"
+powerType.reset = "soft"
+scsi0.virtualDev = "pvscsi"
+scsi0.present = "TRUE"
+scsi1.virtualDev = "pvscsi"
+scsi1.present = "TRUE"
+ide1:0.deviceType = "cdrom-image"
+ide1:0.fileName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/ISOs/RHEL-7.1-20150219.1-Server-x86_64-boot.iso"
+ide1:0.present = "TRUE"
+floppy0.startConnected = "FALSE"
+floppy0.clientDevice = "TRUE"
+floppy0.fileName = "vmware-null-remote-floppy"
+ethernet0.virtualDev = "vmxnet3"
+ethernet0.networkName = "VM Network"
+ethernet0.addressType = "generated"
+ethernet0.present = "TRUE"
+scsi0:0.deviceType = "scsi-hardDisk"
+scsi0:0.fileName = "BZ1308535_21disks.vmdk"
+scsi0:0.present = "TRUE"
+scsi0:1.deviceType = "scsi-hardDisk"
+scsi0:1.fileName = "BZ1308535_21disks_1.vmdk"
+scsi0:1.present = "TRUE"
+scsi0:2.deviceType = "scsi-hardDisk"
+scsi0:2.fileName = "BZ1308535_21disks_2.vmdk"
+scsi0:2.present = "TRUE"
+scsi0:3.deviceType = "scsi-hardDisk"
+scsi0:3.fileName = "BZ1308535_21disks_3.vmdk"
+scsi0:3.present = "TRUE"
+scsi0:4.deviceType = "scsi-hardDisk"
+scsi0:4.fileName = "BZ1308535_21disks_4.vmdk"
+scsi0:4.present = "TRUE"
+scsi0:5.deviceType = "scsi-hardDisk"
+scsi0:5.fileName = "BZ1308535_21disks_5.vmdk"
+scsi0:5.present = "TRUE"
+scsi0:6.deviceType = "scsi-hardDisk"
+scsi0:6.fileName = "BZ1308535_21disks_6.vmdk"
+scsi0:6.present = "TRUE"
+scsi0:8.deviceType = "scsi-hardDisk"
+scsi0:8.fileName = "BZ1308535_21disks_7.vmdk"
+scsi0:8.present = "TRUE"
+scsi0:9.deviceType = "scsi-hardDisk"
+scsi0:9.fileName = "BZ1308535_21disks_8.vmdk"
+scsi0:9.present = "TRUE"
+scsi0:10.deviceType = "scsi-hardDisk"
+scsi0:10.fileName = "BZ1308535_21disks_9.vmdk"
+scsi0:10.present = "TRUE"
+scsi0:11.deviceType = "scsi-hardDisk"
+scsi0:11.fileName = "BZ1308535_21disks_10.vmdk"
+scsi0:11.present = "TRUE"
+scsi0:12.deviceType = "scsi-hardDisk"
+scsi0:12.fileName = "BZ1308535_21disks_11.vmdk"
+scsi0:12.present = "TRUE"
+scsi0:13.deviceType = "scsi-hardDisk"
+scsi0:13.fileName = "BZ1308535_21disks_12.vmdk"
+scsi0:13.present = "TRUE"
+scsi0:14.deviceType = "scsi-hardDisk"
+scsi0:14.fileName = "BZ1308535_21disks_13.vmdk"
+scsi0:14.present = "TRUE"
+scsi0:15.deviceType = "scsi-hardDisk"
+scsi0:15.fileName = "BZ1308535_21disks_14.vmdk"
+scsi0:15.present = "TRUE"
+scsi1:0.deviceType = "scsi-hardDisk"
+scsi1:0.fileName = "BZ1308535_21disks_15.vmdk"
+scsi1:0.present = "TRUE"
+scsi1:1.deviceType = "scsi-hardDisk"
+scsi1:1.fileName = "BZ1308535_21disks_16.vmdk"
+scsi1:1.present = "TRUE"
+scsi1:2.deviceType = "scsi-hardDisk"
+scsi1:2.fileName = "BZ1308535_21disks_17.vmdk"
+scsi1:2.present = "TRUE"
+scsi1:3.deviceType = "scsi-hardDisk"
+scsi1:3.fileName = "BZ1308535_21disks_18.vmdk"
+scsi1:3.present = "TRUE"
+scsi1:4.deviceType = "scsi-hardDisk"
+scsi1:4.fileName = "BZ1308535_21disks_19.vmdk"
+scsi1:4.present = "TRUE"
+scsi1:5.deviceType = "scsi-hardDisk"
+scsi1:5.fileName = "BZ1308535_21disks_20.vmdk"
+scsi1:5.present = "TRUE"
+guestOS = "rhel6-64"
+toolScripts.afterPowerOn = "TRUE"
+toolScripts.afterResume = "TRUE"
+toolScripts.beforeSuspend = "TRUE"
+toolScripts.beforePowerOff = "TRUE"
+uuid.bios = "56 4d 96 af e6 46 bd 86-5c 4d 65 4e 77 36 ef 31"
+uuid.location = "56 4d 96 af e6 46 bd 86-5c 4d 65 4e 77 36 ef 31"
+vc.uuid = "52 31 cb fc c1 3f 96 32-83 c0 bb 70 6c 90 5c fd"
+chipset.onlineStandby = "FALSE"
+sched.cpu.min = "0"
+sched.cpu.shares = "normal"
+sched.mem.min = "0"
+sched.mem.minSize = "0"
+sched.mem.shares = "normal"
+svga.vramSize = "8388608"
+sched.swap.derivedName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/BZ1308535_21disks/BZ1308535_21disks-6a024f8a.vswp"
+replay.supported = "FALSE"
+replay.filename = ""
+scsi0:0.redo = ""
+scsi0:1.redo = ""
+scsi0:2.redo = ""
+scsi0:3.redo = ""
+scsi0:4.redo = ""
+scsi0:5.redo = ""
+scsi0:6.redo = ""
+scsi0:8.redo = ""
+scsi0:9.redo = ""
+scsi0:10.redo = ""
+scsi0:11.redo = ""
+scsi0:12.redo = ""
+scsi0:13.redo = ""
+scsi0:14.redo = ""
+scsi0:15.redo = ""
+scsi1:0.redo = ""
+scsi1:1.redo = ""
+scsi1:2.redo = ""
+scsi1:3.redo = ""
+scsi1:4.redo = ""
+scsi1:5.redo = ""
+pciBridge0.pciSlotNumber = "17"
+pciBridge4.pciSlotNumber = "21"
+pciBridge5.pciSlotNumber = "22"
+pciBridge6.pciSlotNumber = "23"
+pciBridge7.pciSlotNumber = "24"
+scsi0.pciSlotNumber = "160"
+scsi1.pciSlotNumber = "192"
+ethernet0.pciSlotNumber = "224"
+vmci0.pciSlotNumber = "32"
+scsi0.sasWWID = "50 05 05 6f e6 46 bd 80"
+scsi1.sasWWID = "50 05 05 6f e6 46 bc 80"
+ethernet0.generatedAddress = "00:0c:29:36:ef:31"
+ethernet0.generatedAddressOffset = "0"
+vmci0.id = "2000088881"
+hostCPUID.0 = "0000000d756e65476c65746e49656e69"
+hostCPUID.1 = "000206a700100800179ae3bfbfebfbff"
+hostCPUID.80000001 = "00000000000000000000000128100800"
+guestCPUID.0 = "0000000d756e65476c65746e49656e69"
+guestCPUID.1 = "000206a700010800969822030fabfbff"
+guestCPUID.80000001 = "00000000000000000000000128100800"
+userCPUID.0 = "0000000d756e65476c65746e49656e69"
+userCPUID.1 = "000206a700100800169822030fabfbff"
+userCPUID.80000001 = "00000000000000000000000128100800"
+evcCompatibilityMode = "FALSE"
+vmotion.checkpointFBSize = "8388608"
+cleanShutdown = "TRUE"
+softPowerOff = "TRUE"
+tools.remindInstall = "TRUE"
diff --git a/v2v/test-v2v-i-vmx-2.expected b/v2v/test-v2v-i-vmx-2.expected
new file mode 100644
index 0000000..dc3eb60
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-2.expected
@@ -0,0 +1,22 @@
+[   0.0] Opening the source -i vmx test-v2v-i-vmx-2.vmx
+Source guest information (--print-source option):
+
+    source name: Fedora 20
+hypervisor type: vmware
+         memory: 2147483648 (bytes)
+       nr vCPUs: 1
+     CPU vendor: 
+      CPU model: 
+   CPU topology: sockets: - cores/socket: - threads/core: -
+   CPU features: 
+       firmware: bios
+        display: 
+          video: vmvga
+          sound: 
+disks:
+	/Fedora 20.vmdk (vmdk) [scsi]
+removable media:
+
+NICs:
+	Network "VM Network" mac: 00:50:56:9b:5f:0d [vmxnet3]
+
diff --git a/v2v/test-v2v-i-vmx-2.vmx b/v2v/test-v2v-i-vmx-2.vmx
new file mode 100644
index 0000000..d9dcf3a
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-2.vmx
@@ -0,0 +1,84 @@
+.encoding = "UTF-8"
+config.version = "8"
+virtualHW.version = "10"
+nvram = "Fedora 20.nvram"
+pciBridge0.present = "TRUE"
+svga.present = "TRUE"
+pciBridge4.present = "TRUE"
+pciBridge4.virtualDev = "pcieRootPort"
+pciBridge4.functions = "8"
+pciBridge5.present = "TRUE"
+pciBridge5.virtualDev = "pcieRootPort"
+pciBridge5.functions = "8"
+pciBridge6.present = "TRUE"
+pciBridge6.virtualDev = "pcieRootPort"
+pciBridge6.functions = "8"
+pciBridge7.present = "TRUE"
+pciBridge7.virtualDev = "pcieRootPort"
+pciBridge7.functions = "8"
+vmci0.present = "TRUE"
+hpet0.present = "TRUE"
+displayName = "Fedora 20"
+extendedConfigFile = "Fedora 20.vmxf"
+virtualHW.productCompatibility = "hosted"
+svga.vramSize = "8388608"
+memSize = "2048"
+sched.cpu.units = "mhz"
+sched.cpu.affinity = "all"
+powerType.powerOff = "soft"
+powerType.suspend = "hard"
+powerType.reset = "soft"
+scsi0.virtualDev = "pvscsi"
+scsi0.present = "TRUE"
+sata0.present = "TRUE"
+scsi0:0.deviceType = "scsi-hardDisk"
+scsi0:0.fileName = "Fedora 20.vmdk"
+sched.scsi0:0.shares = "normal"
+sched.scsi0:0.throughputCap = "off"
+scsi0:0.present = "TRUE"
+ethernet0.virtualDev = "vmxnet3"
+ethernet0.networkName = "VM Network"
+ethernet0.addressType = "vpx"
+ethernet0.generatedAddress = "00:50:56:9b:5f:0d"
+ethernet0.present = "TRUE"
+sata0:0.startConnected = "FALSE"
+sata0:0.deviceType = "cdrom-image"
+sata0:0.fileName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/ISOs/Fedora-20-x86_64-netinst.iso"
+sata0:0.present = "TRUE"
+floppy0.startConnected = "FALSE"
+floppy0.clientDevice = "TRUE"
+floppy0.fileName = "vmware-null-remote-floppy"
+vmci.filter.enable = "TRUE"
+guestOS = "rhel7-64"
+toolScripts.afterPowerOn = "TRUE"
+toolScripts.afterResume = "TRUE"
+toolScripts.beforeSuspend = "TRUE"
+toolScripts.beforePowerOff = "TRUE"
+uuid.bios = "42 1b 4b 87 e6 b7 d8 81-07 a0 c9 d2 21 cd 3c 6b"
+vc.uuid = "50 1b 1f 1b 73 00 32 bf-93 a1 1c b2 b4 e6 17 d6"
+sched.cpu.min = "0"
+sched.cpu.shares = "normal"
+sched.mem.min = "0"
+sched.mem.minSize = "0"
+sched.mem.shares = "normal"
+sched.swap.derivedName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/Fedora 20/Fedora 20-c71e4118.vswp"
+uuid.location = "56 4d 0f 53 00 63 d5 55-41 01 4c f7 55 ce 03 0e"
+replay.supported = "TRUE"
+replay.filename = ""
+scsi0:0.redo = ""
+pciBridge0.pciSlotNumber = "17"
+pciBridge4.pciSlotNumber = "21"
+pciBridge5.pciSlotNumber = "22"
+pciBridge6.pciSlotNumber = "23"
+pciBridge7.pciSlotNumber = "24"
+scsi0.pciSlotNumber = "160"
+ethernet0.pciSlotNumber = "192"
+vmci0.pciSlotNumber = "32"
+sata0.pciSlotNumber = "33"
+scsi0.sasWWID = "50 05 05 67 e6 b7 d8 80"
+vmci0.id = "567098475"
+vmotion.checkpointFBSize = "8388608"
+cleanShutdown = "TRUE"
+softPowerOff = "TRUE"
+sata0:0.allowGuestConnectionControl = "TRUE"
+tools.syncTime = "FALSE"
diff --git a/v2v/test-v2v-i-vmx-3.expected b/v2v/test-v2v-i-vmx-3.expected
new file mode 100644
index 0000000..9e64352
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-3.expected
@@ -0,0 +1,22 @@
+[   0.0] Opening the source -i vmx test-v2v-i-vmx-3.vmx
+Source guest information (--print-source option):
+
+    source name: RHEL 7.1 UEFI
+hypervisor type: vmware
+         memory: 2147483648 (bytes)
+       nr vCPUs: 1
+     CPU vendor: 
+      CPU model: 
+   CPU topology: sockets: - cores/socket: - threads/core: -
+   CPU features: 
+       firmware: uefi
+        display: 
+          video: vmvga
+          sound: 
+disks:
+	/RHEL 7.1 UEFI.vmdk (vmdk) [scsi]
+removable media:
+	CD-ROM [ide] in slot 2
+NICs:
+	Network "VM Network" mac: 00:0c:29:4b:2b:8c [vmxnet3]
+
diff --git a/v2v/test-v2v-i-vmx-3.vmx b/v2v/test-v2v-i-vmx-3.vmx
new file mode 100644
index 0000000..c392155
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-3.vmx
@@ -0,0 +1,91 @@
+.encoding = "UTF-8"
+config.version = "8"
+virtualHW.version = "8"
+nvram = "RHEL 7.1 UEFI.nvram"
+pciBridge0.present = "TRUE"
+svga.present = "TRUE"
+pciBridge4.present = "TRUE"
+pciBridge4.virtualDev = "pcieRootPort"
+pciBridge4.functions = "8"
+pciBridge5.present = "TRUE"
+pciBridge5.virtualDev = "pcieRootPort"
+pciBridge5.functions = "8"
+pciBridge6.present = "TRUE"
+pciBridge6.virtualDev = "pcieRootPort"
+pciBridge6.functions = "8"
+pciBridge7.present = "TRUE"
+pciBridge7.virtualDev = "pcieRootPort"
+pciBridge7.functions = "8"
+vmci0.present = "TRUE"
+hpet0.present = "TRUE"
+displayName = "RHEL 7.1 UEFI"
+extendedConfigFile = "RHEL 7.1 UEFI.vmxf"
+virtualHW.productCompatibility = "hosted"
+memSize = "2048"
+firmware = "efi"
+sched.cpu.units = "mhz"
+powerType.powerOff = "soft"
+powerType.suspend = "hard"
+powerType.reset = "soft"
+scsi0.virtualDev = "pvscsi"
+scsi0.present = "TRUE"
+ide1:0.startConnected = "FALSE"
+ide1:0.deviceType = "cdrom-image"
+ide1:0.fileName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/ISOs/RHEL-7.1-20150219.1-Server-x86_64-boot.iso"
+ide1:0.present = "TRUE"
+floppy0.startConnected = "FALSE"
+floppy0.clientDevice = "TRUE"
+floppy0.fileName = "vmware-null-remote-floppy"
+ethernet0.virtualDev = "vmxnet3"
+ethernet0.networkName = "VM Network"
+ethernet0.addressType = "generated"
+ethernet0.present = "TRUE"
+scsi0:0.deviceType = "scsi-hardDisk"
+scsi0:0.fileName = "RHEL 7.1 UEFI.vmdk"
+scsi0:0.present = "TRUE"
+guestOS = "rhel6-64"
+toolScripts.afterPowerOn = "TRUE"
+toolScripts.afterResume = "TRUE"
+toolScripts.beforeSuspend = "TRUE"
+toolScripts.beforePowerOff = "TRUE"
+uuid.bios = "56 4d 99 89 a7 21 91 0d-cc 28 e2 db d5 4b 2b 8c"
+uuid.location = "56 4d 99 89 a7 21 91 0d-cc 28 e2 db d5 4b 2b 8c"
+vc.uuid = "52 3f 29 10 d3 81 16 43-fa b0 e3 af 3b ba 36 e5"
+chipset.onlineStandby = "FALSE"
+sched.cpu.min = "0"
+sched.cpu.shares = "normal"
+sched.mem.min = "0"
+sched.mem.minSize = "0"
+sched.mem.shares = "normal"
+svga.vramSize = "8388608"
+sched.swap.derivedName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/RHEL 7.1 UEFI/RHEL 7.1 UEFI-58ff6e6f.vswp"
+replay.supported = "FALSE"
+replay.filename = ""
+scsi0:0.redo = ""
+pciBridge0.pciSlotNumber = "17"
+pciBridge4.pciSlotNumber = "21"
+pciBridge5.pciSlotNumber = "22"
+pciBridge6.pciSlotNumber = "23"
+pciBridge7.pciSlotNumber = "24"
+scsi0.pciSlotNumber = "160"
+ethernet0.pciSlotNumber = "192"
+vmci0.pciSlotNumber = "32"
+scsi0.sasWWID = "50 05 05 69 a7 21 91 00"
+ethernet0.generatedAddress = "00:0c:29:4b:2b:8c"
+ethernet0.generatedAddressOffset = "0"
+vmci0.id = "-716493940"
+hostCPUID.0 = "0000000d756e65476c65746e49656e69"
+hostCPUID.1 = "000206a700100800179ae3bfbfebfbff"
+hostCPUID.80000001 = "00000000000000000000000128100800"
+guestCPUID.0 = "0000000d756e65476c65746e49656e69"
+guestCPUID.1 = "000206a700010800969822030fabfbff"
+guestCPUID.80000001 = "00000000000000000000000128100800"
+userCPUID.0 = "0000000d756e65476c65746e49656e69"
+userCPUID.1 = "000206a700100800169822030fabfbff"
+userCPUID.80000001 = "00000000000000000000000128100800"
+evcCompatibilityMode = "FALSE"
+vmotion.checkpointFBSize = "8388608"
+cleanShutdown = "TRUE"
+softPowerOff = "TRUE"
+ide1:0.allowGuestConnectionControl = "TRUE"
+tools.syncTime = "FALSE"
diff --git a/v2v/test-v2v-i-vmx-4.expected b/v2v/test-v2v-i-vmx-4.expected
new file mode 100644
index 0000000..a70533d
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-4.expected
@@ -0,0 +1,22 @@
+[   0.0] Opening the source -i vmx test-v2v-i-vmx-4.vmx
+Source guest information (--print-source option):
+
+    source name: Windows 7 x64
+hypervisor type: vmware
+         memory: 2147483648 (bytes)
+       nr vCPUs: 1
+     CPU vendor: 
+      CPU model: 
+   CPU topology: sockets: - cores/socket: - threads/core: -
+   CPU features: 
+       firmware: bios
+        display: 
+          video: vmvga
+          sound: 
+disks:
+	/Windows 7 x64.vmdk (vmdk) [scsi]
+removable media:
+	CD-ROM [ide] in slot 2
+NICs:
+	Network "VM Network" mac: 00:0c:29:94:89:23 [e1000]
+
diff --git a/v2v/test-v2v-i-vmx-4.vmx b/v2v/test-v2v-i-vmx-4.vmx
new file mode 100644
index 0000000..7756cf2
--- /dev/null
+++ b/v2v/test-v2v-i-vmx-4.vmx
@@ -0,0 +1,88 @@
+.encoding = "UTF-8"
+config.version = "8"
+virtualHW.version = "8"
+nvram = "Windows 7 x64.nvram"
+pciBridge0.present = "TRUE"
+svga.present = "TRUE"
+pciBridge4.present = "TRUE"
+pciBridge4.virtualDev = "pcieRootPort"
+pciBridge4.functions = "8"
+pciBridge5.present = "TRUE"
+pciBridge5.virtualDev = "pcieRootPort"
+pciBridge5.functions = "8"
+pciBridge6.present = "TRUE"
+pciBridge6.virtualDev = "pcieRootPort"
+pciBridge6.functions = "8"
+pciBridge7.present = "TRUE"
+pciBridge7.virtualDev = "pcieRootPort"
+pciBridge7.functions = "8"
+vmci0.present = "TRUE"
+hpet0.present = "TRUE"
+displayName = "Windows 7 x64"
+extendedConfigFile = "Windows 7 x64.vmxf"
+virtualHW.productCompatibility = "hosted"
+memSize = "2048"
+sched.cpu.units = "mhz"
+powerType.powerOff = "soft"
+powerType.suspend = "hard"
+powerType.reset = "soft"
+scsi0.virtualDev = "lsisas1068"
+scsi0.present = "TRUE"
+ide1:0.deviceType = "cdrom-image"
+ide1:0.fileName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/ISOs/en_windows_7_ultimate_with_sp1_x64_dvd_u_677332.iso"
+ide1:0.present = "TRUE"
+floppy0.startConnected = "FALSE"
+floppy0.clientDevice = "TRUE"
+floppy0.fileName = "vmware-null-remote-floppy"
+ethernet0.virtualDev = "e1000"
+ethernet0.networkName = "VM Network"
+ethernet0.addressType = "generated"
+ethernet0.present = "TRUE"
+scsi0:0.deviceType = "scsi-hardDisk"
+scsi0:0.fileName = "Windows 7 x64.vmdk"
+scsi0:0.present = "TRUE"
+guestOS = "windows7-64"
+toolScripts.afterPowerOn = "TRUE"
+toolScripts.afterResume = "TRUE"
+toolScripts.beforeSuspend = "TRUE"
+toolScripts.beforePowerOff = "TRUE"
+uuid.bios = "56 4d 6f ca 63 a5 a8 3e-13 ec 73 89 1d 94 89 23"
+uuid.location = "56 4d 6f ca 63 a5 a8 3e-13 ec 73 89 1d 94 89 23"
+vc.uuid = "52 7a 63 e1 2c 2f 50 46-91 66 3a e8 fa f9 c4 65"
+chipset.onlineStandby = "FALSE"
+sched.cpu.min = "0"
+sched.cpu.shares = "normal"
+sched.mem.min = "0"
+sched.mem.minSize = "0"
+sched.mem.shares = "normal"
+svga.vramSize = "8388608"
+sched.swap.derivedName = "/vmfs/volumes/5458b680-34ec3500-9f36-001320f5f6ca/Windows 7 x64/Windows 7 x64-8e3b0929.vswp"
+replay.supported = "FALSE"
+replay.filename = ""
+scsi0:0.redo = ""
+pciBridge0.pciSlotNumber = "17"
+pciBridge4.pciSlotNumber = "21"
+pciBridge5.pciSlotNumber = "22"
+pciBridge6.pciSlotNumber = "23"
+pciBridge7.pciSlotNumber = "24"
+scsi0.pciSlotNumber = "160"
+ethernet0.pciSlotNumber = "32"
+vmci0.pciSlotNumber = "33"
+scsi0.sasWWID = "50 05 05 6a 63 a5 a8 30"
+ethernet0.generatedAddress = "00:0c:29:94:89:23"
+ethernet0.generatedAddressOffset = "0"
+vmci0.id = "496273699"
+hostCPUID.0 = "0000000b756e65476c65746e49656e69"
+hostCPUID.1 = "000206c220200800029ee3ffbfebfbff"
+hostCPUID.80000001 = "0000000000000000000000012c100800"
+guestCPUID.0 = "0000000b756e65476c65746e49656e69"
+guestCPUID.1 = "000206c200010800829822030fabfbff"
+guestCPUID.80000001 = "00000000000000000000000128100800"
+userCPUID.0 = "0000000b756e65476c65746e49656e69"
+userCPUID.1 = "000206c220200800029822030fabfbff"
+userCPUID.80000001 = "00000000000000000000000128100800"
+evcCompatibilityMode = "FALSE"
+vmotion.checkpointFBSize = "8388608"
+cleanShutdown = "TRUE"
+softPowerOff = "TRUE"
+tools.remindInstall = "TRUE"
diff --git a/v2v/test-v2v-i-vmx.sh b/v2v/test-v2v-i-vmx.sh
new file mode 100755
index 0000000..5353e7e
--- /dev/null
+++ b/v2v/test-v2v-i-vmx.sh
@@ -0,0 +1,48 @@
+#!/bin/bash -
+# libguestfs virt-v2v test script
+# Copyright (C) 2017 Red Hat Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+# Test -i ova option.
+
+set -e
+
+$TEST_FUNCTIONS
+skip_if_skipped
+skip_if_backend uml
+
+export VIRT_TOOLS_DATA_DIR="$top_srcdir/test-data/fake-virt-tools"
+export VIRTIO_WIN="$top_srcdir/test-data/fake-virtio-win"
+
+rm -f test-v2v-i-vmx-*.actual
+
+for i in 1 2 3 4; do
+    $VG virt-v2v --debug-gc \
+        -i vmx test-v2v-i-vmx-$i.vmx \
+        --print-source > test-v2v-i-vmx-$i.actual
+
+    # Normalize the print-source output.
+    mv test-v2v-i-vmx-$i.actual test-v2v-i-vmx-$i.actual.old
+    sed \
+        -e "s,$(pwd),," \
+        < test-v2v-i-vmx-$i.actual.old > test-v2v-i-vmx-$i.actual
+    rm test-v2v-i-vmx-$i.actual.old
+
+    # Check the output.
+    diff -u test-v2v-i-vmx-$i.expected test-v2v-i-vmx-$i.actual
+done
+
+rm test-v2v-i-vmx-*.actual
diff --git a/v2v/v2v_unit_tests.ml b/v2v/v2v_unit_tests.ml
index bd65788..dd805a3 100644
--- a/v2v/v2v_unit_tests.ml
+++ b/v2v/v2v_unit_tests.ml
@@ -787,6 +787,148 @@ let test_qemu_img_supports ctx =
    *)
   ignore (Utils.qemu_img_supports_offset_and_size ())
 
+(* Test the VMX file parser in the Parse_vmx module. *)
+let test_vmx_parse_string ctx =
+  let cmp = Parse_vmx.equal in
+  let printer = Parse_vmx.to_string 0 in
+
+  (* This should be identical to the empty file. *)
+  let t = Parse_vmx.parse_string "\
+test.foo = \"a\"
+test.bar = \"b\"
+test.present = \"FALSE\"
+" in
+  assert_equal ~cmp ~printer Parse_vmx.empty t;
+
+  (* Test weird escapes. *)
+  let t1 = Parse_vmx.parse_string "\
+foo = \"a|20|21b\"
+" in
+  let t2 = Parse_vmx.parse_string "\
+foo = \"a !b\"
+" in
+  assert_equal ~cmp ~printer t1 t2;
+
+  (* Test case insensitivity. *)
+  let t1 = Parse_vmx.parse_string "\
+foo = \"abc\"
+" in
+  let t2 = Parse_vmx.parse_string "\
+fOO = \"abc\"
+" in
+  assert_equal ~cmp ~printer t1 t2;
+  let t = Parse_vmx.parse_string "\
+flag = \"true\"
+" in
+  assert_bool "parse_vmx: failed case insensitivity test for booleans #1"
+              (Parse_vmx.get_bool t ["FLAG"] = Some true);
+  let t = Parse_vmx.parse_string "\
+flag = \"TRUE\"
+" in
+  assert_bool "parse_vmx: failed case insensitivity test for booleans #2"
+              (Parse_vmx.get_bool t ["Flag"] = Some true);
+
+  (* Missing keys. *)
+  let t = Parse_vmx.parse_string "\
+foo = \"a\"
+" in
+  assert_bool "parse_vmx: failed missing key test"
+              (Parse_vmx.get_string t ["bar"] = None);
+
+  (* namespace_present function *)
+  let t = Parse_vmx.parse_string "\
+foo.bar.present = \"TRUE\"
+foo.baz.present = \"FALSE\"
+foo.a.b = \"abc\"
+foo.a.c = \"abc\"
+foo.b = \"abc\"
+foo.c.a = \"abc\"
+foo.c.b = \"abc\"
+" in
+ assert_bool "parse_vmx: namespace_present #1"
+             (Parse_vmx.namespace_present t ["foo"] = true);
+ assert_bool "parse_vmx: namespace_present #2"
+             (Parse_vmx.namespace_present t ["foo"; "bar"] = true);
+ assert_bool "parse_vmx: namespace_present #3"
+             (* this whole namespace should have been culled *)
+             (Parse_vmx.namespace_present t ["foo"; "baz"] = false);
+ assert_bool "parse_vmx: namespace_present #4"
+             (Parse_vmx.namespace_present t ["foo"; "a"] = true);
+ assert_bool "parse_vmx: namespace_present #5"
+             (* this is a key, not a namespace *)
+             (Parse_vmx.namespace_present t ["foo"; "a"; "b"] = false);
+ assert_bool "parse_vmx: namespace_present #6"
+             (Parse_vmx.namespace_present t ["foo"; "b"] = false);
+ assert_bool "parse_vmx: namespace_present #7"
+             (Parse_vmx.namespace_present t ["foo"; "c"] = true);
+ assert_bool "parse_vmx: namespace_present #8"
+             (Parse_vmx.namespace_present t ["foo"; "d"] = false);
+
+ (* map function *)
+  let t = Parse_vmx.parse_string "\
+foo.bar.present = \"TRUE\"
+foo.baz.present = \"FALSE\"
+foo.a.b = \"abc\"
+foo.a.c = \"abc\"
+foo.b = \"abc\"
+foo.c.a = \"abc\"
+foo.c.b = \"abc\"
+" in
+  let xs =
+    Parse_vmx.map (
+      fun path ->
+        let path = String.concat "." path in
+        function
+        | None -> sprintf "%s.present = \"true\"\n" path
+        | Some v -> sprintf "%s = \"%s\"\n" path v
+    ) t in
+  let xs = List.sort compare xs in
+  let s = String.concat "" xs in
+  assert_equal ~printer:identity "\
+foo.a.b = \"abc\"
+foo.a.c = \"abc\"
+foo.a.present = \"true\"
+foo.b = \"abc\"
+foo.bar.present = \"TRUE\"
+foo.bar.present = \"true\"
+foo.c.a = \"abc\"
+foo.c.b = \"abc\"
+foo.c.present = \"true\"
+foo.present = \"true\"
+" s;
+
+  (* select_namespaces function *)
+  let t1 = Parse_vmx.parse_string "\
+foo.bar.present = \"TRUE\"
+foo.a.b = \"abc\"
+foo.a.c = \"abc\"
+foo.b = \"abc\"
+foo.c.a = \"abc\"
+foo.c.b = \"abc\"
+" in
+  let t2 =
+    Parse_vmx.select_namespaces
+      (function ["foo"] -> true | _ -> false) t1 in
+  assert_equal ~cmp ~printer t1 t2;
+
+  let t1 = Parse_vmx.parse_string "\
+foo.bar.present = \"TRUE\"
+foo.a.b = \"abc\"
+foo.a.c = \"abc\"
+foo.b = \"abc\"
+foo.c.a = \"abc\"
+foo.c.b = \"abc\"
+foo.c.c.d.e.f = \"abc\"
+" in
+  let t1 =
+    Parse_vmx.select_namespaces
+      (function ["foo"; "a"] -> true | _ -> false) t1 in
+  let t2 = Parse_vmx.parse_string "\
+foo.a.b = \"abc\"
+foo.a.c = \"abc\"
+" in
+  assert_equal ~cmp ~printer t2 t1
+
 (* Suites declaration. *)
 let suite =
   "virt-v2v" >:::
@@ -798,6 +940,7 @@ let suite =
         test_virtio_iso_path_matches_guest_os;
       "Utils.shell_unquote" >:: test_shell_unquote;
       "Utils.qemu_img_supports" >:: test_qemu_img_supports;
+      "Parse_vmx.parse_string" >::test_vmx_parse_string;
     ]
 
 let () =
diff --git a/v2v/virt-v2v.pod b/v2v/virt-v2v.pod
index 7ad674d..31d1ce0 100644
--- a/v2v/virt-v2v.pod
+++ b/v2v/virt-v2v.pod
@@ -41,7 +41,8 @@ libguestfs E<ge> 1.28.
  ... ───▶│  (default) │   │            │ ──┐ └────────────┘
          └────────────┘   │            │ ─┐└──────▶ -o glance
  -i libvirtxml ─────────▶ │            │ ┐└─────────▶ -o rhv
-                          └────────────┘ └──────────▶ -o vdsm
+ -i vmx ────────────────▶ │            │ └──────────▶ -o vdsm
+                          └────────────┘
 
 Virt-v2v has a number of possible input and output modes, selected
 using the I<-i> and I<-o> options.  Only one input and output mode can
@@ -60,6 +61,8 @@ method used by L<virt-p2v(1)> behind the scenes.
 
 I<-i ova> is used for reading from a VMware ova source file.
 
+I<-i vmx> is used for reading from a VMware vmx file.
+
 I<-o glance> is used for writing to OpenStack Glance.
 
 I<-o libvirt> is used for writing to any libvirt target.  Libvirt can
@@ -215,6 +218,14 @@ ova manifest file and check the vmdk volumes for validity (checksums)
 as well as analyzing the ovf file, and then convert the guest.  See
 L</INPUT FROM VMWARE OVA> below
 
+=item B<-i> B<vmx>
+
+Set the input method to I<vmx>.
+
+In this mode you can read a VMware vmx file directly.  This is useful
+when VMware VMs are stored on an NFS server which you can mount
+directly.  See L</INPUT FROM VMWARE VMX> below
+
 =item B<-ic> libvirtURI
 
 Specify a libvirt connection URI to use when reading the guest.  This
@@ -843,9 +854,10 @@ I<--bridge> option instead.  For example:
 
 Virt-v2v is able to import guests from VMware vCenter Server.
 
-vCenter E<ge> 5.0 is required.  If you don't have vCenter, using OVA
-is recommended instead (see L</INPUT FROM VMWARE OVA> below), or if
-that is not possible then see L</INPUT FROM VMWARE ESXi HYPERVISOR>.
+vCenter E<ge> 5.0 is required.  If you don’t have vCenter, using OVA
+or VMX is recommended instead (see L</INPUT FROM VMWARE OVA> and/or
+L</INPUT FROM VMWARE VMX> below), or if that is not possible then see
+L</INPUT FROM VMWARE ESXi HYPERVISOR>.
 
 Virt-v2v uses libvirt for access to vCenter, and therefore the input
 mode should be I<-i libvirt>.  As this is the default, you don't need
@@ -1116,12 +1128,58 @@ directory containing the files:
 
  $ virt-v2v -i ova /path/to/files -o local -os /var/tmp
 
+=head1 INPUT FROM VMWARE VMX
+
+Virt-v2v is able to import guests from VMware’s vmx files.  This is
+useful where VMware virtual machines are stored on a separate NFS
+server and you are able to mount the NFS storage directly.
+
+If you find a folder of files called F<I<guest>.vmx>,
+F<I<guest>.vmxf>, F<I<guest>.nvram> and one or more F<.vmdk> disk
+images, then you can use this method.
+
+=head2 VMX: REMOVE VMWARE TOOLS FROM WINDOWS GUESTS
+
+For Windows guests, you should remove VMware tools before conversion.
+Although this is not strictly necessary, and the guest will still be
+able to run, if you don't do this then the converted guest will
+complain on every boot.  The tools cannot be removed after conversion
+because the uninstaller checks if it is running on VMware and refuses
+to start (which is also the reason that virt-v2v cannot remove them).
+
+This is not necessary for Linux guests, as virt-v2v is able to remove
+VMware tools.
+
+=head2 VMX: GUEST MUST BE SHUT DOWN
+
+B<The guest must be shut down before conversion starts>.  If you don't
+shut it down, you will end up with a corrupted VM disk on the target.
+With other methods, virt-v2v tries to prevent concurrent access, but
+because the I<-i vmx> method works directly against the storage,
+checking for concurrent access is not possible.
+
+=head2 VMX: MOUNT THE NFS STORAGE ON THE CONVERSION SERVER
+
+Virt-v2v must be able to access the F<.vmx> file and any local
+F<.vmdk> disks.  Normally this means you must mount the NFS storage
+containing these files.
+
+=head2 VMX: IMPORTING A GUEST
+
+To import a vmx file, do:
+
+ $ virt-v2v -i vmx guest.vmx -o local -os /var/tmp
+
+Virt-v2v processes the vmx file and uses it to find the location of
+any vmdk disks.
+
 =head1 INPUT FROM VMWARE ESXi HYPERVISOR
 
 Virt-v2v cannot access an ESXi hypervisor directly.  You should use
-the OVA method above (see L</INPUT FROM VMWARE OVA>) if possible, as
-it is much faster and requires much less disk space than the method
-described in this section.
+the OVA or VMX methods above (see L</INPUT FROM VMWARE OVA> and/or
+L</INPUT FROM VMWARE VMX>) if possible, as it is much faster and
+requires much less disk space than the method described in this
+section.
 
 You can use the L<virt-v2v-copy-to-local(1)> tool to copy the guest
 off the hypervisor into a local file, and then convert it.
-- 
2.9.4