Skip to content

Commit a4e596c

Browse files
IOUtils rework (#1865)
1 parent e7b6e8d commit a4e596c

2 files changed

Lines changed: 117 additions & 36 deletions

File tree

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
package io.shiftleft.utils
22

33
import java.io.Reader
4-
import java.nio.charset.{CharsetDecoder, CodingErrorAction}
5-
import java.nio.file.Path
6-
import java.util.regex.Pattern
4+
import java.nio.charset.{CharsetDecoder, CodingErrorAction, StandardCharsets}
5+
import java.nio.file.{Files, Path}
76
import scala.io.{BufferedSource, Codec, Source}
8-
import scala.jdk.CollectionConverters.IteratorHasAsScala
97
import scala.util.Using
108

119
object IOUtils {
1210

13-
private val boms: Set[Char] = Set(
14-
'\uefbb', // UTF-8
15-
'\ufeff', // UTF-16 (BE)
16-
'\ufffe' // UTF-16 (LE)
17-
)
18-
1911
/** Creates a new UTF-8 decoder. Sadly, instances of CharsetDecoder are not thread-safe as the doc states: 'Instances
2012
* of this class are not safe for use by multiple concurrent threads.' (copied from:
2113
* [[java.nio.charset.CharsetDecoder]])
@@ -28,43 +20,30 @@ object IOUtils {
2820
.onMalformedInput(CodingErrorAction.REPLACE)
2921
.onUnmappableCharacter(CodingErrorAction.REPLACE)
3022

23+
/** Skips a leading byte order mark (BOM) if present. Once decoded as UTF-8, a BOM shows up as the single character
24+
* U+FEFF (e.g. the UTF-8 BOM bytes EF BB BF decode to U+FEFF).
25+
*/
3126
private def skipBOMIfPresent(reader: Reader): Unit = {
3227
reader.mark(1)
33-
val possibleBOM = new Array[Char](1)
34-
reader.read(possibleBOM)
35-
if (!boms.contains(possibleBOM(0))) {
28+
if (reader.read() != '\ufeff') {
3629
reader.reset()
3730
}
3831
}
3932

4033
private def contentFromBufferedSource(bufferedSource: BufferedSource): Seq[String] = {
4134
val reader = bufferedSource.bufferedReader()
4235
skipBOMIfPresent(reader)
43-
reader.lines().iterator().asScala.toSeq
44-
}
45-
46-
private def contentStringFromBufferedSource(bufferedSource: BufferedSource): String = {
47-
val reader = bufferedSource.bufferedReader()
48-
val stringBuilder = new StringBuilder
49-
val bufferSize = 1024
50-
var productive = true
51-
52-
skipBOMIfPresent(reader)
53-
while (productive) {
54-
val buffer = new Array[Char](bufferSize)
55-
val read = reader.read(buffer)
56-
productive = read > 0
57-
if (productive) {
58-
stringBuilder.appendAll(buffer, 0, read)
59-
}
36+
val lines = List.newBuilder[String]
37+
var line = reader.readLine()
38+
while (line != null) {
39+
lines += line
40+
line = reader.readLine()
6041
}
61-
62-
stringBuilder.toString
42+
lines.result()
6343
}
6444

6545
/** Reads a file at the given path and:
6646
* - skips BOM if present
67-
* - removes unpaired surrogates
6847
* - uses UTF-8 encoding (replacing malformed and unmappable characters)
6948
*
7049
* @param path
@@ -77,15 +56,25 @@ object IOUtils {
7756

7857
/** Reads a file at the given path and:
7958
* - skips BOM if present
80-
* - removes unpaired surrogates
8159
* - uses UTF-8 encoding (replacing malformed and unmappable characters)
8260
*
8361
* @param path
8462
* the file path
8563
* @return
8664
* a String with the given file's contents
8765
*/
88-
def readEntireFile(path: Path): String =
89-
Using.resource(Source.fromFile(path.toFile)(createDecoder()))(contentStringFromBufferedSource)
66+
def readEntireFile(path: Path): String = {
67+
val bytes = Files.readAllBytes(path)
68+
// Strip the UTF-8 BOM (EF BB BF) if present. The String constructor replaces malformed and unmappable input with
69+
// the default replacement character, i.e. decoding is lenient just like in readLinesInFile above.
70+
val offset = if (startsWithUtf8Bom(bytes)) Utf8BomLength else 0
71+
new String(bytes, offset, bytes.length - offset, StandardCharsets.UTF_8)
72+
}
73+
74+
private val Utf8BomLength = 3
75+
76+
private def startsWithUtf8Bom(bytes: Array[Byte]): Boolean =
77+
bytes.length >= Utf8BomLength &&
78+
bytes(0) == 0xef.toByte && bytes(1) == 0xbb.toByte && bytes(2) == 0xbf.toByte
9079

9180
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package io.shiftleft.utils
2+
3+
import org.scalatest.matchers.should.Matchers
4+
import org.scalatest.wordspec.AnyWordSpec
5+
6+
import java.nio.charset.StandardCharsets
7+
import java.nio.file.{Files, Path}
8+
9+
class IOUtilsTest extends AnyWordSpec with Matchers {
10+
11+
private val Utf8Bom = Array(0xef.toByte, 0xbb.toByte, 0xbf.toByte)
12+
13+
private def withTempFile(content: Array[Byte])(f: Path => Unit): Unit = {
14+
val path = Files.createTempFile("io-utils-test", ".tmp")
15+
try {
16+
Files.write(path, content)
17+
f(path)
18+
} finally {
19+
Files.deleteIfExists(path)
20+
}
21+
}
22+
23+
private def utf8(s: String): Array[Byte] = s.getBytes(StandardCharsets.UTF_8)
24+
25+
"IOUtils.readLinesInFile" should {
26+
"read all lines" in {
27+
withTempFile(utf8("foo\nbar\r\nbaz")) { path =>
28+
IOUtils.readLinesInFile(path) shouldBe Seq("foo", "bar", "baz")
29+
}
30+
}
31+
32+
"skip a UTF-8 BOM if present" in {
33+
withTempFile(Utf8Bom ++ utf8("foo\nbar")) { path =>
34+
IOUtils.readLinesInFile(path) shouldBe Seq("foo", "bar")
35+
}
36+
}
37+
38+
"replace malformed UTF-8 input instead of throwing" in {
39+
// 0xC3 starts a two-byte sequence but 0x28 ('(') is not a valid continuation byte
40+
withTempFile(utf8("foo") ++ Array(0xc3.toByte, 0x28.toByte)) { path =>
41+
IOUtils.readLinesInFile(path) shouldBe Seq("foo\ufffd(")
42+
}
43+
}
44+
45+
"not strip a leading character that merely encodes like a BOM" in {
46+
// EF AE BB is the genuine UTF-8 encoding of U+FBBB (a regular character, not a BOM)
47+
withTempFile(Array(0xef.toByte, 0xae.toByte, 0xbb.toByte)) { path =>
48+
IOUtils.readLinesInFile(path) shouldBe Seq("\ufbbb")
49+
}
50+
}
51+
52+
"return an empty Seq for an empty file" in {
53+
withTempFile(Array.emptyByteArray) { path =>
54+
IOUtils.readLinesInFile(path) shouldBe empty
55+
}
56+
}
57+
}
58+
59+
"IOUtils.readEntireFile" should {
60+
"read the entire content" in {
61+
withTempFile(utf8("foo\nbar\n")) { path =>
62+
IOUtils.readEntireFile(path) shouldBe "foo\nbar\n"
63+
}
64+
}
65+
66+
"skip a UTF-8 BOM if present" in {
67+
withTempFile(Utf8Bom ++ utf8("foo")) { path =>
68+
IOUtils.readEntireFile(path) shouldBe "foo"
69+
}
70+
}
71+
72+
"replace malformed UTF-8 input instead of throwing" in {
73+
withTempFile(Array(0xc3.toByte, 0x28.toByte)) { path =>
74+
IOUtils.readEntireFile(path) shouldBe "\ufffd("
75+
}
76+
}
77+
78+
"not strip a leading character that merely encodes like a BOM" in {
79+
// EF AE BB is the genuine UTF-8 encoding of U+FBBB (a regular character, not a BOM)
80+
withTempFile(Array(0xef.toByte, 0xae.toByte, 0xbb.toByte)) { path =>
81+
IOUtils.readEntireFile(path) shouldBe "\ufbbb"
82+
}
83+
}
84+
85+
"return an empty String for an empty file" in {
86+
withTempFile(Array.emptyByteArray) { path =>
87+
IOUtils.readEntireFile(path) shouldBe ""
88+
}
89+
}
90+
}
91+
92+
}

0 commit comments

Comments
 (0)