package org.ovirt.engine.core.bll.storage.disk.image; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Map; import java.util.TreeMap; import java.util.regex.Pattern; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.binary.Hex; import org.apache.commons.lang.StringUtils; import org.ovirt.engine.core.common.AuditLogType; import org.ovirt.engine.core.common.businessentities.storage.Disk; import org.ovirt.engine.core.common.utils.ValidationUtils; import org.ovirt.engine.core.dal.dbbroker.auditloghandling.AuditLogDirector; import org.ovirt.engine.core.dal.dbbroker.auditloghandling.AuditLogable; import org.ovirt.engine.core.dal.dbbroker.auditloghandling.AuditLogableImpl; import org.ovirt.engine.core.utils.JsonHelper; public class MetadataDiskDescriptionHandler { private static final MetadataDiskDescriptionHandler instance = new MetadataDiskDescriptionHandler(); private static final String DISK_ALIAS = "DiskAlias"; private static final String DISK_DESCRIPTION = "DiskDescription"; private static final String DISK_ENCODING = "Enc"; private static final String[] DESCRIPTION_FIELDS_PRIORITY = {DISK_ALIAS, DISK_DESCRIPTION}; private static final Pattern ASCII_PATTERN = Pattern.compile(ValidationUtils.ONLY_ASCII_OR_NONE); // We are intentionally using UTF_16LE so we can encode and decode on any machine. private static final Charset CHARSET = StandardCharsets.UTF_16LE; // Encoding with HEX which is based on utf-16LE gives a minimum of 4 bytes per character. private static final int MIN_ENCODING_RATIO_WITH_HEX_USING_UTF_16LE = 4; // Encoding with HEX which is based on utf-16LE gives a maximum of 8 bytes per character. private static final int MAX_ENCODING_RATIO_WITH_HEX_USING_UTF_16LE = 8; /** * We can utilize 210 bytes from the disk metadata for its description. * Since we use JSON, the actual number of bytes we can use is 208 (2 bytes for the braces). */ private static final int METADATA_DESCRIPTION_MAX_LENGTH = 208; private MetadataDiskDescriptionHandler() { } public static MetadataDiskDescriptionHandler getInstance() { return instance; } /** * Creates and returns a Json string containing the disk alias, description and encoding. * Since there's not always enough space for both alias and description, they are added according to the priority * noted by DESCRIPTION_FIELDS_PRIORITY. * The disk alias and description are preserved in the disk meta data. If the meta data will be added with more * fields, UpdateVmDiskCommand should be changed accordingly. */ public String generateJsonDiskDescription(Disk disk) throws IOException { Map<String, Object> description = new TreeMap<>(); description.put(DISK_ALIAS, disk.getDiskAlias()); description.put(DISK_DESCRIPTION, StringUtils.defaultString(disk.getDiskDescription())); return JsonHelper.mapToJson(generateJsonDiskDescription(description, DESCRIPTION_FIELDS_PRIORITY), false); } private Map<String, Object> generateJsonDiskDescription(Map<String, Object> descriptionFields, String... descriptionFieldsPriority) { Map<String, Object> descriptionMap = new TreeMap<>(); int descriptionAvailableLength = METADATA_DESCRIPTION_MAX_LENGTH; for (int priority = 0; priority < descriptionFieldsPriority.length; ++priority) { String fieldName = descriptionFieldsPriority[priority]; String fieldValue = descriptionFields.get(fieldName).toString(); descriptionAvailableLength = addFieldToDescriptionMap(descriptionMap, fieldName, fieldValue, 1 << priority, descriptionAvailableLength); boolean fieldValueContainsOnlyAscii = stringMatchesAsciiPattern(fieldValue); if ((fieldValueContainsOnlyAscii && descriptionAvailableLength <= 0) || (!fieldValueContainsOnlyAscii && descriptionAvailableLength < MAX_ENCODING_RATIO_WITH_HEX_USING_UTF_16LE)) { // Storage space limitation reached. if (!auditLogIfFieldWasNotAddedSuccessfully(descriptionFields, descriptionMap, fieldName, fieldValue, descriptionFieldsPriority, priority)) { break; } } } return descriptionMap; } /** * Returns true iff the field noted by fieldName was added successfully. */ private boolean auditLogIfFieldWasNotAddedSuccessfully(Map<String, Object> descriptionFields, Map<String, Object> descriptionMap, String fieldName, String fieldValue, String[] descriptionFieldsPriority, int fieldPriorityIndex) { String diskAlias = descriptionFields.get(DISK_ALIAS).toString(); String storedFieldValue = (String) descriptionMap.get(fieldName); if (storedFieldValue == null) { // The field wasn't stored due to a space limitation. auditLogFailedToStoreDiskFields( diskAlias, getDiskFieldsNamesLeft(descriptionFieldsPriority, fieldPriorityIndex)); return false; } else { // The field's value that should have been stored (before truncation, if one was performed). String fullFieldValue = stringMatchesAsciiPattern(fieldValue) ? fieldValue : encodeDiskProperty(fieldValue); if (storedFieldValue.length() < fullFieldValue.length()) { // The field was stored but it was truncated due to a space limitation. if (descriptionFieldsPriority.length - fieldPriorityIndex > 1) { // At least one field will be entirely dropped. auditLogDiskFieldTruncatedAndOthersWereLost(diskAlias, fieldName, getDiskFieldsNamesLeft(descriptionFieldsPriority, fieldPriorityIndex + 1)); } else { // Only the last field was truncated. auditLogDiskFieldTruncated(diskAlias, fieldName); } return false; } } return true; } private String getDiskFieldsNamesLeft(String[] descriptionFieldsPriority, int priority) { String[] descriptionFieldsLeft = Arrays.copyOfRange(descriptionFieldsPriority, priority, descriptionFieldsPriority.length); return StringUtils.join(descriptionFieldsLeft, ", ", 0, descriptionFieldsLeft.length); } /** * Adds the given field to the map and returns the up to date descriptionAvailableLength (number of bytes left to * store in the disk metadata description field). */ private int addFieldToDescriptionMap(Map<String, Object> descriptionMap, String fieldName, String fieldValue, int fieldPriority, int descriptionAvailableLength) { boolean encodeField = false; String curFieldValue = fieldValue; if (!stringMatchesAsciiPattern(curFieldValue)) { // This field contains non-ASCII characters and thus should be encoded. encodeField = true; curFieldValue = encodeDiskProperty(curFieldValue); descriptionAvailableLength -= addEditEncodingField(descriptionMap, fieldPriority); } if ((descriptionAvailableLength -= calculateJsonFieldPotentialOverhead(fieldName, descriptionMap)) > 0) { // There's enough space for the field's overhead in the metadata + at least one character from its value. if (curFieldValue.length() > descriptionAvailableLength) { curFieldValue = truncateDiskProperty(fieldValue, descriptionAvailableLength, encodeField); } descriptionMap.put(fieldName, curFieldValue); return descriptionAvailableLength - curFieldValue.length(); } return descriptionAvailableLength; } private boolean stringMatchesAsciiPattern(String str) { return ASCII_PATTERN.matcher(str).matches(); } /** * Edits the encoding field in case there's already an encoded field in the map, or adds a new one in case it's * the first one to be encoded. The encoding field is a number that represents the fields that were encoded. * If a bit is on, the corresponding field in DESCRIPTION_FIELDS_PRIORITY is encoded. * For example, Enc=2 (10 in binary) means that the second field in DESCRIPTION_FIELDS_PRIORITY (DISK_DESCRIPTION) * is encoded. Enc=3 (11 in binary) means that the first and the second fields are both encoded. * The method returns the number of characters spent by editing/adding the encoding field. */ private int addEditEncodingField(Map<String, Object> descriptionMap, int fieldPriority) { String oldEncodingFieldValue = (String) descriptionMap.get(DISK_ENCODING); if (oldEncodingFieldValue == null) { // This is the first field to be encoded. String fieldPriorityStr = String.valueOf(fieldPriority); descriptionMap.put(DISK_ENCODING, fieldPriorityStr); return generateJsonField(DISK_ENCODING, fieldPriorityStr).length(); } // There's already an encoded field in the map. String newEncodingFieldValue = String.valueOf(Integer.parseInt(oldEncodingFieldValue) | fieldPriority); descriptionMap.put(DISK_ENCODING, newEncodingFieldValue); return newEncodingFieldValue.length() - oldEncodingFieldValue.length(); } private String encodeDiskProperty(String diskProperty) { return Hex.encodeHexString(diskProperty.getBytes(CHARSET)); } private int calculateJsonFieldPotentialOverhead(String fieldName, Map<String, Object> descriptionMap) { return generateJsonField(fieldName, StringUtils.EMPTY).length() + (descriptionMap.isEmpty() ? 0 : 1); // for the comma. } protected String generateJsonField(String fieldName, String fieldValue) { return String.format("\"%s\":\"%s\"", fieldName, fieldValue); } private String truncateDiskProperty(String diskProperty, int maxLength, boolean encodeProperty) { if (encodeProperty) { // We encode with HEX based on utf-16LE, so that each char is encoded to 4-8 bytes. // Thus, we can save iterations by truncating the original string by 4. diskProperty = diskProperty.substring(0, maxLength / MIN_ENCODING_RATIO_WITH_HEX_USING_UTF_16LE); String encodedProperty = StringUtils.EMPTY; for (int i = diskProperty.length(); i > 0; --i) { encodedProperty = encodeDiskProperty(diskProperty.substring(0, i)); if (encodedProperty.length() <= maxLength) { break; } } return encodedProperty; } return diskProperty.substring(0, maxLength); } private void auditLogDiskFieldTruncated(String diskAlias, String fieldName) { AuditLogable logable = createDiskEvent(diskAlias); logable.addCustomValue("DiskFieldName", fieldName); getAuditLogDirector().log(logable, AuditLogType.FAILED_TO_STORE_ENTIRE_DISK_FIELD_IN_DISK_DESCRIPTION_METADATA); } private void auditLogDiskFieldTruncatedAndOthersWereLost(String diskAlias, String fieldName, String diskFieldsNames) { AuditLogable logable = createDiskEvent(diskAlias); logable.addCustomValue("DiskFieldName", fieldName); logable.addCustomValue("DiskFieldsNames", diskFieldsNames); getAuditLogDirector().log(logable, AuditLogType.FAILED_TO_STORE_ENTIRE_DISK_FIELD_AND_REST_OF_FIELDS_IN_DISK_DESCRIPTION_METADATA); } private void auditLogFailedToStoreDiskFields(String diskAlias, String diskFieldsNames) { AuditLogable logable = createDiskEvent(diskAlias); logable.addCustomValue("DiskFieldsNames", diskFieldsNames); getAuditLogDirector().log(logable, AuditLogType.FAILED_TO_STORE_DISK_FIELDS_IN_DISK_DESCRIPTION_METADATA); } private AuditLogable createDiskEvent(String diskAlias) { AuditLogable logable = new AuditLogableImpl(); logable.addCustomValue("DiskAlias", diskAlias); return logable; } protected AuditLogDirector getAuditLogDirector() { return new AuditLogDirector(); } public void enrichDiskByJsonDescription(String jsonDiskDescription, Disk disk) throws IOException, DecoderException { Map<String, Object> diskDescriptionMap = JsonHelper.jsonToMap(jsonDiskDescription); String encodingStr = (String) diskDescriptionMap.get(DISK_ENCODING); if (encodingStr != null) { int encoding = Integer.parseInt(encodingStr); for (int priority = 0; priority < DESCRIPTION_FIELDS_PRIORITY.length; ++priority) { if (((1 << priority) & encoding) != 0) { // The field is encoded and thus needs to be decoded. String fieldName = DESCRIPTION_FIELDS_PRIORITY[priority]; String fieldValue = (String) diskDescriptionMap.get(fieldName); diskDescriptionMap.put(fieldName, decodeDiskProperty(fieldValue)); } } } // Reaching here means that no DecoderException was thrown by any of the calls to decodeDiskProperty. // This way we can promise that we save both diskAlias and diskDescription to the disk or non of them. disk.setDiskAlias((String) diskDescriptionMap.get(DISK_ALIAS)); disk.setDiskDescription((String) diskDescriptionMap.get(DISK_DESCRIPTION)); } private String decodeDiskProperty(String diskProperty) throws DecoderException { return new String(Hex.decodeHex(diskProperty.toCharArray()), CHARSET); } }