diff --git a/src/main/java/org/cyclonedx/parsers/BomParserFactory.java b/src/main/java/org/cyclonedx/parsers/BomParserFactory.java index c0bae141c9..6e40d74d5e 100644 --- a/src/main/java/org/cyclonedx/parsers/BomParserFactory.java +++ b/src/main/java/org/cyclonedx/parsers/BomParserFactory.java @@ -20,14 +20,13 @@ import org.apache.commons.io.IOUtils; import org.cyclonedx.exception.ParseException; -import org.cyclonedx.parsers.JsonParser; -import org.cyclonedx.parsers.Parser; -import org.cyclonedx.parsers.XmlParser; + import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.Arrays; public class BomParserFactory { @@ -35,21 +34,23 @@ private BomParserFactory() {} public static Parser createParser(final File file) throws ParseException { try (final InputStream fis = Files.newInputStream(file.toPath())) { - final byte[] bytes = IOUtils.toByteArray(fis, 1); - return createParser(bytes); + final byte[] prefix = new byte[4]; // potential 3-byte UTF-8 byte-order mark + 1 content byte + final int actualPrefixLength = IOUtils.read(fis, prefix); + return createParser(Arrays.copyOf(prefix, actualPrefixLength)); } catch (IOException e) { throw new ParseException("An error occurred creating parser from file", e); } } public static Parser createParser(final byte[] bytes) throws ParseException { - if(bytes.length < 1) { + final int offset = hasUtf8ByteOrderMark(bytes) ? 3 : 0; + if (bytes.length - offset < 1) { throw new ParseException("Cannot create parser from empty byte array."); } - if (bytes[0] == 123) { + if (bytes[offset] == (byte) '{') { return new JsonParser(); - } else if (bytes[0] == 60) { + } else if (bytes[offset] == (byte) '<') { return new XmlParser(); } else { throw new ParseException("The specified BOM is not in a supported format. Supported formats are XML and JSON"); @@ -57,7 +58,8 @@ public static Parser createParser(final byte[] bytes) throws ParseException { } public static boolean looksLikeCycloneDX(final byte[] bytes) { - final String bomString = new String(bytes, StandardCharsets.UTF_8); + final int offset = hasUtf8ByteOrderMark(bytes) ? 3 : 0; + final String bomString = new String(bytes, offset, bytes.length - offset, StandardCharsets.UTF_8); if (bomString.startsWith("= 3 + && bytes[0] == (byte) 0xEF + && bytes[1] == (byte) 0xBB + && bytes[2] == (byte) 0xBF; + } + } diff --git a/src/main/java/org/cyclonedx/parsers/JsonParser.java b/src/main/java/org/cyclonedx/parsers/JsonParser.java index ec73014b7c..8e97f3a117 100644 --- a/src/main/java/org/cyclonedx/parsers/JsonParser.java +++ b/src/main/java/org/cyclonedx/parsers/JsonParser.java @@ -160,7 +160,7 @@ public List validate(final InputStream inputStream, final Versio * @return a list of exceptions encountered during validation */ public List validate(final String bomString, final Version schemaVersion) throws IOException { - return validate(mapper.readTree(bomString), schemaVersion); + return validate(mapper.readTree(skipUtf8Bom(bomString)), schemaVersion); } /** @@ -244,4 +244,12 @@ public boolean isValid(final InputStream inputStream) throws IOException { public boolean isValid(final InputStream inputStream, final Version schemaVersion) throws IOException { return validate(inputStream, schemaVersion).isEmpty(); } + + private static String skipUtf8Bom(String string) { + // NB: Jackson does not strip a UTF-8 BOM when reading from a String, + // but it DOES do that for byte-based input. + return string.startsWith("\uFEFF") + ? string.substring(1) + : string; + } } diff --git a/src/test/java/org/cyclonedx/BomParserFactoryTest.java b/src/test/java/org/cyclonedx/BomParserFactoryTest.java index fcf763b075..780402e649 100644 --- a/src/test/java/org/cyclonedx/BomParserFactoryTest.java +++ b/src/test/java/org/cyclonedx/BomParserFactoryTest.java @@ -24,9 +24,11 @@ import org.cyclonedx.parsers.Parser; import org.cyclonedx.parsers.XmlParser; import org.junit.jupiter.api.Test; + import java.io.File; -import java.util.Objects; +import java.nio.file.Files; +import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -35,17 +37,41 @@ public class BomParserFactoryTest { @Test public void testXMLFactory() throws Exception { Parser parser = BomParserFactory.createParser( - new File(Objects.requireNonNull(BomParserFactory.class.getResource("/bom-1.2.xml")).getFile())); + new File(requireNonNull(BomParserFactory.class.getResource("/bom-1.2.xml")).getFile())); assertInstanceOf(XmlParser.class, parser); } @Test public void testJSONFactory() throws Exception { Parser parser = BomParserFactory.createParser(new File( - Objects.requireNonNull(BomParserFactory.class.getResource("/bom-1.2.json")).getFile())); + requireNonNull(BomParserFactory.class.getResource("/bom-1.2.json")).getFile())); + assertInstanceOf(JsonParser.class, parser); + } + + @Test + public void testXMLFactoryWithUtf8ByteOrderMarker() throws Exception { + Parser parser = BomParserFactory.createParser( + new File(requireNonNull(BomParserFactory.class.getResource("/bom-1.2-utf8bom.xml")).getFile())); + assertInstanceOf(XmlParser.class, parser); + } + + @Test + public void testJSONFactoryWithUtf8ByteOrderMarker() throws Exception { + Parser parser = BomParserFactory.createParser(new File( + requireNonNull(BomParserFactory.class.getResource("/bom-1.2-utf8bom.json")).getFile())); assertInstanceOf(JsonParser.class, parser); } + @Test + public void testFactoryFromByteArrayWithUtf8ByteOrderMarker() throws Exception { + byte[] xml = Files.readAllBytes(new File( + requireNonNull(BomParserFactory.class.getResource("/bom-1.2-utf8bom.xml")).getFile()).toPath()); + byte[] json = Files.readAllBytes(new File( + requireNonNull(BomParserFactory.class.getResource("/bom-1.2-utf8bom.json")).getFile()).toPath()); + assertInstanceOf(XmlParser.class, BomParserFactory.createParser(xml)); + assertInstanceOf(JsonParser.class, BomParserFactory.createParser(json)); + } + @Test() public void testFactoryThrowsParseExceptionWithEmptyData() { byte[] emptyData = new byte[]{}; diff --git a/src/test/java/org/cyclonedx/parsers/JsonParserTest.java b/src/test/java/org/cyclonedx/parsers/JsonParserTest.java index 47d8327a0e..0bf457423f 100644 --- a/src/test/java/org/cyclonedx/parsers/JsonParserTest.java +++ b/src/test/java/org/cyclonedx/parsers/JsonParserTest.java @@ -20,14 +20,19 @@ import org.cyclonedx.Version; import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Citation; import org.cyclonedx.model.Component; import org.cyclonedx.model.Component.Type; import org.cyclonedx.model.Dependency; import org.cyclonedx.model.ExternalReference; -import org.cyclonedx.model.TlpClassification; import org.cyclonedx.model.License; import org.cyclonedx.model.LicenseChoice; import org.cyclonedx.model.OrganizationalEntity; +import org.cyclonedx.model.Patent; +import org.cyclonedx.model.PatentAssertion; +import org.cyclonedx.model.PatentFamily; +import org.cyclonedx.model.PatentItem; +import org.cyclonedx.model.TlpClassification; import org.cyclonedx.model.attestation.Assessor; import org.cyclonedx.model.attestation.Attestation; import org.cyclonedx.model.attestation.AttestationMap; @@ -64,14 +69,10 @@ import org.cyclonedx.model.definition.Standard; import org.cyclonedx.model.license.Acknowledgement; import org.cyclonedx.model.license.Expression; -import org.cyclonedx.model.license.ExpressionDetailed; import org.cyclonedx.model.license.ExpressionDetail; -import org.cyclonedx.model.Citation; -import org.cyclonedx.model.Patent; -import org.cyclonedx.model.PatentFamily; -import org.cyclonedx.model.PatentAssertion; -import org.cyclonedx.model.PatentItem; +import org.cyclonedx.model.license.ExpressionDetailed; import org.junit.jupiter.api.Test; + import java.io.File; import java.util.ArrayList; import java.util.Arrays; @@ -98,6 +99,15 @@ public void testValid12Bom() throws Exception { System.out.println(bom.getSerialNumber()); } + @Test + public void testValid12BomWithUtf8ByteOrderMarker() throws Exception { + final File file = new File(Objects.requireNonNull(this.getClass().getResource("/bom-1.2-utf8bom.json")).getFile()); + final JsonParser parser = new JsonParser(); + assertNotNull(parser.parse(file)); + assertNotNull(getJsonBom("bom-1.2-utf8bom.json")); + assertTrue(parser.isValid(file, Version.VERSION_12)); + } + @Test public void testParsedObjects12Bom() throws Exception { final Bom bom = getJsonBom("bom-1.2.json"); diff --git a/src/test/java/org/cyclonedx/parsers/XmlParserTest.java b/src/test/java/org/cyclonedx/parsers/XmlParserTest.java index 44d5a55da0..eb25bbf887 100644 --- a/src/test/java/org/cyclonedx/parsers/XmlParserTest.java +++ b/src/test/java/org/cyclonedx/parsers/XmlParserTest.java @@ -136,6 +136,15 @@ public void testValid12Bom() throws Exception { assertTrue(valid); } + @Test + public void testValid12BomWithUtf8ByteOrderMarker() throws Exception { + final File file = new File(Objects.requireNonNull(this.getClass().getResource("/bom-1.2-utf8bom.xml")).getFile()); + final XmlParser parser = new XmlParser(); + assertNotNull(parser.parse(file)); + assertNotNull(getXmlBom("bom-1.2-utf8bom.xml")); + assertTrue(parser.isValid(file, Version.VERSION_12)); + } + @Test public void testValidBomLink() throws Exception { final File file = new File(Objects.requireNonNull(this.getClass().getResource("/bom-1.4-bomlink.xml")).getFile()); diff --git a/src/test/resources/bom-1.2-utf8bom.json b/src/test/resources/bom-1.2-utf8bom.json new file mode 100644 index 0000000000..8706b720de --- /dev/null +++ b/src/test/resources/bom-1.2-utf8bom.json @@ -0,0 +1 @@ +{"bomFormat":"CycloneDX","specVersion":"1.2","version":1} diff --git a/src/test/resources/bom-1.2-utf8bom.xml b/src/test/resources/bom-1.2-utf8bom.xml new file mode 100644 index 0000000000..df5fbbd0a0 --- /dev/null +++ b/src/test/resources/bom-1.2-utf8bom.xml @@ -0,0 +1,2 @@ + +