Skip to content

Commit 9b78131

Browse files
committed
Add UTF-8 validity check
1 parent 354f94a commit 9b78131

9 files changed

Lines changed: 236 additions & 31 deletions

File tree

encoding/utf8/README.mbt.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ test "decode" {
3535
}
3636
```
3737

38+
## Validation
39+
40+
Use `is_valid` to check whether bytes are well-formed UTF-8 without decoding them.
41+
42+
```mbt check
43+
///|
44+
test "is_valid" {
45+
inspect(@utf8.is_valid(b"hi"), content="true")
46+
inspect(@utf8.is_valid(b"\x80"), content="false")
47+
}
48+
```
49+
3850
## Lossy Decoding
3951

4052
Use `decode_lossy` to decode bytes that may contain invalid UTF-8, replacing invalid sequences with the Unicode replacement character (U+FFFD).

encoding/utf8/decode.mbt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,71 @@ fn drop_utf8_bom(bytes : BytesView, ignore_bom : Bool) -> BytesView {
2727
}
2828
bytes
2929
}
30+
31+
///|
32+
fn utf8_ascii8_bits(src : Bytes, offset : Int) -> Int {
33+
src.unsafe_get(offset).to_int() |
34+
src.unsafe_get(offset + 1).to_int() |
35+
src.unsafe_get(offset + 2).to_int() |
36+
src.unsafe_get(offset + 3).to_int() |
37+
src.unsafe_get(offset + 4).to_int() |
38+
src.unsafe_get(offset + 5).to_int() |
39+
src.unsafe_get(offset + 6).to_int() |
40+
src.unsafe_get(offset + 7).to_int()
41+
}
42+
43+
///|
44+
fn utf8_find_malformed(src : Bytes, src_offset : Int, src_length : Int) -> Int {
45+
let end = src_offset + src_length
46+
let mut i = src_offset
47+
while i + 8 <= end &&
48+
src.unsafe_get(i).to_int() < 0x80 &&
49+
(utf8_ascii8_bits(src, i) & 0x80) == 0 {
50+
i += 8
51+
}
52+
while i < end {
53+
let b = src.unsafe_get(i).to_int()
54+
if b < 0x80 {
55+
i += 1
56+
} else if b < 0xC2 {
57+
return i - src_offset
58+
} else if b < 0xE0 {
59+
if i + 1 >= end {
60+
return i - src_offset
61+
}
62+
let c1 = src.unsafe_get(i + 1).to_int()
63+
if c1 < 0x80 || c1 > 0xBF {
64+
return i - src_offset
65+
}
66+
i += 2
67+
} else if b < 0xF0 {
68+
if i + 2 >= end {
69+
return i - src_offset
70+
}
71+
let c1 = src.unsafe_get(i + 1).to_int()
72+
let c2 = src.unsafe_get(i + 2).to_int()
73+
let lo = if b == 0xE0 { 0xA0 } else { 0x80 }
74+
let hi = if b == 0xED { 0x9F } else { 0xBF }
75+
if c1 < lo || c1 > hi || c2 < 0x80 || c2 > 0xBF {
76+
return i - src_offset
77+
}
78+
i += 3
79+
} else if b < 0xF5 {
80+
if i + 3 >= end {
81+
return i - src_offset
82+
}
83+
let c1 = src.unsafe_get(i + 1).to_int()
84+
let c2 = src.unsafe_get(i + 2).to_int()
85+
let c3 = src.unsafe_get(i + 3).to_int()
86+
let lo = if b == 0xF0 { 0x90 } else { 0x80 }
87+
let hi = if b == 0xF4 { 0x8F } else { 0xBF }
88+
if c1 < lo || c1 > hi || c2 < 0x80 || c2 > 0xBF || c3 < 0x80 || c3 > 0xBF {
89+
return i - src_offset
90+
}
91+
i += 4
92+
} else {
93+
return i - src_offset
94+
}
95+
}
96+
-1
97+
}

encoding/utf8/decode_js.mbt

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -50,37 +50,6 @@ extern "js" fn decode_utf8_lossy_js(
5050
#| new TextDecoder("utf-8", { ignoreBOM: false }),
5151
#| )
5252

53-
///|
54-
fn utf8_find_malformed(src : Bytes, src_offset : Int, src_length : Int) -> Int {
55-
let view = src[src_offset:src_offset + src_length]
56-
for bytes = view {
57-
match bytes {
58-
[] => break -1
59-
[
60-
_..=0x7F,
61-
_..=0x7F,
62-
_..=0x7F,
63-
_..=0x7F,
64-
_..=0x7F,
65-
_..=0x7F,
66-
_..=0x7F,
67-
_..=0x7F,
68-
.. rest,
69-
] => continue rest
70-
[0..=0x7F, .. rest] => continue rest
71-
[0xC2..=0xDF, 0x80..=0xBF, .. rest] => continue rest
72-
[0xE0, 0xA0..=0xBF, 0x80..=0xBF, .. rest]
73-
| [0xE1..=0xEC, 0x80..=0xBF, 0x80..=0xBF, .. rest]
74-
| [0xED, 0x80..=0x9F, 0x80..=0xBF, .. rest]
75-
| [0xEE..=0xEF, 0x80..=0xBF, 0x80..=0xBF, .. rest] => continue rest
76-
[0xF0, 0x90..=0xBF, 0x80..=0xBF, 0x80..=0xBF, .. rest]
77-
| [0xF1..=0xF3, 0x80..=0xBF, 0x80..=0xBF, 0x80..=0xBF, .. rest]
78-
| [0xF4, 0x80..=0x8F, 0x80..=0xBF, 0x80..=0xBF, .. rest] => continue rest
79-
malformed => break malformed.start_offset() - src_offset
80-
}
81-
}
82-
}
83-
8453
///|
8554
fn strict_malformed_suffix(bytes : BytesView) -> BytesView {
8655
let input = bytes.data()

encoding/utf8/decode_test.mbt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,32 @@ test "decode sliced views" {
117117
inspect(@utf8.decode_lossy(b".A\x80\xFFB."[1:5]), content="A��B")
118118
}
119119

120+
///|
121+
test "is_valid UTF-8" {
122+
assert_true(@utf8.is_valid(b""))
123+
assert_true(@utf8.is_valid(b"hello"))
124+
assert_true(@utf8.is_valid(b"\xc2\xa9"))
125+
assert_true(@utf8.is_valid(b"\xe4\xb8\xad"))
126+
assert_true(@utf8.is_valid(b"\xf0\x9f\x98\x80"))
127+
assert_true(@utf8.is_valid(b".A\xc2\xa9\xe4\xb8\xad."[1:7]))
128+
assert_false(@utf8.is_valid(b"\x80"))
129+
assert_false(@utf8.is_valid(b"\xc2"))
130+
assert_false(@utf8.is_valid(b"\xc0\x80"))
131+
assert_false(@utf8.is_valid(b"\xe0\x80\x80"))
132+
assert_false(@utf8.is_valid(b"\xed\xa0\x80"))
133+
assert_false(@utf8.is_valid(b"\xf4\x90\x80\x80"))
134+
assert_false(@utf8.is_valid(b"\xff"))
135+
}
136+
137+
///|
138+
test "is_valid UTF-8 with bom" {
139+
let bytes = b"\xef\xbb\xbfhello"
140+
assert_true(@utf8.is_valid(bytes))
141+
assert_true(@utf8.is_valid(bytes, ignore_bom=true))
142+
assert_false(@utf8.is_valid(b"\xef\xbb\xbf\xff"))
143+
assert_false(@utf8.is_valid(b"\xef\xbb\xbf\xff", ignore_bom=true))
144+
}
145+
120146
///|
121147
test "decode malformed sliced views" {
122148
try {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2026 International Digital Economy Academy
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
///|
16+
let utf8_is_valid_bench_size = 4096
17+
18+
///|
19+
fn utf8_is_valid_ascii_bytes() -> Bytes {
20+
Bytes::makei(utf8_is_valid_bench_size, i => (0x20 + i % 0x5F).to_byte())
21+
}
22+
23+
///|
24+
fn utf8_is_valid_mixed_bytes() -> Bytes {
25+
Bytes::makei(utf8_is_valid_bench_size, i => {
26+
match i % 8 {
27+
0 => b'\xC3'
28+
1 => b'\xA9'
29+
2 => b'\xE4'
30+
3 => b'\xB8'
31+
4 => b'\xAD'
32+
_ => (0x41 + i % 26).to_byte()
33+
}
34+
})
35+
}
36+
37+
///|
38+
test "bench utf8.is_valid ascii n=4096" (it : @bench.T) {
39+
let bytes = utf8_is_valid_ascii_bytes()
40+
it.bench(fn() { it.keep(@utf8.is_valid(bytes)) })
41+
}
42+
43+
///|
44+
test "bench utf8.decode ascii n=4096" (it : @bench.T) {
45+
let bytes = utf8_is_valid_ascii_bytes()
46+
it.bench(fn() { it.keep(try! @utf8.decode(bytes)) })
47+
}
48+
49+
///|
50+
test "bench utf8.is_valid mixed n=4096" (it : @bench.T) {
51+
let bytes = utf8_is_valid_mixed_bytes()
52+
it.bench(fn() { it.keep(@utf8.is_valid(bytes)) })
53+
}
54+
55+
///|
56+
test "bench utf8.decode mixed n=4096" (it : @bench.T) {
57+
let bytes = utf8_is_valid_mixed_bytes()
58+
it.bench(fn() { it.keep(try! @utf8.decode(bytes)) })
59+
}

encoding/utf8/is_valid_js.mbt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2026 International Digital Economy Academy
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
///|
16+
extern "js" fn is_valid_utf8_js(
17+
bytes : Bytes,
18+
start : Int,
19+
len : Int,
20+
preserve_bom : Bool,
21+
) -> Bool =
22+
#| ((preserveBOMDecoder, dropBOMDecoder) => function(bytes, start, len, preserveBOM) {
23+
#| try {
24+
#| const end = start + len;
25+
#| const slice = bytes.subarray(start, end);
26+
#| const decoder = preserveBOM ? preserveBOMDecoder : dropBOMDecoder;
27+
#| decoder.decode(slice);
28+
#| return true;
29+
#| } catch (_) {
30+
#| return false;
31+
#| }
32+
#| })(
33+
#| new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }),
34+
#| new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }),
35+
#| )
36+
37+
///|
38+
/// Returns true if the input bytes are well-formed UTF-8.
39+
pub fn is_valid(bytes : BytesView, ignore_bom? : Bool = false) -> Bool {
40+
is_valid_utf8_js(
41+
bytes.data(),
42+
bytes.start_offset(),
43+
bytes.length(),
44+
!ignore_bom,
45+
)
46+
}

encoding/utf8/is_valid_nonjs.mbt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2026 International Digital Economy Academy
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
///|
16+
/// Returns true if the input bytes are well-formed UTF-8.
17+
pub fn is_valid(bytes : BytesView, ignore_bom? : Bool = false) -> Bool {
18+
let bytes = drop_utf8_bom(bytes, ignore_bom)
19+
utf8_find_malformed(bytes.data(), bytes.start_offset(), bytes.length()) < 0
20+
}

encoding/utf8/moon.pkg

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55

66
import {
77
"moonbitlang/core/buffer",
8+
"moonbitlang/core/bench",
89
} for "test"
910

1011
options(
@@ -13,5 +14,7 @@ options(
1314
"decode_nonjs.mbt": [ "not", "js" ],
1415
"encode_js.mbt": [ "js" ],
1516
"encode_nonjs.mbt": [ "not", "js" ],
17+
"is_valid_js.mbt": [ "js" ],
18+
"is_valid_nonjs.mbt": [ "not", "js" ],
1619
},
1720
)

encoding/utf8/pkg.generated.mbti

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ pub fn decode_lossy(BytesView, ignore_bom? : Bool) -> String
1212

1313
pub fn encode(StringView, bom? : Bool) -> Bytes
1414

15+
pub fn is_valid(BytesView, ignore_bom? : Bool) -> Bool
16+
1517
// Errors
1618
pub suberror Malformed {
1719
Malformed(BytesView)

0 commit comments

Comments
 (0)