VisioPackageValidator.java

package com.taxonomy.export;

import com.taxonomy.visio.VisioConnect;
import com.taxonomy.visio.VisioDocument;
import com.taxonomy.visio.VisioPage;
import com.taxonomy.visio.VisioShape;
import com.taxonomy.visio.VisioProperty;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** Validates the bounded in-memory model before any VSDX bytes are emitted. */
final class VisioPackageValidator {

    static final long MAX_VISIO_ID = 0xffff_ffffL;

    private VisioPackageValidator() {
    }

    static void validate(VisioDocument document) {
        if (document == null) {
            throw invalid("Visio document must not be null");
        }
        List<VisioPage> pages = document.getPages();
        if (pages == null) {
            throw invalid("Visio page collection must not be null");
        }

        if (pages.isEmpty() || pages.size() > 32) throw invalid("Visio profile requires 1 to 32 pages");
        validateProperties(document.getProperties());
        for (var loss : document.getLosses()) {
            if (loss == null) throw invalid("Missing loss entry");
            for (String text : List.of(loss.scope(), loss.id(), loss.field(), loss.kind(), loss.rationale())) validateXmlText(text, "Loss report");
        }
        java.util.Set<Long> pageIds = new java.util.HashSet<>();
        long shapeCount = 0, connectorCount = 0;
        for (int pageIndex = 0; pageIndex < pages.size(); pageIndex++) {
            VisioPage page = pages.get(pageIndex);
            if (page == null) {
                throw invalid("Page " + pageIndex + " must not be null");
            }
            if (page.getId() == null || !page.getId().matches("0|[1-9][0-9]*")) throw invalid("Page ID must be a canonical unsigned integer");
            long pageId;
            try { pageId = Long.parseLong(page.getId()); } catch (NumberFormatException e) { throw invalid("Page ID out of range", e); }
            if (pageId > MAX_VISIO_ID || !pageIds.add(pageId)) throw invalid("Duplicate or out-of-range page ID");
            validateXmlText(page.getName(), "Page " + pageIndex + " name");

            List<VisioShape> shapes = page.getShapes();
            if (shapes == null) {
                throw invalid("Shape collection on page " + pageIndex + " must not be null");
            }
            List<VisioConnect> connects = page.getConnects();
            if (connects == null) {
                throw invalid("Connect collection on page " + pageIndex + " must not be null");
            }

            shapeCount += shapes.size(); connectorCount += connects.size();
            if (shapeCount > 20_000 || connectorCount > 60_000) throw invalid("Visio profile shape/connector limit exceeded");
            Map<Long, VisioShape> shapesById = new HashMap<>();
            long maximumShapeId = 0;
            for (int shapeIndex = 0; shapeIndex < shapes.size(); shapeIndex++) {
                VisioShape shape = shapes.get(shapeIndex);
                if (shape == null) {
                    throw invalid("Shape " + shapeIndex + " on page " + pageIndex
                            + " must not be null");
                }

                long shapeId = parseShapeId(
                        shape.getId(), "Shape " + shapeIndex + " on page " + pageIndex);
                if (shapesById.putIfAbsent(shapeId, shape) != null) {
                    throw invalid("Duplicate Visio shape ID " + shapeId
                            + " on page " + pageIndex);
                }
                maximumShapeId = Math.max(maximumShapeId, shapeId);

                validateProperties(shape.getProperties());
                validateXmlText(shape.getText(), "Text of shape " + shapeId);
                if (shape.getType() != null) {
                    validateXmlText(shape.getType(), "Type of shape " + shapeId);
                }
                validateCoordinate(shape.getX(), "PinX of shape " + shapeId, true);
                validateCoordinate(shape.getY(), "PinY of shape " + shapeId, true);
                validateCoordinate(shape.getWidth(), "Width of shape " + shapeId, false);
                validateCoordinate(shape.getHeight(), "Height of shape " + shapeId, false);
                validateCoordinate(
                        shape.getX() + shape.getWidth() / 2.0,
                        "Right page extent of shape " + shapeId,
                        false);
                validateCoordinate(
                        shape.getY() + shape.getHeight() / 2.0,
                        "Top page extent of shape " + shapeId,
                        false);
            }

            if (maximumShapeId > MAX_VISIO_ID - connects.size()) {
                throw invalid("Connector IDs overflow the Visio unsigned-int range on page "
                        + pageIndex);
            }

            for (int connectIndex = 0; connectIndex < connects.size(); connectIndex++) {
                VisioConnect connect = connects.get(connectIndex);
                if (connect == null) {
                    throw invalid("Connector " + connectIndex + " on page " + pageIndex
                            + " must not be null");
                }

                validateProperties(connect.getProperties());
                long fromId = parseShapeId(
                        connect.getFromShape(), "Source of connector " + connectIndex);
                long toId = parseShapeId(
                        connect.getToShape(), "Target of connector " + connectIndex);
                VisioShape source = shapesById.get(fromId);
                if (source == null) {
                    throw invalid("Connector " + connectIndex
                            + " references missing source shape " + fromId);
                }
                VisioShape target = shapesById.get(toId);
                if (target == null) {
                    throw invalid("Connector " + connectIndex
                            + " references missing target shape " + toId);
                }
                validateCoordinate(
                        Math.hypot(
                                target.getX() - source.getX(),
                                target.getY() - source.getY()),
                        "Straight-line length of connector " + connectIndex,
                        false);
                validateXmlText(
                        connect.getRelationType(), "Label of connector " + connectIndex);
            }
        }
    }

    static long parseShapeId(String value, String field) {
        if (value == null || !value.matches("[1-9][0-9]*")) {
            throw invalid(field + " must contain a positive numeric Visio ID");
        }

        try {
            long id = Long.parseLong(value);
            if (id <= 0 || id > MAX_VISIO_ID) {
                throw invalid(field + " must be between 1 and " + MAX_VISIO_ID
                        + ", but was " + value);
            }
            return id;
        } catch (NumberFormatException exception) {
            throw invalid(field + " must contain a positive numeric Visio ID, but was "
                    + value, exception);
        }
    }

    private static void validateCoordinate(double value, String field, boolean allowZero) {
        boolean inRange = Double.isFinite(value) && (allowZero ? value >= 0 : value > 0);
        if (!inRange) {
            throw invalid(field + " must be "
                    + (allowZero ? "finite and non-negative" : "finite and positive")
                    + ", but was " + value);
        }
    }

    static void validateXmlText(String value, String field) {
        if (value == null) {
            throw invalid(field + " must not be null");
        }
        if (value.length() > 32767) throw invalid(field + " exceeds 32767 characters");
        for (int offset = 0; offset < value.length();) {
            int codePoint = value.codePointAt(offset);
            if (!isXml10CodePoint(codePoint)) {
                throw invalid(field + " contains an XML 1.0 control or surrogate character: U+"
                        + Integer.toHexString(codePoint).toUpperCase());
            }
            offset += Character.charCount(codePoint);
        }
    }

    static void validateProperties(Map<String, VisioProperty> properties) {
        if (properties.size() > 64) throw invalid("Too many Visio properties");
        for (var entry : properties.entrySet()) {
            if (entry.getKey() == null || !entry.getKey().matches("taxonomy\\.[A-Za-z][A-Za-z0-9]{0,63}") || entry.getValue() == null) {
                throw invalid("Unsafe Visio property key/value");
            }
            validateXmlText(entry.getValue().value(), entry.getKey());
        }
    }

    private static boolean isXml10CodePoint(int codePoint) {
        return codePoint == 0x9
                || codePoint == 0xA
                || codePoint == 0xD
                || (codePoint >= 0x20 && codePoint <= 0xD7FF)
                || (codePoint >= 0xE000 && codePoint <= 0xFFFD)
                || (codePoint >= 0x10000 && codePoint <= 0x10FFFF);
    }

    private static IllegalArgumentException invalid(String message) {
        return new IllegalArgumentException(message);
    }

    private static IllegalArgumentException invalid(String message, Exception cause) {
        return new IllegalArgumentException(message, cause);
    }
}