scrubber_rb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.cargo/config.toml +21 -0
- data/CHANGELOG.md +59 -0
- data/Cargo.lock +626 -0
- data/Cargo.toml +10 -0
- data/LICENSE +21 -0
- data/README.md +298 -0
- data/SECURITY.md +54 -0
- data/ext/scrubber_rb/Cargo.toml +41 -0
- data/ext/scrubber_rb/build.rs +6 -0
- data/ext/scrubber_rb/extconf.rb +6 -0
- data/ext/scrubber_rb/src/detectors/checksum.rs +222 -0
- data/ext/scrubber_rb/src/detectors/mod.rs +636 -0
- data/ext/scrubber_rb/src/detectors/upi.rs +138 -0
- data/ext/scrubber_rb/src/detectors/validate.rs +339 -0
- data/ext/scrubber_rb/src/engine.rs +716 -0
- data/ext/scrubber_rb/src/lib.rs +247 -0
- data/ext/scrubber_rb/src/nogvl.rs +84 -0
- data/ext/scrubber_rb/src/offsets.rs +118 -0
- data/ext/scrubber_rb/src/pattern.rs +319 -0
- data/ext/scrubber_rb/src/replace.rs +238 -0
- data/lib/scrubber/instance.rb +223 -0
- data/lib/scrubber/llm_guard.rb +79 -0
- data/lib/scrubber/log_formatter.rb +40 -0
- data/lib/scrubber/match.rb +27 -0
- data/lib/scrubber/middleware.rb +124 -0
- data/lib/scrubber/version.rb +5 -0
- data/lib/scrubber_rb.rb +109 -0
- data/sig/scrubber_rb.rbs +150 -0
- metadata +96 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
//! Ruby bindings for the scrubber_rb engine.
|
|
2
|
+
//!
|
|
3
|
+
//! This file is the only place that touches Ruby objects. Everything below it
|
|
4
|
+
//! is plain Rust that knows nothing about VALUEs, which is what makes the
|
|
5
|
+
//! engine testable with `cargo test` and safe to run with the GVL released.
|
|
6
|
+
//!
|
|
7
|
+
//! Encoding is handled on the Ruby side: we take and return byte strings and
|
|
8
|
+
//! `lib/scrubber/instance.rb` restores the caller's encoding. That keeps the
|
|
9
|
+
//! FFI surface to bytes and integers.
|
|
10
|
+
|
|
11
|
+
mod detectors;
|
|
12
|
+
mod engine;
|
|
13
|
+
mod nogvl;
|
|
14
|
+
mod offsets;
|
|
15
|
+
mod pattern;
|
|
16
|
+
mod replace;
|
|
17
|
+
|
|
18
|
+
use magnus::{
|
|
19
|
+
exception::ExceptionClass, function, method, prelude::*, value::ReprValue, Error, RArray,
|
|
20
|
+
RModule, RString, Ruby, Value,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
use engine::{BuildError, CustomSpec, Engine};
|
|
24
|
+
use nogvl::GVL_THRESHOLD;
|
|
25
|
+
|
|
26
|
+
/// The `Scrubber::Native` object: an immutable compiled engine.
|
|
27
|
+
#[magnus::wrap(class = "Scrubber::Native", free_immediately, size)]
|
|
28
|
+
struct Native {
|
|
29
|
+
engine: Engine,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl Native {
|
|
33
|
+
/// `Scrubber::Native.new(detectors, customs, replacement, hash_salt)`
|
|
34
|
+
///
|
|
35
|
+
/// * `detectors` - array of detector-key strings
|
|
36
|
+
/// * `customs` - array of `[name, regexp_source, regexp_options]` triples
|
|
37
|
+
/// * `replacement` - `"label"` / `"mask"` / `"hash"` / `"remove"`
|
|
38
|
+
/// * `hash_salt` - string, or nil
|
|
39
|
+
fn new(
|
|
40
|
+
detectors: Vec<String>,
|
|
41
|
+
customs: Vec<(String, String, i32)>,
|
|
42
|
+
replacement: String,
|
|
43
|
+
hash_salt: Option<String>,
|
|
44
|
+
) -> Result<Native, Error> {
|
|
45
|
+
let specs: Vec<CustomSpec> = customs
|
|
46
|
+
.into_iter()
|
|
47
|
+
.map(|(name, source, options)| CustomSpec {
|
|
48
|
+
name,
|
|
49
|
+
source,
|
|
50
|
+
options,
|
|
51
|
+
})
|
|
52
|
+
.collect();
|
|
53
|
+
|
|
54
|
+
let engine = Engine::build(&detectors, &specs, &replacement, hash_salt)
|
|
55
|
+
.map_err(build_error_to_ruby)?;
|
|
56
|
+
Ok(Native { engine })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Redact `input`, returning a new binary string, or `nil` when nothing
|
|
60
|
+
/// matched so the caller can skip rebuilding an identical string.
|
|
61
|
+
fn scrub(ruby: &Ruby, rb_self: &Native, input: RString) -> Result<Value, Error> {
|
|
62
|
+
// Copy out of Ruby memory first: everything after this point may run
|
|
63
|
+
// without the GVL, and may not touch a VALUE.
|
|
64
|
+
let bytes = unsafe { input.as_slice() }.to_vec();
|
|
65
|
+
let engine = &rb_self.engine;
|
|
66
|
+
|
|
67
|
+
let outcome = if bytes.len() >= GVL_THRESHOLD {
|
|
68
|
+
nogvl::without_gvl(|| engine.scrub(&bytes))
|
|
69
|
+
} else {
|
|
70
|
+
nogvl::guarded(|| engine.scrub(&bytes))
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
match outcome {
|
|
74
|
+
Ok(Some(out)) => Ok(ruby.str_from_slice(&out).as_value()),
|
|
75
|
+
Ok(None) => Ok(ruby.qnil().as_value()),
|
|
76
|
+
Err(panic) => Err(internal_error(ruby, &nogvl::panic_message(panic.as_ref()))),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Locate matches without replacing them.
|
|
81
|
+
///
|
|
82
|
+
/// Returns `[[type, begin, end, preview], ...]`. Offsets are character
|
|
83
|
+
/// offsets when `char_offsets` is true (the caller checked the string is
|
|
84
|
+
/// UTF-8) and byte offsets otherwise, which for single-byte encodings is
|
|
85
|
+
/// the same thing.
|
|
86
|
+
fn detect(
|
|
87
|
+
ruby: &Ruby,
|
|
88
|
+
rb_self: &Native,
|
|
89
|
+
input: RString,
|
|
90
|
+
char_offsets: bool,
|
|
91
|
+
) -> Result<RArray, Error> {
|
|
92
|
+
let bytes = unsafe { input.as_slice() }.to_vec();
|
|
93
|
+
let engine = &rb_self.engine;
|
|
94
|
+
|
|
95
|
+
let outcome = if bytes.len() >= GVL_THRESHOLD {
|
|
96
|
+
nogvl::without_gvl(|| collect_matches(engine, &bytes, char_offsets))
|
|
97
|
+
} else {
|
|
98
|
+
nogvl::guarded(|| collect_matches(engine, &bytes, char_offsets))
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let found = match outcome {
|
|
102
|
+
Ok(found) => found,
|
|
103
|
+
Err(panic) => return Err(internal_error(ruby, &nogvl::panic_message(panic.as_ref()))),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let out = ruby.ary_new_capa(found.len());
|
|
107
|
+
for (kind, begin, end, preview) in found {
|
|
108
|
+
let row = ruby.ary_new_capa(4);
|
|
109
|
+
row.push(ruby.str_new(&kind))?;
|
|
110
|
+
row.push(begin)?;
|
|
111
|
+
row.push(end)?;
|
|
112
|
+
row.push(ruby.str_new(&preview))?;
|
|
113
|
+
out.push(row)?;
|
|
114
|
+
}
|
|
115
|
+
Ok(out)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// How many compiled patterns this engine holds. Used by specs and
|
|
119
|
+
/// `Scrubber::Instance#inspect`.
|
|
120
|
+
fn rule_count(&self) -> usize {
|
|
121
|
+
self.engine.rule_count()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Pure-Rust half of `detect`, safe to run without the GVL.
|
|
126
|
+
fn collect_matches(
|
|
127
|
+
engine: &Engine,
|
|
128
|
+
bytes: &[u8],
|
|
129
|
+
char_offsets: bool,
|
|
130
|
+
) -> Vec<(String, usize, usize, String)> {
|
|
131
|
+
let hits = engine.scan(bytes);
|
|
132
|
+
if hits.is_empty() {
|
|
133
|
+
return Vec::new();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let positions: Vec<usize> = if char_offsets {
|
|
137
|
+
let mut wanted = Vec::with_capacity(hits.len() * 2);
|
|
138
|
+
for hit in &hits {
|
|
139
|
+
wanted.push(hit.start);
|
|
140
|
+
wanted.push(hit.end);
|
|
141
|
+
}
|
|
142
|
+
// `wanted` is already ascending: hits are in document order and
|
|
143
|
+
// non-overlapping.
|
|
144
|
+
offsets::to_char_offsets(bytes, &wanted)
|
|
145
|
+
} else {
|
|
146
|
+
hits.iter().flat_map(|h| [h.start, h.end]).collect()
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
hits.iter()
|
|
150
|
+
.enumerate()
|
|
151
|
+
.map(|(i, hit)| {
|
|
152
|
+
(
|
|
153
|
+
engine.kind_of(hit).to_string(),
|
|
154
|
+
positions[i * 2],
|
|
155
|
+
positions[i * 2 + 1],
|
|
156
|
+
engine.preview(bytes, hit),
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
.collect()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fn error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
|
|
163
|
+
ruby.class_object()
|
|
164
|
+
.const_get::<_, RModule>("Scrubber")
|
|
165
|
+
.and_then(|m| m.const_get::<_, ExceptionClass>(name))
|
|
166
|
+
.unwrap_or_else(|_| ruby.exception_runtime_error())
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn internal_error(ruby: &Ruby, message: &str) -> Error {
|
|
170
|
+
Error::new(
|
|
171
|
+
error_class(ruby, "InternalError"),
|
|
172
|
+
format!(
|
|
173
|
+
"{message}. This is a bug in scrubber_rb - please report it at \
|
|
174
|
+
https://github.com/TheSoloHacker47/scrubber-rb/issues (redact your input first)."
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn build_error_to_ruby(err: BuildError) -> Error {
|
|
180
|
+
let Ok(ruby) = Ruby::get() else {
|
|
181
|
+
// Unreachable from a Ruby thread; keeps the signature total.
|
|
182
|
+
return Error::new(
|
|
183
|
+
magnus::exception::runtime_error(),
|
|
184
|
+
"scrubber_rb: no Ruby VM on this thread",
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
let class = match &err {
|
|
188
|
+
BuildError::UnknownDetector { .. } => "UnknownDetectorError",
|
|
189
|
+
BuildError::UnsupportedPattern { .. } | BuildError::InvalidPattern { .. } => {
|
|
190
|
+
"UnsupportedPatternError"
|
|
191
|
+
}
|
|
192
|
+
BuildError::UnknownStrategy { .. } => "ConfigurationError",
|
|
193
|
+
};
|
|
194
|
+
Error::new(error_class(&ruby, class), err.to_string())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Detector keys, so Ruby doesn't have to keep a second copy of the list in
|
|
198
|
+
/// sync with the registry.
|
|
199
|
+
fn default_detectors(ruby: &Ruby) -> Result<RArray, Error> {
|
|
200
|
+
let out = ruby.ary_new_capa(detectors::DEFAULTS.len());
|
|
201
|
+
for key in detectors::DEFAULTS {
|
|
202
|
+
out.push(ruby.str_new(key))?;
|
|
203
|
+
}
|
|
204
|
+
Ok(out)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fn india_detectors(ruby: &Ruby) -> Result<RArray, Error> {
|
|
208
|
+
let out = ruby.ary_new_capa(detectors::INDIA.len());
|
|
209
|
+
for key in detectors::INDIA {
|
|
210
|
+
out.push(ruby.str_new(key))?;
|
|
211
|
+
}
|
|
212
|
+
Ok(out)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fn all_detectors(ruby: &Ruby) -> Result<RArray, Error> {
|
|
216
|
+
let keys = detectors::all_keys();
|
|
217
|
+
let out = ruby.ary_new_capa(keys.len());
|
|
218
|
+
for key in keys {
|
|
219
|
+
out.push(ruby.str_new(key))?;
|
|
220
|
+
}
|
|
221
|
+
Ok(out)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[magnus::init]
|
|
225
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
226
|
+
let module = ruby.define_module("Scrubber")?;
|
|
227
|
+
|
|
228
|
+
// Error hierarchy lives here so the native extension can raise it before
|
|
229
|
+
// any Ruby file has been loaded.
|
|
230
|
+
let base = module.define_error("Error", ruby.exception_standard_error())?;
|
|
231
|
+
module.define_error("UnknownDetectorError", base)?;
|
|
232
|
+
module.define_error("UnsupportedPatternError", base)?;
|
|
233
|
+
module.define_error("ConfigurationError", base)?;
|
|
234
|
+
module.define_error("InternalError", base)?;
|
|
235
|
+
|
|
236
|
+
let native = module.define_class("Native", ruby.class_object())?;
|
|
237
|
+
native.define_singleton_method("new", function!(Native::new, 4))?;
|
|
238
|
+
native.define_method("scrub", method!(Native::scrub, 1))?;
|
|
239
|
+
native.define_method("detect", method!(Native::detect, 2))?;
|
|
240
|
+
native.define_method("rule_count", method!(Native::rule_count, 0))?;
|
|
241
|
+
|
|
242
|
+
native.define_singleton_method("default_detectors", function!(default_detectors, 0))?;
|
|
243
|
+
native.define_singleton_method("india_detectors", function!(india_detectors, 0))?;
|
|
244
|
+
native.define_singleton_method("all_detectors", function!(all_detectors, 0))?;
|
|
245
|
+
|
|
246
|
+
Ok(())
|
|
247
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//! Releasing the GVL around long scans.
|
|
2
|
+
//!
|
|
3
|
+
//! Scanning a 50MB log file takes long enough that holding the GVL would stall
|
|
4
|
+
//! every other thread in a Puma worker. The rule that makes this safe: the
|
|
5
|
+
//! input is copied into a Rust-owned buffer *before* the GVL is released, and
|
|
6
|
+
//! nothing inside the closure touches a Ruby object.
|
|
7
|
+
|
|
8
|
+
use std::ffi::c_void;
|
|
9
|
+
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
10
|
+
|
|
11
|
+
/// Inputs at least this large are scanned with the GVL released. Below it the
|
|
12
|
+
/// release/reacquire round trip costs more than the scan.
|
|
13
|
+
pub const GVL_THRESHOLD: usize = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
/// Run `func` with the GVL released.
|
|
16
|
+
///
|
|
17
|
+
/// # Safety contract
|
|
18
|
+
///
|
|
19
|
+
/// `func` must not call any Ruby C API function or touch any `VALUE`. Callers
|
|
20
|
+
/// in this crate satisfy that by operating only on an owned `Vec<u8>`.
|
|
21
|
+
pub fn without_gvl<F, R>(func: F) -> std::thread::Result<R>
|
|
22
|
+
where
|
|
23
|
+
F: FnOnce() -> R,
|
|
24
|
+
{
|
|
25
|
+
struct Payload<F, R> {
|
|
26
|
+
func: Option<F>,
|
|
27
|
+
result: Option<std::thread::Result<R>>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
unsafe extern "C" fn trampoline<F, R>(data: *mut c_void) -> *mut c_void
|
|
31
|
+
where
|
|
32
|
+
F: FnOnce() -> R,
|
|
33
|
+
{
|
|
34
|
+
let payload = &mut *(data as *mut Payload<F, R>);
|
|
35
|
+
if let Some(func) = payload.func.take() {
|
|
36
|
+
// A panic must not unwind across the C frame Ruby put us in, so it
|
|
37
|
+
// is caught here and re-raised on the Ruby side as
|
|
38
|
+
// Scrubber::InternalError.
|
|
39
|
+
payload.result = Some(catch_unwind(AssertUnwindSafe(func)));
|
|
40
|
+
}
|
|
41
|
+
std::ptr::null_mut()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let mut payload = Payload {
|
|
45
|
+
func: Some(func),
|
|
46
|
+
result: None,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
unsafe {
|
|
50
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
51
|
+
Some(trampoline::<F, R>),
|
|
52
|
+
std::ptr::addr_of_mut!(payload) as *mut c_void,
|
|
53
|
+
// No unblocking function: the scan is pure CPU work with no
|
|
54
|
+
// syscalls to interrupt, and it always terminates (the regex engine
|
|
55
|
+
// is linear-time). Ruby will simply defer the interrupt.
|
|
56
|
+
None,
|
|
57
|
+
std::ptr::null_mut(),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
payload
|
|
62
|
+
.result
|
|
63
|
+
.take()
|
|
64
|
+
.unwrap_or_else(|| Err(Box::new("scan closure never ran")))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Run `func`, converting a panic into a `Result` instead of an abort.
|
|
68
|
+
pub fn guarded<F, R>(func: F) -> std::thread::Result<R>
|
|
69
|
+
where
|
|
70
|
+
F: FnOnce() -> R,
|
|
71
|
+
{
|
|
72
|
+
catch_unwind(AssertUnwindSafe(func))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Human-readable text for whatever `catch_unwind` handed back.
|
|
76
|
+
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
|
77
|
+
if let Some(s) = payload.downcast_ref::<&str>() {
|
|
78
|
+
(*s).to_string()
|
|
79
|
+
} else if let Some(s) = payload.downcast_ref::<String>() {
|
|
80
|
+
s.clone()
|
|
81
|
+
} else {
|
|
82
|
+
"unknown panic in the scrubber_rb native extension".to_string()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//! Byte offset -> character offset mapping.
|
|
2
|
+
//!
|
|
3
|
+
//! Rust works in bytes; `Scrubber#detect` promises offsets that index the Ruby
|
|
4
|
+
//! string. For UTF-8 those differ the moment anyone writes an emoji or a word
|
|
5
|
+
//! in Devanagari, so we convert here rather than making callers guess
|
|
6
|
+
//! (behaviour contract S11).
|
|
7
|
+
|
|
8
|
+
/// Convert byte offsets to character offsets in one pass over `bytes`.
|
|
9
|
+
///
|
|
10
|
+
/// `bytes` may contain invalid UTF-8; each invalid byte counts as one
|
|
11
|
+
/// character, which is exactly how Ruby counts them in a UTF-8 string.
|
|
12
|
+
/// `wanted` must be sorted ascending. Offsets past the end map to the total
|
|
13
|
+
/// character count.
|
|
14
|
+
pub fn to_char_offsets(bytes: &[u8], wanted: &[usize]) -> Vec<usize> {
|
|
15
|
+
let mut out = Vec::with_capacity(wanted.len());
|
|
16
|
+
if wanted.is_empty() {
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let mut next = 0usize;
|
|
21
|
+
let mut byte_idx = 0usize;
|
|
22
|
+
let mut char_idx = 0usize;
|
|
23
|
+
|
|
24
|
+
while byte_idx <= bytes.len() {
|
|
25
|
+
while next < wanted.len() && wanted[next] <= byte_idx {
|
|
26
|
+
// `<=` rather than `==` so an offset landing inside a multi-byte
|
|
27
|
+
// sequence (only reachable via a caller bug) degrades to the
|
|
28
|
+
// character that contains it instead of running off the end.
|
|
29
|
+
out.push(char_idx);
|
|
30
|
+
next += 1;
|
|
31
|
+
}
|
|
32
|
+
if next == wanted.len() || byte_idx == bytes.len() {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
byte_idx += char_len_at(bytes, byte_idx);
|
|
36
|
+
char_idx += 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Anything past the end of the string clamps to the character count.
|
|
40
|
+
while out.len() < wanted.len() {
|
|
41
|
+
out.push(char_idx);
|
|
42
|
+
}
|
|
43
|
+
out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Length in bytes of the character starting at `i`, treating an invalid
|
|
47
|
+
/// sequence as a single one-byte character.
|
|
48
|
+
fn char_len_at(bytes: &[u8], i: usize) -> usize {
|
|
49
|
+
let b = bytes[i];
|
|
50
|
+
let expected = match b {
|
|
51
|
+
0x00..=0x7f => 1,
|
|
52
|
+
0xc2..=0xdf => 2,
|
|
53
|
+
0xe0..=0xef => 3,
|
|
54
|
+
0xf0..=0xf4 => 4,
|
|
55
|
+
_ => return 1, // continuation byte or invalid lead: one "character"
|
|
56
|
+
};
|
|
57
|
+
if expected == 1 {
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
// Verify the continuation bytes are actually there; if not, the lead byte
|
|
61
|
+
// stands alone.
|
|
62
|
+
for k in 1..expected {
|
|
63
|
+
match bytes.get(i + k) {
|
|
64
|
+
Some(c) if (0x80..=0xbf).contains(c) => {}
|
|
65
|
+
_ => return 1,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
expected
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[cfg(test)]
|
|
72
|
+
mod tests {
|
|
73
|
+
use super::*;
|
|
74
|
+
|
|
75
|
+
#[test]
|
|
76
|
+
fn ascii_offsets_are_identity() {
|
|
77
|
+
let s = "hello world";
|
|
78
|
+
assert_eq!(to_char_offsets(s.as_bytes(), &[0, 6, 11]), vec![0, 6, 11]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn multibyte_offsets_count_characters() {
|
|
83
|
+
// "ЁЯОЙ рдирдорд╕реНрддреЗ nik@example.com"
|
|
84
|
+
let s = "ЁЯОЙ рдирдорд╕реНрддреЗ nik@example.com";
|
|
85
|
+
let byte_start = s.find("nik@").unwrap();
|
|
86
|
+
let char_start = s.chars().take_while(|c| *c != 'n').count();
|
|
87
|
+
assert_eq!(
|
|
88
|
+
to_char_offsets(s.as_bytes(), &[byte_start]),
|
|
89
|
+
vec![char_start]
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn handles_invalid_bytes_as_single_characters() {
|
|
95
|
+
let mut bytes = b"ab".to_vec();
|
|
96
|
+
bytes.push(0xff);
|
|
97
|
+
bytes.extend_from_slice("cd".as_bytes());
|
|
98
|
+
// a=0, b=1, 0xff=2, c=3, d=4
|
|
99
|
+
assert_eq!(to_char_offsets(&bytes, &[0, 2, 3, 5]), vec![0, 2, 3, 5]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[test]
|
|
103
|
+
fn truncated_sequence_does_not_run_off_the_end() {
|
|
104
|
+
let bytes = vec![0xe0, 0xa4]; // start of a 3-byte sequence, truncated
|
|
105
|
+
assert_eq!(to_char_offsets(&bytes, &[0, 1, 2]), vec![0, 1, 2]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#[test]
|
|
109
|
+
fn offsets_past_the_end_clamp() {
|
|
110
|
+
let s = "abc";
|
|
111
|
+
assert_eq!(to_char_offsets(s.as_bytes(), &[1, 99]), vec![1, 3]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#[test]
|
|
115
|
+
fn empty_request_is_empty() {
|
|
116
|
+
assert!(to_char_offsets(b"abc", &[]).is_empty());
|
|
117
|
+
}
|
|
118
|
+
}
|