summaryrefslogtreecommitdiffstats
path: root/src/main/java/org/openslx/virtualization/configuration/VirtualizationConfigurationVirtualboxFileFormat.java
blob: b5b31800560d8018de36b4a06f13bd93903b8152 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
package org.openslx.virtualization.configuration;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.XMLConstants;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openslx.util.Resources;
import org.openslx.util.Util;
import org.openslx.util.XmlHelper;
import org.openslx.virtualization.Version;
import org.openslx.virtualization.configuration.VirtualizationConfiguration.DriveBusType;
import org.openslx.virtualization.configuration.VirtualizationConfiguration.HardDisk;
import org.w3c.dom.DOMException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

/**
 * Class handling the parsing of a .vbox machine description file
 */
public class VirtualizationConfigurationVirtualboxFileFormat
{
	private static final Logger LOGGER = LogManager.getLogger( VirtualizationConfigurationVirtualboxFileFormat.class );

	// key information set during initial parsing of the XML file
	private String osName = "";
	private ArrayList<HardDisk> hddsArray = new ArrayList<HardDisk>();

	// XPath and DOM parsing related members
	private Document doc = null;

	/**
	 * Version of the configuration file format.
	 */
	private Version version = null;

	/**
	 * File names of XML schema files for different file format versions.
	 */
	private final static HashMap<Version, String> FILE_FORMAT_SCHEMA_VERSIONS = new HashMap<Version, String>() {

		private static final long serialVersionUID = -3163681758191475625L;

		{
			put( Version.valueOf( "1.15" ), "VirtualBox-settings_v1-15.xsd" );
			put( Version.valueOf( "1.16" ), "VirtualBox-settings_v1-16.xsd" );
			put( Version.valueOf( "1.17" ), "VirtualBox-settings_v1-17.xsd" );
			put( Version.valueOf( "1.18" ), "VirtualBox-settings_v1-18.xsd" );
		}
	};

	/**
	 * Path to the VirtualBox file format schemas within the *.jar file.
	 */
	private final static String FILE_FORMAT_SCHEMA_PREFIX_PATH = Resources.PATH_SEPARATOR + "virtualbox"
			+ Resources.PATH_SEPARATOR + "xsd";

	public static final String DUMMY_VALUE = "[dummy]";

	// list of nodes to automatically remove when reading the vbox file
	private static final String[] BLACKLIST = {
			"/VirtualBox/Machine/Hardware/GuestProperties",
			"/VirtualBox/Machine/Hardware/VideoCapture",
			"/VirtualBox/Machine/Hardware/HID",
			"/VirtualBox/Machine/Hardware/LPT",
			"/VirtualBox/Machine/Hardware/SharedFolders",
			"/VirtualBox/Machine/Hardware/Network/Adapter[@slot='0']/*",
			"/VirtualBox/Machine/ExtraData",
			"/VirtualBox/Machine/StorageControllers/StorageController/AttachedDevice[not(@type='HardDisk')]",
			"/VirtualBox/Machine/Hardware/StorageControllers/StorageController/AttachedDevice[not(@type='HardDisk')]",
			"/VirtualBox/Machine/MediaRegistry/FloppyImages",
			"/VirtualBox/Machine/MediaRegistry/DVDImages" };
	
	public static enum MatchMode
	{
		EXACTLY_ONE,
		MULTIPLE,
		FIRST_ONLY,
	};

	/**
	 * Creates a vbox configuration by constructing a DOM from the given VirtualBox machine
	 * configuration file.
	 * Will validate the given file against the VirtualBox XSD schema and only proceed if it is
	 * valid.
	 * 
	 * @param file the VirtualBox machine configuration file
	 * @throws IOException if an error occurs while reading the file
	 * @throws VirtualizationConfigurationException if the given file is not a valid VirtualBox
	 *            configuration file.
	 */
	public VirtualizationConfigurationVirtualboxFileFormat( File file ) throws IOException, VirtualizationConfigurationException
	{
		doc = XmlHelper.parseDocumentFromStream( new FileInputStream( file ) );
		doc = XmlHelper.removeFormattingNodes( doc );
		if ( doc == null )
			throw new VirtualizationConfigurationException( "Could not parse given VirtualBox machine configuration file!" );

		this.parseConfigurationVersion();
		this.init();
	}

	/**
	 * Creates an vbox configuration by constructing a DOM from the XML content given as a byte
	 * array.
	 * 
	 * @param machineDescription content of the XML file saved as a byte array.
	 * @param length of the machine description byte array.
	 * @throws VirtualizationConfigurationException creation of VirtualBox configuration file representation failed.
	 */
	public VirtualizationConfigurationVirtualboxFileFormat( byte[] machineDescription, int length ) throws VirtualizationConfigurationException
	{
		ByteArrayInputStream is = new ByteArrayInputStream( machineDescription );

		doc = XmlHelper.parseDocumentFromStream( is );
		if ( doc == null ) {
			final String errorMsg = "Could not parse given VirtualBox machine description from byte array!";
			LOGGER.debug( errorMsg );
			throw new VirtualizationConfigurationException( errorMsg );
		}

		this.parseConfigurationVersion();
		this.init();
	}

	public void validate() throws VirtualizationConfigurationException
	{
		this.validateFileFormatVersion( this.getVersion() );
	}

	public void validateFileFormatVersion( Version version ) throws VirtualizationConfigurationException
	{
		if ( this.getVersion() != null && this.doc != null ) {
			// check if specified version is supported
			final String fileName = FILE_FORMAT_SCHEMA_VERSIONS.get( version );

			if ( fileName == null ) {
				final String errorMsg = "File format version " + version.toString() + " is not supported!";
				LOGGER.debug( errorMsg );
				throw new VirtualizationConfigurationException( errorMsg );
			} else {
				// specified version is supported, so validate document with corresponding schema file
				final InputStream schemaResource = VirtualizationConfigurationVirtualboxFileFormat
						.getSchemaResource( fileName );

				if ( schemaResource != null ) {
					try {
						final SchemaFactory factory = SchemaFactory.newInstance( XMLConstants.W3C_XML_SCHEMA_NS_URI );
						final Schema schema = factory.newSchema( new StreamSource( schemaResource ) );
						final Validator validator = schema.newValidator();
						validator.validate( new DOMSource( this.doc ) );
					} catch ( SAXException | IOException e ) {
						final String errorMsg = "XML configuration is not a valid VirtualBox v" + version.toString()
								+ " configuration: " + e.getLocalizedMessage();
						LOGGER.debug( errorMsg );
						throw new VirtualizationConfigurationException( errorMsg );
					}
				}
			}
		}
	}

	private static InputStream getSchemaResource( String fileName )
	{
		final String schemaFilePath = FILE_FORMAT_SCHEMA_PREFIX_PATH + File.separator + fileName;
		return VirtualizationConfigurationVirtualboxFileFormat.class.getResourceAsStream( schemaFilePath );
	}

	/**
	 * Main initialization functions parsing the document created during the constructor.
	 * @throws VirtualizationConfigurationException 
	 */
	private void init() throws VirtualizationConfigurationException
	{
		try {
			this.validate();
		} catch ( VirtualizationConfigurationException e ) {
			// do not print output of failed validation if placeholders are available
			// since those placeholder values violate the defined UUID pattern
			if ( !this.checkForPlaceholders() ) {
				final String errorMsg = "XML configuration is not a valid VirtualBox v" + version.toString()
						+ " configuration: " + e.getLocalizedMessage();
				LOGGER.debug( errorMsg );
			}
		}

		if ( Util.isEmptyString( getDisplayName() ) ) {
			throw new VirtualizationConfigurationException( "Machine doesn't have a name" );
		}
		try {
			ensureHardwareUuid();
			setOsType();
			fixUsb(); // Since we now support selecting specific speed
			removeUnusedHdds();
			if ( checkForPlaceholders() ) {
				return;
			}
			setHdds();
			removeBlacklistedElements();
			addPlaceHolders();
		} catch ( XPathExpressionException e ) {
			LOGGER.debug( "Could not initialize VBoxConfig", e );
			return;
		}
	}
	
	private void parseConfigurationVersion() throws VirtualizationConfigurationException
	{
		String versionText;
		try {
			versionText = XmlHelper.compileXPath( "/VirtualBox/@version" ).evaluate( this.doc );
		} catch ( XPathExpressionException e ) {
			throw new VirtualizationConfigurationException(
					"Failed to parse the version number of the configuration file" );
		}

		if ( versionText == null || versionText.isEmpty() ) {
			throw new VirtualizationConfigurationException( "Configuration file does not contain any version number!" );
		} else {
			// parse version information from textual description
			final Pattern versionPattern = Pattern.compile( "^(\\d+\\.\\d+).*$" );
			final Matcher versionMatcher = versionPattern.matcher( versionText );

			if ( versionMatcher.find() ) {
				this.version = Version.valueOf( versionMatcher.group( 1 ) );
			}

			if ( this.version == null ) {
				throw new VirtualizationConfigurationException( "Configuration file version number is not valid!" );
			}
		}
	}

	private void fixUsb()
	{
		NodeList list = findNodes( "/VirtualBox/Machine/Hardware/USB/Controllers/Controller" );
		if ( list != null && list.getLength() != 0 ) {
			LOGGER.info( "USB present, not fixing anything" );
			return;
		}
		// If there's no USB section, this can mean two things:
		// 1) Old config that would always default to USB 2.0 for "USB enabled" or nothing for disabled
		// 2) New config with USB disabled
		list = findNodes( "/VirtualBox/OpenSLX/USB[@disabled]" );
		if ( list != null && list.getLength() != 0 ) {
			LOGGER.info( "USB explicitly disabled" );
			return; // Explicitly marked as disabled, do nothing
		}
		// We assume case 1) and add USB 2.0
		LOGGER.info( "Fixing USB: Adding USB 2.0" );
		Element controller;
		Element node = createNodeRecursive( "/VirtualBox/Machine/Hardware/USB/Controllers" );
		controller = addNewNode( node, "Controller" );
		controller.setAttribute( "name", "OHCI" );
		controller.setAttribute( "type", "OHCI" );
		controller = addNewNode( node, "Controller" );
		controller.setAttribute( "name", "EHCI" );
		controller.setAttribute( "type", "EHCI" );
	}
	
	/**
	 * Remove any HDDs from MediaRegistry that aren't being used
	 */
	private void removeUnusedHdds()
	{
		Set<String> existing = new HashSet<>();
		NodeList list = findNodes( storageControllersPath() + "/StorageController/AttachedDevice/Image" );
		if ( list != null && list.getLength() != 0 ) {
			for ( int i = 0; i < list.getLength(); ++i ) {
				Node item = list.item( i );
				if ( ! ( item instanceof Element ) )
					continue;
				Element e = (Element)item;
				String uuid = e.getAttribute( "uuid" );
				if ( Util.isEmptyString( uuid ) )
					continue;
				existing.add( uuid );
			}
		}
		// Now check registry
		list = findNodes( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk" );
		if ( list != null && list.getLength() != 0 ) {
			for ( int i = 0; i < list.getLength(); ++i ) {
				Node item = list.item( i );
				if ( ! ( item instanceof Element ) )
					continue;
				Element e = (Element)item;
				String uuid = e.getAttribute( "uuid" );
				if ( !existing.contains( uuid ) ) {
					LOGGER.info( "Removing unused HDD " + uuid + " from MediaRegistry" );
					e.getParentNode().removeChild( e );
				}
			}
		}
	}

	public String storageControllersPath()
	{
		if ( this.getVersion().isSmallerThan( Version.valueOf( "1.17" ) ) )
			return "/VirtualBox/Machine/StorageControllers";
		return "/VirtualBox/Machine/Hardware/StorageControllers";
	}

	/**
	 * Saves the machine's uuid as hardware uuid to prevent VMs from
	 * believing in a hardware change.
	 *
	 * @throws XPathExpressionException
	 * @throws VirtualizationConfigurationException 
	 */
	private void ensureHardwareUuid() throws XPathExpressionException, VirtualizationConfigurationException
	{
		// we will need the machine uuid, so get it
		String machineUuid = XmlHelper.compileXPath( "/VirtualBox/Machine/@uuid" ).evaluate( this.doc );
		if ( machineUuid.isEmpty() ) {
			LOGGER.error( "Machine UUID empty, should never happen!" );
			throw new VirtualizationConfigurationException( "XML doesn't contain a machine uuid" );
		}

		NodeList hwNodes = findNodes( "/VirtualBox/Machine/Hardware" );
		int count = hwNodes.getLength();
		if ( count != 1 ) {
			throw new VirtualizationConfigurationException( "Zero or > 1 '/VirtualBox/Machine/Hardware' node were found, should never happen!" );
		}
		Element hw = (Element)hwNodes.item( 0 );
		String hwUuid = hw.getAttribute( "uuid" );
		if ( !hwUuid.isEmpty() ) {
			LOGGER.info( "Found hardware uuid: " + hwUuid );
			return;
		} else {
			if ( !addAttributeToNode( hw, "uuid", machineUuid ) ) {
				LOGGER.error( "Failed to set machine UUID '" + machineUuid + "' as hardware UUID." );
				return;
			}
			LOGGER.info( "Saved machine UUID as hardware UUID." );
		}
	}

	public Version getVersion()
	{
		return this.version;
	}

	/**
	 * Self-explanatory.
	 */
	public void addPlaceHolders()
	{
		// placeholder for the location of the virtual hdd
		changeAttribute( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk", "location", DUMMY_VALUE, MatchMode.MULTIPLE );
		// in case it already has a snapshot
		changeAttribute( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk/HardDisk", "location", DUMMY_VALUE, MatchMode.MULTIPLE );
		changeAttribute( "/VirtualBox/Machine", "snapshotFolder", DUMMY_VALUE, MatchMode.FIRST_ONLY );
	}

	/**
	 * Function checks if the placeholders are present
	 * 
	 * @return true if the placeholders are present, false otherwise
	 */
	private boolean checkForPlaceholders()
	{
		// TODO this should be more robust...
		NodeList hdds = findNodes( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk" );
		for ( int i = 0; i < hdds.getLength(); i++ ) {
			Element hdd = (Element)hdds.item( i );
			if ( hdd == null )
				continue;
			if ( hdd.getAttribute( "location" ).equals( DUMMY_VALUE ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Called during init(), prunes the DOM from the elements blacklisted defined
	 * in the member blacklist, a list of XPath expressions as String
	 * 
	 * @throws XPathExpressionException
	 */
	private void removeBlacklistedElements() throws XPathExpressionException
	{
		// iterate over the blackList
		for ( String blackedTag : BLACKLIST ) {
			XPathExpression blackedExpr = XmlHelper.compileXPath( blackedTag );
			NodeList blackedNodes = (NodeList)blackedExpr.evaluate( this.doc, XPathConstants.NODESET );
			for ( int i = 0; i < blackedNodes.getLength(); i++ ) {
				// go through the child nodes of the blacklisted ones -> why?
				Element child = (Element)blackedNodes.item( i );
				removeNode( child );
			}
		}
	}

	/**
	 * Getter for the display name
	 *
	 * @return the display name of this VM
	 */
	public String getDisplayName()
	{
		try {
			return XmlHelper.compileXPath( "/VirtualBox/Machine/@name" ).evaluate( this.doc );
		} catch ( XPathExpressionException e ) {
			return "";
		}
	}

	/**
	 * Function finds and saves the name of the guest OS
	 * 
	 * @throws XPathExpressionException failed to find and retrieve name of the guest OS.
	 */
	public void setOsType() throws XPathExpressionException
	{
		String os = XmlHelper.compileXPath( "/VirtualBox/Machine/@OSType" ).evaluate( this.doc );
		if ( os != null && !os.isEmpty() ) {
			osName = os;
		}
	}

	/**
	 * Getter for the parsed guest OS name
	 *
	 * @return name of the guest OS
	 */
	public String getOsName()
	{
		return osName;
	}

	/**
	 * Search for attached hard drives and determine their controller and their path.
	 *
	 * @throws XPathExpressionException failed to find attached hard drives and their controllers.
	 */
	public void setHdds() throws XPathExpressionException
	{
		final XPathExpression hddsExpr = XmlHelper.compileXPath( storageControllersPath()
				+ "/StorageController/AttachedDevice[@type='HardDisk']/Image" );

		NodeList nodes = (NodeList)hddsExpr.evaluate( this.doc, XPathConstants.NODESET );
		if ( nodes == null ) {
			LOGGER.error( "Failed to find attached hard drives." );
			return;
		}
		for ( int i = 0; i < nodes.getLength(); i++ ) {
			Element hddElement = (Element)nodes.item( i );
			if ( hddElement == null )
				continue;
			String uuid = hddElement.getAttribute( "uuid" );
			if ( uuid.isEmpty() )
				continue;
			// got uuid, check if it was registered
			XPathExpression hddsRegistered = XmlHelper.compileXPath( "/VirtualBox/Machine/MediaRegistry/HardDisks/HardDisk[@uuid='" + uuid + "']" );
			NodeList hddsRegisteredNodes = (NodeList)hddsRegistered.evaluate( this.doc, XPathConstants.NODESET );
			if ( hddsRegisteredNodes == null || hddsRegisteredNodes.getLength() != 1 ) {
				LOGGER.error( "Found hard disk with uuid '" + uuid + "' which does not appear (unique) in the Media Registry. Skipping." );
				continue;
			}
			Element hddElementReg = (Element)hddsRegisteredNodes.item( 0 );
			if ( hddElementReg == null )
				continue;
			String fileName = hddElementReg.getAttribute( "location" );
			String type = hddElementReg.getAttribute( "type" );
			if ( !type.equals( "Normal" ) && !type.equals( "Writethrough" ) ) {
				LOGGER.warn( "Type of the disk file is neither 'Normal' nor 'Writethrough' but: " + type );
				LOGGER.warn( "This makes the image not directly modificable, which might lead to problems when editing it locally." );
			}
			// search if it is also attached to a controller
			Node hddDevice = hddElement.getParentNode();
			if ( hddDevice == null ) {
				LOGGER.error( "HDD node had a null parent, shouldn't happen" );
				continue;
			}
			Element hddController = (Element)hddDevice.getParentNode();
			if ( hddController == null ) {
				LOGGER.error( "HDD node had a null parent, shouldn't happen" );
				continue;
			}
			String controllerMode = hddController.getAttribute( "type" );
			String controllerType = hddController.getAttribute( "name" );
			DriveBusType busType;
			if ( controllerType.equals( "NVMe" ) ) {
				busType = DriveBusType.NVME;
			} else {
				try {
					// This assumes the type in the xml matches our enum constants.
					busType = DriveBusType.valueOf( controllerType );
				} catch (Exception e) {
					LOGGER.warn( "Skipping unknown HDD controller type '" + controllerType + "'" );
					continue;
				}
			}
			LOGGER.info( "Adding hard disk with controller: " + busType + " (" + controllerMode + ") from file '" + fileName + "'." );
			hddsArray.add( new HardDisk( controllerMode, busType, fileName ) );
		}
	}

	/**
	 * Getter for the list of detected hard drives.
	 *
	 * @return list of disk drives.
	 */
	public ArrayList<HardDisk> getHdds()
	{
		return hddsArray;
	}

	/**
	 * Detect if the vbox file has any machine snapshot by looking at
	 * the existance of '/VirtualBox/Machine/Snapshot' elements.
	 * 
	 * @return true if a machine snapshot is present, false otherwise.
	 */
	public boolean isMachineSnapshot()
	{
		// check if the vbox configuration file contains some machine snapshots.
		// by looking at the existance of /VirtualBox/Machine/Snapshot
		NodeList machineSnapshots = findNodes( "/VirtualBox/Machine/Snapshot" );
		return machineSnapshots != null && machineSnapshots.getLength() > 0;
	}

	/**
	 * Searches the DOM for the elements matching the given XPath expression.
	 * 
	 * @param xpath expression to search the DOM with
	 * @return nodes found by evaluating given XPath expression
	 */
	public NodeList findNodes( String xpath )
	{
		NodeList nodes = null;
		try {
			XPathExpression expr = XmlHelper.compileXPath( xpath );
			Object nodesObject = expr.evaluate( this.doc, XPathConstants.NODESET );
			nodes = (NodeList)nodesObject;
		} catch ( XPathExpressionException e ) {
			LOGGER.error( "Could not build path", e );
		}
		return nodes;
	}

	/**
	 * Function used to change the value of an attribute of given element(s).
	 *
	 * @param elementXPath given as an xpath expression
	 * @param attribute attribute to change
	 * @param value to set the attribute to
	 * @param mode what to do if multiple nodes match XPath
	 * @return state of the change operation whether the attribute was changed successfully or not.
	 */
	public boolean changeAttribute( String elementXPath, String attribute, String value, MatchMode mode )
	{
		NodeList nodes = findNodes( elementXPath );
		if ( nodes == null || nodes.getLength() == 0 ) {
			if ( mode != MatchMode.MULTIPLE ) {
				LOGGER.error( "No node could be found for: " + elementXPath );
			}
			return false;
		}
		if ( nodes.getLength() != 1 && ( mode == MatchMode.EXACTLY_ONE ) ) {
			LOGGER.error( "Multiple nodes found for: " + elementXPath );
			return false;
		}
		boolean ret = true;
		for ( int i = 0; i < nodes.getLength(); ++i ) {
			if ( !addAttributeToNode( nodes.item( i ), attribute, value ) ) {
				ret = false;
			}
			if ( mode == MatchMode.FIRST_ONLY )
				break;
		}
		return ret;
	}

	/**
	 * Add given attribute with given value to the given node.
	 * NOTE: this will overwrite the attribute of the node if it already exists.
	 *
	 * @param node to add the attribute to
	 * @param attribute attribute to add to the node
	 * @param value of the attribute
	 * @return true if successful, false otherwise
	 */
	public boolean addAttributeToNode( Node node, String attribute, String value )
	{
		if ( node == null || node.getNodeType() != Node.ELEMENT_NODE ) {
			LOGGER.error( "Trying to change attribute of a non element node!" );
			return false;
		}
		try {
			( (Element)node ).setAttribute( attribute, value );
		} catch ( DOMException e ) {
			LOGGER.error( "Failed set '" + attribute + "' to '" + value + "' of xml node '" + node.getNodeName() + "': ", e );
			return false;
		}
		return true;
	}

	/**
	 * Adds a new node named nameOfNewNode to the given parent found by parentXPath.
	 *
	 * @param parentXPath XPath expression to the parent
	 * @param childName name of the node to be added
	 * @return the newly added Node
	 */
	public Element addNewNode( String parentXPath, String childName )
	{
		NodeList possibleParents = findNodes( parentXPath );
		if ( possibleParents == null || possibleParents.getLength() != 1 ) {
			LOGGER.error( "Could not find unique parent node to add new node to: " + parentXPath );
			return null;
		}
		return addNewNode( possibleParents.item( 0 ), childName );
	}

	public Element createNodeRecursive( String xPath )
	{
		String[] nodeNames = xPath.split( "/" );
		Node parent = this.doc;
		Element latest = null;
		for ( int nodeIndex = 0; nodeIndex < nodeNames.length; ++nodeIndex ) {
			if ( nodeNames[nodeIndex].length() == 0 )
				continue;
			Node node = skipNonElementNodes( parent.getFirstChild() );
			while ( node != null ) {
				if ( node.getNodeType() == Node.ELEMENT_NODE && nodeNames[nodeIndex].equals( node.getNodeName() ) )
					break; // Found existing
				// Check next on same level
				node = skipNonElementNodes( node.getNextSibling() );
			}
			if ( node == null ) {
				node = doc.createElement( nodeNames[nodeIndex] );
				parent.appendChild( node );
			}
			parent = node;
			latest = (Element)node;
		}
		return latest;
	}
	
	private Element skipNonElementNodes( Node nn )
	{
		while ( nn != null && nn.getNodeType() != Node.ELEMENT_NODE ) {
			nn = nn.getNextSibling();
		}
		return (Element)nn;
	}
	
	public void setExtraData( String key, String value )
	{
		NodeList nl = findNodes( "/VirtualBox/Machine/ExtraData/ExtraDataItem" );
		Element e = null;
		if ( nl != null ) {
			for ( int i = 0; i < nl.getLength(); ++i ) {
				Node n = nl.item( i );
				if ( n.getNodeType() == Node.ELEMENT_NODE ) {
					final Element ne = (Element)n;
					final String keyValue = ne.getAttribute( "name" );
					if ( keyValue != null && keyValue.equals( key ) ) {
						e = ne;
						break;
					}
				}
			}
		}
		if ( e == null ) {
			Element p = createNodeRecursive( "/VirtualBox/Machine/ExtraData" );
			e = addNewNode( p, "ExtraDataItem" );
			e.setAttribute( "name", key );
		}
		e.setAttribute( "value", value );
	}

	/**
	 * Creates a new element to the given parent node.
	 *
	 * @param parent to add the new element to
	 * @param childName name of the new element to create
	 * @return the newly created node
	 */
	public Element addNewNode( Node parent, String childName )
	{
		if ( parent == null || parent.getNodeType() != Node.ELEMENT_NODE ) {
			return null;
		}
		Element newNode = null;
		try {
			newNode = doc.createElement( childName );
			parent.appendChild( newNode );
		} catch ( DOMException e ) {
			LOGGER.error( "Failed to add '" + childName + "' to '" + parent.getNodeName() + "'." );
		}
		return newNode;
	}

	/**
	 * Helper to remove given node from the DOM.
	 *
	 * @param node Node object to remove.
	 */
	private void removeNode( Node node )
	{
		if ( node == null )
			return;
		Node parent = node.getParentNode();
		if ( parent != null )
			parent.removeChild( node );
	}

	/**
	 * Helper to output the DOM as a String.
	 *
	 * @param prettyPrint sets whether to indent the output
	 * @return (un-)formatted XML
	 */
	public String toString( boolean prettyPrint )
	{
		return XmlHelper.getXmlFromDocument( doc, prettyPrint );
	}

	/**
	 * Remove all nodes with name childName from parentPath
	 * @param parentPath XPath to parent node of where child nodes are to be deleted
	 * @param childName Name of nodes to delete
	 */
	public void removeNodes( String parentPath, String childName )
	{
		NodeList parentNodes = findNodes( parentPath );
		// XPath might match multiple nodes
		for ( int i = 0; i < parentNodes.getLength(); ++i ) {
			Node parent = parentNodes.item( i );
			List<Node> delList = new ArrayList<>( 0 );
			// Iterate over child nodes
			for ( Node child = parent.getFirstChild(); child != null; child = child.getNextSibling() ) {
				if ( childName.equals( child.getNodeName() ) ) {
					// Remember all to be deleted (don't delete while iterating)
					delList.add( child );
				}
			}
			// Now delete them all
			for ( Node child : delList ) {
				parent.removeChild( child );
			}
		}
	}
}