disarm 0.15.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/LICENSE +21 -0
- data/README.md +104 -0
- data/ext/disarm/Cargo.toml +36 -0
- data/ext/disarm/extconf.rb +7 -0
- data/ext/disarm/src/lib.rs +959 -0
- data/lib/disarm/version.rb +6 -0
- data/lib/disarm.rb +618 -0
- metadata +144 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
//! magnus bindings exposing the pure-Rust `disarm` core as the Ruby `Disarm`
|
|
2
|
+
//! module (#45). Every method is a thin wrapper over `disarm_core::api`, so the
|
|
3
|
+
//! security/transform behaviour is defined once in the core and inherited here.
|
|
4
|
+
//!
|
|
5
|
+
//! This file is deliberately the *raw* shim: positional arguments, string scheme
|
|
6
|
+
//! / target tokens, and standard Ruby exceptions. The idiomatic Ruby surface —
|
|
7
|
+
//! keyword arguments, symbol tokens, defaults, the `Disarm::Error` hierarchy, and
|
|
8
|
+
//! the single `transliterate(text, scheme:)` entrypoint — is a thin pure-Ruby
|
|
9
|
+
//! layer in `lib/disarm.rb` that forwards to the `_`-prefixed methods defined
|
|
10
|
+
//! here (#357). Keeping the native side raw avoids fighting magnus's fixed-arity
|
|
11
|
+
//! `function!` over keyword handling.
|
|
12
|
+
//!
|
|
13
|
+
//! Targets magnus 0.8 (Ruby >= 3.1). Build via rake-compiler / rb-sys, not
|
|
14
|
+
//! `cargo build` directly (it needs the Ruby headers rb-sys configures).
|
|
15
|
+
|
|
16
|
+
// S-4: this shim is an FFI boundary that must never panic across into Ruby. Lock
|
|
17
|
+
// that in structurally with the no-panic restriction lints (caught by the binding's
|
|
18
|
+
// clippy gate). The handful of `Ruby::get().expect(...)` calls are GVL invariants
|
|
19
|
+
// (a Ruby callback always holds the GVL) and carry a local `#[allow]` with that
|
|
20
|
+
// justification; everything else must return a magnus `Error`, never panic.
|
|
21
|
+
#![cfg_attr(
|
|
22
|
+
not(test),
|
|
23
|
+
deny(
|
|
24
|
+
clippy::unwrap_used,
|
|
25
|
+
clippy::expect_used,
|
|
26
|
+
clippy::indexing_slicing,
|
|
27
|
+
clippy::string_slice,
|
|
28
|
+
clippy::panic,
|
|
29
|
+
clippy::todo,
|
|
30
|
+
clippy::unimplemented
|
|
31
|
+
)
|
|
32
|
+
)]
|
|
33
|
+
|
|
34
|
+
use std::collections::HashSet;
|
|
35
|
+
|
|
36
|
+
use disarm_core::api;
|
|
37
|
+
use magnus::{function, method, prelude::*, Error, RHash, Ruby};
|
|
38
|
+
|
|
39
|
+
/// Map a `disarm` error onto the closest standard Ruby exception:
|
|
40
|
+
/// `InvalidArgument` → `ArgumentError`, everything else → `RuntimeError`. The
|
|
41
|
+
/// pure-Ruby layer (`lib/disarm.rb`) rescues these and re-raises them as
|
|
42
|
+
/// `Disarm::InvalidArgument` / `Disarm::Error` so consumers can `rescue
|
|
43
|
+
/// Disarm::Error` (#357); raising the built-ins here keeps the native side free
|
|
44
|
+
/// of any dependency on Ruby-defined classes existing at call time.
|
|
45
|
+
///
|
|
46
|
+
/// Named `map_err` (not `raise`) to signal it constructs a magnus `Error` value
|
|
47
|
+
/// rather than raising immediately — mirrors the Node shim's convention.
|
|
48
|
+
fn map_err(e: &disarm_core::Error) -> Error {
|
|
49
|
+
// magnus 0.8 moved the exception-class constructors onto the `Ruby` handle
|
|
50
|
+
// (Ractor-safety). map_err is only ever called from inside a Ruby method
|
|
51
|
+
// callback, so the GVL is held and `Ruby::get()` cannot fail — a justified
|
|
52
|
+
// exception to the no-panic gate above.
|
|
53
|
+
#[allow(clippy::expect_used)]
|
|
54
|
+
let ruby = Ruby::get().expect("map_err must run while holding the Ruby GVL");
|
|
55
|
+
let class = match e.kind() {
|
|
56
|
+
disarm_core::ErrorKind::InvalidArgument => ruby.exception_arg_error(),
|
|
57
|
+
_ => ruby.exception_runtime_error(),
|
|
58
|
+
};
|
|
59
|
+
Error::new(class, e.to_string())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Malformed-Unicode boundary (#469 / #472) ──────────────────────────────────
|
|
63
|
+
//
|
|
64
|
+
// Ruby has no "lone surrogate" scalar; the equivalent malformed input is a String
|
|
65
|
+
// tagged UTF-8 whose bytes are not valid UTF-8 — typically WTF-8, where a surrogate
|
|
66
|
+
// code point is the (forbidden) 3-byte sequence `ED A0–BF 80–BF`. magnus's
|
|
67
|
+
// `String` conversion validates UTF-8 and raises `EncodingError` on such bytes, so
|
|
68
|
+
// every entrypoint would reject it. [`Wtf8Text`] takes the raw `RString` bytes
|
|
69
|
+
// *before* that validation and brings them to the same WTF-8 → UTF-8 contract the
|
|
70
|
+
// Python and Node bindings honor: a well-formed high+low pair recombines into its
|
|
71
|
+
// astral scalar, and each genuinely lone surrogate code unit (or non-decodable byte)
|
|
72
|
+
// becomes exactly one `U+FFFD`. Valid UTF-8 skips the WTF-8 decoder via a `from_utf8`
|
|
73
|
+
// check (it still allocates the owned `String` magnus would have).
|
|
74
|
+
|
|
75
|
+
/// Decode WTF-8 bytes to valid UTF-8: recombine surrogate pairs into astral scalars,
|
|
76
|
+
/// map each lone surrogate — and any byte that is not part of a valid WTF-8 sequence —
|
|
77
|
+
/// to one `U+FFFD`. (Per code unit, not Ruby's per-byte `String#scrub`.)
|
|
78
|
+
fn wtf8_to_utf8(bytes: &[u8]) -> String {
|
|
79
|
+
// A continuation byte (`10xx_xxxx`); its low 6 bits, or `None` if absent/not a
|
|
80
|
+
// continuation. Slice access is via `get` so the binding's no-panic gate holds.
|
|
81
|
+
#[inline]
|
|
82
|
+
fn cont(b: Option<&u8>) -> Option<u32> {
|
|
83
|
+
b.filter(|&&x| x & 0xC0 == 0x80)
|
|
84
|
+
.map(|&x| u32::from(x & 0x3F))
|
|
85
|
+
}
|
|
86
|
+
// Pass 1: decode to code points, allowing surrogate scalars. A byte that does not
|
|
87
|
+
// begin a *valid* 1–4 byte WTF-8 sequence — including an overlong encoding (e.g. the
|
|
88
|
+
// 2-byte `C0 AF` for `/`) or an out-of-range 4-byte lead (`F5..F7`, > U+10FFFF) —
|
|
89
|
+
// yields one U+FFFD and advances by a single byte, so the following bytes are
|
|
90
|
+
// re-examined individually rather than swallowed.
|
|
91
|
+
let mut cps: Vec<u32> = Vec::with_capacity(bytes.len());
|
|
92
|
+
let mut i = 0;
|
|
93
|
+
while let Some(&b) = bytes.get(i) {
|
|
94
|
+
let c1 = cont(bytes.get(i + 1));
|
|
95
|
+
let c2 = cont(bytes.get(i + 2));
|
|
96
|
+
let c3 = cont(bytes.get(i + 3));
|
|
97
|
+
let (cp, len) = if b < 0x80 {
|
|
98
|
+
(u32::from(b), 1)
|
|
99
|
+
} else if let (0b110, Some(x1)) = (b >> 5, c1) {
|
|
100
|
+
let cp = (u32::from(b & 0x1F) << 6) | x1;
|
|
101
|
+
// reject overlong (a 2-byte form for < U+0080 — lead C0/C1).
|
|
102
|
+
if cp >= 0x80 {
|
|
103
|
+
(cp, 2)
|
|
104
|
+
} else {
|
|
105
|
+
(0xFFFD, 1)
|
|
106
|
+
}
|
|
107
|
+
} else if let (0b1110, Some(x1), Some(x2)) = (b >> 4, c1, c2) {
|
|
108
|
+
let cp = (u32::from(b & 0x0F) << 12) | (x1 << 6) | x2;
|
|
109
|
+
// reject overlong (< U+0800); surrogates U+D800..U+DFFF are valid WTF-8.
|
|
110
|
+
if cp >= 0x800 {
|
|
111
|
+
(cp, 3)
|
|
112
|
+
} else {
|
|
113
|
+
(0xFFFD, 1)
|
|
114
|
+
}
|
|
115
|
+
} else if let (0b11110, Some(x1), Some(x2), Some(x3)) = (b >> 3, c1, c2, c3) {
|
|
116
|
+
let cp = (u32::from(b & 0x07) << 18) | (x1 << 12) | (x2 << 6) | x3;
|
|
117
|
+
// reject overlong (< U+10000) and out-of-range (> U+10FFFF — lead F5..F7).
|
|
118
|
+
if (0x1_0000..=0x10_FFFF).contains(&cp) {
|
|
119
|
+
(cp, 4)
|
|
120
|
+
} else {
|
|
121
|
+
(0xFFFD, 1)
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
(0xFFFD, 1)
|
|
125
|
+
};
|
|
126
|
+
cps.push(cp);
|
|
127
|
+
i += len;
|
|
128
|
+
}
|
|
129
|
+
// Pass 2: recombine high+low surrogate pairs into the astral scalar; a lone
|
|
130
|
+
// surrogate becomes one U+FFFD.
|
|
131
|
+
let mut out = String::with_capacity(bytes.len());
|
|
132
|
+
let mut j = 0;
|
|
133
|
+
while let Some(&cp) = cps.get(j) {
|
|
134
|
+
let low = cps
|
|
135
|
+
.get(j + 1)
|
|
136
|
+
.copied()
|
|
137
|
+
.filter(|n| (0xDC00..=0xDFFF).contains(n));
|
|
138
|
+
if let (true, Some(lo)) = ((0xD800..=0xDBFF).contains(&cp), low) {
|
|
139
|
+
let astral = 0x1_0000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
|
|
140
|
+
out.push(char::from_u32(astral).unwrap_or('\u{FFFD}'));
|
|
141
|
+
j += 2;
|
|
142
|
+
} else {
|
|
143
|
+
out.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); // surrogate -> None -> U+FFFD
|
|
144
|
+
j += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// A text argument decoded at the boundary with the WTF-8 → UTF-8 contract (#472).
|
|
151
|
+
/// Used in place of `String` for every text parameter; `Deref<Target = str>` lets the
|
|
152
|
+
/// existing `&text` call sites reach the core unchanged.
|
|
153
|
+
struct Wtf8Text(String);
|
|
154
|
+
|
|
155
|
+
impl std::ops::Deref for Wtf8Text {
|
|
156
|
+
type Target = str;
|
|
157
|
+
fn deref(&self) -> &str {
|
|
158
|
+
&self.0
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
impl magnus::TryConvert for Wtf8Text {
|
|
163
|
+
fn try_convert(val: magnus::Value) -> Result<Self, Error> {
|
|
164
|
+
let s = magnus::RString::try_convert(val)?;
|
|
165
|
+
// SAFETY: the bytes are copied out immediately; no Ruby API runs between
|
|
166
|
+
// `as_slice` and `to_vec`, so the string cannot be moved or collected.
|
|
167
|
+
let bytes = unsafe { s.as_slice().to_vec() };
|
|
168
|
+
let decoded = match std::str::from_utf8(&bytes) {
|
|
169
|
+
Ok(valid) => valid.to_owned(),
|
|
170
|
+
Err(_) => wtf8_to_utf8(&bytes),
|
|
171
|
+
};
|
|
172
|
+
Ok(Wtf8Text(decoded))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Transliteration ───────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/// `Disarm._transliterate(text)` — Unicode → ASCII with the default scheme (the
|
|
179
|
+
/// common case; keeps the core's borrow-on-no-op fast path).
|
|
180
|
+
fn transliterate(text: Wtf8Text) -> String {
|
|
181
|
+
api::transliterate(&text).into_owned()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// `Disarm._transliterate_opts(text, "default" | "strict_iso9" | "gost7034", lang)`
|
|
185
|
+
/// — a scheme and/or a language profile via the core's `Transliterate` builder.
|
|
186
|
+
/// `lang` is `nil` (no profile) or a code like `"uk"` (Київ → Kyiv); it composes
|
|
187
|
+
/// with the scheme. The idiomatic layer routes the bare-default/no-lang case to
|
|
188
|
+
/// `_transliterate` so this is only hit when at least one option is set.
|
|
189
|
+
fn transliterate_opts(
|
|
190
|
+
text: Wtf8Text,
|
|
191
|
+
scheme: String,
|
|
192
|
+
lang: Option<String>,
|
|
193
|
+
) -> Result<String, Error> {
|
|
194
|
+
let mut builder = api::Transliterate::new();
|
|
195
|
+
if scheme != "default" {
|
|
196
|
+
let scheme: api::Scheme = scheme.parse().map_err(|e| map_err(&e))?;
|
|
197
|
+
builder = builder.scheme(scheme);
|
|
198
|
+
}
|
|
199
|
+
if let Some(lang) = lang {
|
|
200
|
+
builder = builder.lang(lang);
|
|
201
|
+
}
|
|
202
|
+
Ok(builder.run(&text).into_owned())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Confusables (TR39) ────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/// `Disarm._normalize_confusables(text, "latin" | "cyrillic" | "arabic" | "hebrew")`.
|
|
208
|
+
fn normalize_confusables(
|
|
209
|
+
text: Wtf8Text,
|
|
210
|
+
target: String,
|
|
211
|
+
digit_policy: String,
|
|
212
|
+
) -> Result<String, Error> {
|
|
213
|
+
let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
|
|
214
|
+
let digit_policy: api::DigitPolicy = digit_policy.parse().map_err(|e| map_err(&e))?;
|
|
215
|
+
Ok(api::normalize_confusables_with(&text, target, digit_policy).into_owned())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// `Disarm._confusable?(text, "latin" | "cyrillic" | "arabic" | "hebrew")`.
|
|
219
|
+
fn is_confusable(text: Wtf8Text, target: String) -> Result<bool, Error> {
|
|
220
|
+
let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
|
|
221
|
+
Ok(api::is_confusable(&text, target))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// `Disarm._unmapped_confusables(target)` — every upstream confusable source the
|
|
225
|
+
/// bundled table does not fold (#563).
|
|
226
|
+
fn unmapped_confusables(target: String) -> Result<Vec<String>, Error> {
|
|
227
|
+
let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
|
|
228
|
+
Ok(api::unmapped_confusables(target)
|
|
229
|
+
.into_iter()
|
|
230
|
+
.map(String::from)
|
|
231
|
+
.collect())
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// `Disarm._find_unmapped_confusables(text, target)` — unfolded confusable sources in
|
|
235
|
+
/// `text`, as `[char, byte_offset]` pairs (the Ruby layer maps these to
|
|
236
|
+
/// `{ char:, offset: }` hashes, matching `find_untranslatable`).
|
|
237
|
+
fn find_unmapped_confusables(
|
|
238
|
+
text: Wtf8Text,
|
|
239
|
+
target: String,
|
|
240
|
+
) -> Result<Vec<(String, usize)>, Error> {
|
|
241
|
+
let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
|
|
242
|
+
Ok(api::find_unmapped_confusables(&text, target)
|
|
243
|
+
.into_iter()
|
|
244
|
+
.map(|u| (u.ch.to_string(), u.offset))
|
|
245
|
+
.collect())
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Canonicalization primitives ───────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
fn strip_accents(text: Wtf8Text) -> String {
|
|
251
|
+
api::strip_accents(&text).into_owned()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fn fold_case(text: Wtf8Text) -> String {
|
|
255
|
+
api::fold_case(&text).into_owned()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// `Disarm._is_case_fold_stable?(text)` — whether case folding and simple
|
|
259
|
+
/// lowercasing agree, so the value is a stable identity key (#619).
|
|
260
|
+
fn is_case_fold_stable(text: Wtf8Text) -> bool {
|
|
261
|
+
api::is_case_fold_stable(&text)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/// `Disarm._find_key_collisions(values, key, lang)` — which values share an
|
|
265
|
+
/// identity key (#620), as the tuple list `lib/disarm.rb` maps to named hashes.
|
|
266
|
+
/// Same rationale as `AnomalyReportTuple`: a tuple beats registering a Ruby class
|
|
267
|
+
/// for an internal boundary.
|
|
268
|
+
type KeyCollisionTuple = (String, Vec<String>, Vec<usize>);
|
|
269
|
+
|
|
270
|
+
fn find_key_collisions(
|
|
271
|
+
values: Vec<String>,
|
|
272
|
+
key: String,
|
|
273
|
+
lang: Option<String>,
|
|
274
|
+
) -> Result<Vec<KeyCollisionTuple>, Error> {
|
|
275
|
+
let key: api::KeyForm = key.parse().map_err(|e| map_err(&e))?;
|
|
276
|
+
Ok(api::find_key_collisions(&values, key, lang.as_deref())
|
|
277
|
+
.map_err(|e| map_err(&e))?
|
|
278
|
+
.into_iter()
|
|
279
|
+
.map(|c| (c.key, c.values, c.indices))
|
|
280
|
+
.collect())
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// `Disarm._slugify(text, …)` — the full slug option surface, positional. The
|
|
284
|
+
/// Ruby layer maps its keyword arguments (with the core's documented defaults)
|
|
285
|
+
/// onto this order. `regex_pattern` and `replacements` are intentionally not
|
|
286
|
+
/// surfaced yet (they need non-scalar Ruby↔Rust conversion); everything else the
|
|
287
|
+
/// core's `SlugConfig` exposes is reachable.
|
|
288
|
+
#[allow(clippy::too_many_arguments)]
|
|
289
|
+
fn slugify(
|
|
290
|
+
text: Wtf8Text,
|
|
291
|
+
separator: String,
|
|
292
|
+
lowercase: bool,
|
|
293
|
+
max_length: usize,
|
|
294
|
+
word_boundary: bool,
|
|
295
|
+
save_order: bool,
|
|
296
|
+
stopwords: Vec<String>,
|
|
297
|
+
allow_unicode: bool,
|
|
298
|
+
lang: Option<String>,
|
|
299
|
+
entities: bool,
|
|
300
|
+
decimal: bool,
|
|
301
|
+
hexadecimal: bool,
|
|
302
|
+
safe_chars: String,
|
|
303
|
+
) -> String {
|
|
304
|
+
let mut config = api::SlugConfig::default()
|
|
305
|
+
.with_separator(separator)
|
|
306
|
+
.with_lowercase(lowercase)
|
|
307
|
+
.with_max_length(max_length)
|
|
308
|
+
.with_word_boundary(word_boundary)
|
|
309
|
+
.with_save_order(save_order)
|
|
310
|
+
.with_stopwords(stopwords)
|
|
311
|
+
.with_allow_unicode(allow_unicode)
|
|
312
|
+
.with_safe_chars(safe_chars);
|
|
313
|
+
if let Some(lang) = lang {
|
|
314
|
+
config = config.with_lang(lang);
|
|
315
|
+
}
|
|
316
|
+
// `entities`/`decimal`/`hexadecimal` have no chainable setter; the fields are
|
|
317
|
+
// public, so set them directly (the core defaults all three to `true`).
|
|
318
|
+
config.entities = entities;
|
|
319
|
+
config.decimal = decimal;
|
|
320
|
+
config.hexadecimal = hexadecimal;
|
|
321
|
+
api::slugify(&text, &config)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/// `Disarm._demojize(text, strip_modifiers)`.
|
|
325
|
+
fn demojize(text: Wtf8Text, strip_modifiers: bool) -> String {
|
|
326
|
+
api::demojize(&text, strip_modifiers)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Security presets (fallible) ───────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/// Strip the non-interchange and invisible classes while KEEPING the script (#698).
|
|
332
|
+
///
|
|
333
|
+
/// The seven universal `strip*` primitives cannot be composed into this, and the
|
|
334
|
+
/// difference runs in both directions. `strip_format` is *less* destructive where
|
|
335
|
+
/// rendering matters — it preserves the Private Use Area for icon fonts and keeps the
|
|
336
|
+
/// VS15/VS16 presentation selectors after a base (`RENDERING_STRIP`), both of which the
|
|
337
|
+
/// naive chain deletes — and *more* destructive with whitespace, because it ends in
|
|
338
|
+
/// `CollapseWs` and folds TAB/LF to a space where the primitives leave them. The policy
|
|
339
|
+
/// itself is a private constant, so a caller on this binding could not express it at all.
|
|
340
|
+
///
|
|
341
|
+
/// Unlike `canonicalize` it does NOT fold confusables, so non-Latin text keeps its script
|
|
342
|
+
/// — the point of the preset.
|
|
343
|
+
fn canonicalize_strict(text: Wtf8Text) -> Result<String, Error> {
|
|
344
|
+
api::canonicalize_strict(&text)
|
|
345
|
+
.map(std::borrow::Cow::into_owned)
|
|
346
|
+
.map_err(|e| map_err(&e))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn strip_format(text: Wtf8Text) -> String {
|
|
350
|
+
api::strip_format(&text).into_owned()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
fn strip_obfuscation(text: Wtf8Text) -> Result<String, Error> {
|
|
354
|
+
api::strip_obfuscation(&text)
|
|
355
|
+
.map(std::borrow::Cow::into_owned)
|
|
356
|
+
.map_err(|e| map_err(&e))
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
fn canonicalize(text: Wtf8Text) -> Result<String, Error> {
|
|
360
|
+
api::canonicalize(&text)
|
|
361
|
+
.map(std::borrow::Cow::into_owned)
|
|
362
|
+
.map_err(|e| map_err(&e))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/// `Disarm._search_key(text, lang)` — case/accent/script-insensitive lookup key.
|
|
366
|
+
/// `lang` is `nil` (no profile) or a code like `"ru"`. Fails on an unknown `lang`.
|
|
367
|
+
fn search_key(text: Wtf8Text, lang: Option<String>) -> Result<String, Error> {
|
|
368
|
+
api::search_key(&text, lang.as_deref())
|
|
369
|
+
.map(std::borrow::Cow::into_owned)
|
|
370
|
+
.map_err(|e| map_err(&e))
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// `Disarm._sort_key(text, lang)` — collation sort key (preserves base accented
|
|
374
|
+
/// characters for correct ordering). Fails on an unknown `lang`.
|
|
375
|
+
fn sort_key(text: Wtf8Text, lang: Option<String>) -> Result<String, Error> {
|
|
376
|
+
api::sort_key(&text, lang.as_deref())
|
|
377
|
+
.map(std::borrow::Cow::into_owned)
|
|
378
|
+
.map_err(|e| map_err(&e))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// `Disarm._catalog_key(text, lang, strict_iso9)` — catalog deduplication key.
|
|
382
|
+
/// `strict_iso9` selects the ISO 9:1995 Cyrillic scheme. Fails on an unknown `lang`.
|
|
383
|
+
fn catalog_key(text: Wtf8Text, lang: Option<String>, strict_iso9: bool) -> Result<String, Error> {
|
|
384
|
+
api::catalog_key(&text, lang.as_deref(), strict_iso9)
|
|
385
|
+
.map(std::borrow::Cow::into_owned)
|
|
386
|
+
.map_err(|e| map_err(&e))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// `Disarm._suspicious_hostname?(host)` — flags mixed-script / confusable IDN
|
|
390
|
+
/// spoofs. A false result asserts nothing was *found*, not that the host is safe.
|
|
391
|
+
fn suspicious_hostname(host: Wtf8Text) -> bool {
|
|
392
|
+
// #362 made the Rust api return `HostnameAnalysis` (the verdict is its
|
|
393
|
+
// `suspicious` field) instead of a `(bool, _)` tuple.
|
|
394
|
+
api::is_suspicious_hostname(&host).suspicious
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// The full `HostnameAnalysis` (#549) flattened into the tuple shape `lib/disarm.rb`
|
|
398
|
+
/// maps to a named hash — same rationale as `AnomalyReportTuple` (avoid registering
|
|
399
|
+
/// a Ruby class for an internal boundary). magnus converts the nested
|
|
400
|
+
/// `Vec<Vec<String>>` and the `Vec<bool>` to nested Ruby arrays automatically.
|
|
401
|
+
///
|
|
402
|
+
/// The last two fields are a nested pair rather than two more top-level ones: magnus
|
|
403
|
+
/// implements `IntoValue` for tuples up to arity 12 (`r_array.rs`, `seq!(N in 0..12)`),
|
|
404
|
+
/// and `compat_fold` (#709) was the thirteenth. Ruby destructures the pair in the same
|
|
405
|
+
/// statement, so the hash `analyze_hostname` returns is unchanged. A fourteenth field
|
|
406
|
+
/// should register a class or build an `RArray` rather than nest again.
|
|
407
|
+
#[allow(clippy::type_complexity)]
|
|
408
|
+
type HostnameAnalysisTuple = (
|
|
409
|
+
bool, // suspicious
|
|
410
|
+
Vec<String>, // scripts
|
|
411
|
+
bool, // mixed_script
|
|
412
|
+
bool, // has_confusables
|
|
413
|
+
bool, // bidi_conflict
|
|
414
|
+
bool, // bidi_control
|
|
415
|
+
bool, // has_invisible
|
|
416
|
+
bool, // compat_fold (#709)
|
|
417
|
+
bool, // cross_label_script
|
|
418
|
+
Vec<Vec<String>>, // label_scripts
|
|
419
|
+
bool, // whole_script_confusable
|
|
420
|
+
// Nested to stay inside magnus's arity-12 ceiling; see the doc comment above.
|
|
421
|
+
(
|
|
422
|
+
Vec<bool>, // label_whole_script_confusable
|
|
423
|
+
String, // canonical
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
/// `Disarm._analyze_hostname(host)` — the full analysis behind the
|
|
428
|
+
/// `suspicious_hostname?` predicate, as the tuple the Ruby layer maps to a hash.
|
|
429
|
+
fn analyze_hostname(host: Wtf8Text, contractions: bool) -> HostnameAnalysisTuple {
|
|
430
|
+
let a = api::analyze_hostname_with(&host, contractions);
|
|
431
|
+
(
|
|
432
|
+
a.suspicious,
|
|
433
|
+
a.scripts,
|
|
434
|
+
a.mixed_script,
|
|
435
|
+
a.has_confusables,
|
|
436
|
+
a.bidi_conflict,
|
|
437
|
+
a.bidi_control,
|
|
438
|
+
a.has_invisible,
|
|
439
|
+
a.compat_fold,
|
|
440
|
+
a.cross_label_script,
|
|
441
|
+
a.label_scripts,
|
|
442
|
+
a.whole_script_confusable,
|
|
443
|
+
(a.label_whole_script_confusable, a.canonical),
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── Normalization (#375) ──────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
/// `Disarm._normalize(text, "NFC" | "NFD" | "NFKC" | "NFKD")`. The idiomatic
|
|
450
|
+
/// layer upcases its `form:` symbol/string before forwarding.
|
|
451
|
+
fn normalize(text: Wtf8Text, form: String) -> Result<String, Error> {
|
|
452
|
+
let form: api::NormalizationForm = form.parse().map_err(|e| map_err(&e))?;
|
|
453
|
+
Ok(api::normalize(&text, form))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/// `Disarm._normalized?(text, form)`.
|
|
457
|
+
fn is_normalized(text: Wtf8Text, form: String) -> Result<bool, Error> {
|
|
458
|
+
let form: api::NormalizationForm = form.parse().map_err(|e| map_err(&e))?;
|
|
459
|
+
Ok(api::is_normalized(&text, form))
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Text cleaning (#375) ──────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/// `Disarm._collapse_whitespace(text)` — fold whitespace only (#433).
|
|
465
|
+
fn collapse_whitespace(text: Wtf8Text) -> String {
|
|
466
|
+
api::collapse_whitespace(&text)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/// `Disarm._strip_control_chars(text)` — remove C0/C1 controls (except tab/newline).
|
|
470
|
+
fn strip_control_chars(text: Wtf8Text) -> String {
|
|
471
|
+
api::strip_control_chars(&text)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/// `Disarm._strip_zero_width_chars(text)` — remove ZWSP/ZWNJ/ZWJ/word-joiner.
|
|
475
|
+
fn strip_zero_width_chars(text: Wtf8Text) -> String {
|
|
476
|
+
api::strip_zero_width_chars(&text)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/// `Disarm._strip_bidi(text)` — remove Unicode bidirectional control characters.
|
|
480
|
+
fn strip_bidi(text: Wtf8Text) -> String {
|
|
481
|
+
api::strip_bidi(&text)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/// `Disarm._strip_tags(text)` — strip the Unicode Tags block, keeping emoji flags (#413).
|
|
485
|
+
fn strip_tags(text: Wtf8Text) -> String {
|
|
486
|
+
api::strip_tags(&text)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/// `Disarm._strip_variation_selectors(text)` — strip every variation selector (#413).
|
|
490
|
+
fn strip_variation_selectors(text: Wtf8Text) -> String {
|
|
491
|
+
api::strip_variation_selectors(&text)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/// `Disarm._strip_noncharacters(text)` — strip every Unicode noncharacter (#413).
|
|
495
|
+
fn strip_noncharacters(text: Wtf8Text) -> String {
|
|
496
|
+
api::strip_noncharacters(&text)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/// `Disarm._strip_pua(text)` — strip every Private Use Area code point (#413).
|
|
500
|
+
fn strip_pua(text: Wtf8Text) -> String {
|
|
501
|
+
api::strip_pua(&text)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/// `Disarm._strip_zalgo(text, max_marks)` — cap combining marks per base.
|
|
505
|
+
fn strip_zalgo(text: Wtf8Text, max_marks: usize) -> String {
|
|
506
|
+
api::strip_zalgo(&text, max_marks)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/// `Disarm._zalgo?(text, threshold)` — any base carrying > threshold marks.
|
|
510
|
+
fn is_zalgo(text: Wtf8Text, threshold: usize) -> bool {
|
|
511
|
+
api::is_zalgo(&text, threshold)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Grapheme clusters (#375) ──────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
/// `Disarm._grapheme_len(text)` — count of user-perceived characters.
|
|
517
|
+
fn grapheme_len(text: Wtf8Text) -> usize {
|
|
518
|
+
api::grapheme_len(&text)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/// `Disarm._grapheme_split(text)` — split into grapheme-cluster strings.
|
|
522
|
+
fn grapheme_split(text: Wtf8Text) -> Vec<String> {
|
|
523
|
+
api::grapheme_split(&text)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/// `Disarm._grapheme_truncate(text, max_graphemes)` — truncate by graphemes,
|
|
527
|
+
/// never mid-cluster.
|
|
528
|
+
fn grapheme_truncate(text: Wtf8Text, max_graphemes: usize) -> String {
|
|
529
|
+
api::grapheme_truncate(&text, max_graphemes)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/// `Disarm._grapheme_width(cluster, ambiguous_wide)` — display columns of one
|
|
533
|
+
/// cluster (East Asian Width).
|
|
534
|
+
fn grapheme_width(cluster: String, ambiguous_wide: bool) -> usize {
|
|
535
|
+
api::grapheme_width(&cluster, ambiguous_wide)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/// `Disarm._terminal_width(text, ambiguous_wide)` — display columns of the whole
|
|
539
|
+
/// string.
|
|
540
|
+
fn terminal_width(text: Wtf8Text, ambiguous_wide: bool) -> usize {
|
|
541
|
+
api::terminal_width(&text, ambiguous_wide)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── Filenames (#375) ──────────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
/// `Disarm._sanitize_filename(text, separator, max_length, platform, lang,
|
|
547
|
+
/// preserve_extension)` — fallible; `platform` is "universal" | "windows" | "posix".
|
|
548
|
+
#[allow(clippy::too_many_arguments)]
|
|
549
|
+
fn sanitize_filename(
|
|
550
|
+
text: Wtf8Text,
|
|
551
|
+
separator: String,
|
|
552
|
+
max_length: usize,
|
|
553
|
+
platform: String,
|
|
554
|
+
lang: Option<String>,
|
|
555
|
+
preserve_extension: bool,
|
|
556
|
+
) -> Result<String, Error> {
|
|
557
|
+
let platform: api::Platform = platform.parse().map_err(|e| map_err(&e))?;
|
|
558
|
+
api::sanitize_filename(
|
|
559
|
+
&text,
|
|
560
|
+
&separator,
|
|
561
|
+
max_length,
|
|
562
|
+
platform,
|
|
563
|
+
lang.as_deref(),
|
|
564
|
+
preserve_extension,
|
|
565
|
+
)
|
|
566
|
+
.map_err(|e| map_err(&e))
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Reverse transliteration & untranslatable scan (#375) ──────────────────────
|
|
570
|
+
|
|
571
|
+
/// `Disarm._reverse_transliterate(text, lang)` — Latin → native; `lang` is
|
|
572
|
+
/// "el" | "ru" | "uk".
|
|
573
|
+
fn reverse_transliterate(text: Wtf8Text, lang: String) -> Result<String, Error> {
|
|
574
|
+
let lang: api::ReverseLang = lang.parse().map_err(|e| map_err(&e))?;
|
|
575
|
+
Ok(api::reverse_transliterate(&text, lang))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/// `Disarm._find_untranslatable(text, scheme, lang)` — every character with no
|
|
579
|
+
/// romanization, as `[char, byte_offset]` pairs (the Ruby layer maps these to
|
|
580
|
+
/// `{ char:, offset: }` hashes).
|
|
581
|
+
fn find_untranslatable(
|
|
582
|
+
text: Wtf8Text,
|
|
583
|
+
scheme: String,
|
|
584
|
+
lang: Option<String>,
|
|
585
|
+
) -> Result<Vec<(String, usize)>, Error> {
|
|
586
|
+
let mut builder = api::Transliterate::new();
|
|
587
|
+
if scheme != "default" {
|
|
588
|
+
let scheme: api::Scheme = scheme.parse().map_err(|e| map_err(&e))?;
|
|
589
|
+
builder = builder.scheme(scheme);
|
|
590
|
+
}
|
|
591
|
+
if let Some(lang) = lang {
|
|
592
|
+
builder = builder.lang(lang);
|
|
593
|
+
}
|
|
594
|
+
Ok(builder
|
|
595
|
+
.find_untranslatable(&text)
|
|
596
|
+
.into_iter()
|
|
597
|
+
.map(|u| (u.ch.to_string(), u.offset))
|
|
598
|
+
.collect())
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/// `Disarm._ml_normalize(text, lang, emoji_style, fold_case)` — the ML/NLP preset.
|
|
602
|
+
fn ml_normalize(
|
|
603
|
+
text: Wtf8Text,
|
|
604
|
+
lang: Option<String>,
|
|
605
|
+
emoji_style: String,
|
|
606
|
+
fold_case: bool,
|
|
607
|
+
) -> Result<String, Error> {
|
|
608
|
+
api::ml_normalize(&text, lang.as_deref(), &emoji_style, fold_case)
|
|
609
|
+
.map(std::borrow::Cow::into_owned)
|
|
610
|
+
.map_err(|e| map_err(&e))
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── Script analysis (#375) ────────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
/// `Disarm._detect_scripts(text)` — Unicode scripts present, in first-appearance
|
|
616
|
+
/// order (Common/Inherited excluded).
|
|
617
|
+
fn detect_scripts(text: Wtf8Text) -> Vec<String> {
|
|
618
|
+
api::detect_scripts(&text)
|
|
619
|
+
.into_iter()
|
|
620
|
+
.map(str::to_owned)
|
|
621
|
+
.collect()
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/// `Disarm._is_mixed_script?(text)` — whether `text` mixes more than one script.
|
|
625
|
+
fn is_mixed_script(text: Wtf8Text) -> bool {
|
|
626
|
+
api::is_mixed_script(&text)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/// `Disarm._has_bidi_conflict?(text)` — whether `text` mixes strong left-to-right
|
|
630
|
+
/// and strong right-to-left characters (the "BiDi Swap" reorder precondition,
|
|
631
|
+
/// #412). Fires on the real letters, no `U+202x` override; `false` is not a
|
|
632
|
+
/// safety guarantee.
|
|
633
|
+
fn has_bidi_conflict(text: Wtf8Text) -> bool {
|
|
634
|
+
api::has_bidi_conflict(&text)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/// `Disarm._has_bidi_control?(text)` — All twelve UAX #9 explicit formatting characters, uncontexted (#778).
|
|
638
|
+
/// The counterpart to `has_bidi_conflict`, which reads strong-direction letters and is
|
|
639
|
+
/// blind to these; the two are disjoint. The anomaly detector's `bidi` kind reports nine
|
|
640
|
+
/// of the twelve, holding back LRM, RLM and ALM because a lone directional mark is
|
|
641
|
+
/// ordinary in right-to-left text.
|
|
642
|
+
fn has_bidi_control(text: Wtf8Text) -> bool {
|
|
643
|
+
api::has_bidi_control(&text)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/// `Disarm._inspect_auto_lang(text)` — `[script, chosen_lang, reason,
|
|
647
|
+
/// discriminators_hit]` (the Ruby layer maps it to a hash). `script`/`chosen_lang`
|
|
648
|
+
/// are nil when nothing was detected.
|
|
649
|
+
fn inspect_auto_lang(text: Wtf8Text) -> (Option<String>, Option<String>, String, Vec<String>) {
|
|
650
|
+
let r = api::inspect_auto_lang(&text);
|
|
651
|
+
(r.script, r.chosen_lang, r.reason, r.discriminators_hit)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── Metadata introspection (#404 phase 3) ─────────────────────────────────────
|
|
655
|
+
|
|
656
|
+
/// `Disarm._lang_info(code)` — curated metadata for one language as a Ruby Hash
|
|
657
|
+
/// with symbol keys (`{ name:, script:, region:, context: }`), mirroring the
|
|
658
|
+
/// hash-building style of `inspect_auto_lang`'s wrapper. Fails (ArgumentError →
|
|
659
|
+
/// Disarm::InvalidArgument) on an unknown code.
|
|
660
|
+
fn lang_info(code: String) -> Result<RHash, Error> {
|
|
661
|
+
let meta = api::lang_info(&code).map_err(|e| map_err(&e))?;
|
|
662
|
+
// GVL invariant: a Ruby method callback always holds the GVL. Justified.
|
|
663
|
+
#[allow(clippy::expect_used)]
|
|
664
|
+
let ruby = Ruby::get().expect("a Ruby method callback always holds the GVL");
|
|
665
|
+
let hash = ruby.hash_new();
|
|
666
|
+
hash.aset(ruby.to_symbol("name"), meta.name)?;
|
|
667
|
+
hash.aset(ruby.to_symbol("script"), meta.script)?;
|
|
668
|
+
hash.aset(ruby.to_symbol("region"), meta.region)?;
|
|
669
|
+
hash.aset(ruby.to_symbol("context"), meta.context)?;
|
|
670
|
+
Ok(hash)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/// `Disarm._script_info(name)` — curated metadata for one script as a Ruby Hash
|
|
674
|
+
/// with symbol keys (`{ name:, default_lang:, example:, context_aware: }`);
|
|
675
|
+
/// `default_lang` is `nil` when the core has none. Fails (ArgumentError →
|
|
676
|
+
/// Disarm::InvalidArgument) on an unknown script.
|
|
677
|
+
fn script_info(name: String) -> Result<RHash, Error> {
|
|
678
|
+
let meta = api::script_info(&name).map_err(|e| map_err(&e))?;
|
|
679
|
+
// GVL invariant: a Ruby method callback always holds the GVL. Justified.
|
|
680
|
+
#[allow(clippy::expect_used)]
|
|
681
|
+
let ruby = Ruby::get().expect("a Ruby method callback always holds the GVL");
|
|
682
|
+
let hash = ruby.hash_new();
|
|
683
|
+
hash.aset(ruby.to_symbol("name"), meta.name)?;
|
|
684
|
+
// `Option<&str>` maps to the string or `nil`, matching the core's `None`.
|
|
685
|
+
hash.aset(ruby.to_symbol("default_lang"), meta.default_lang)?;
|
|
686
|
+
hash.aset(ruby.to_symbol("example"), meta.example)?;
|
|
687
|
+
hash.aset(ruby.to_symbol("context_aware"), meta.context_aware)?;
|
|
688
|
+
Ok(hash)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/// The UCD release disarm's normalizer implements (#645). Not a library-wide Unicode
|
|
692
|
+
/// version — the bundled tables track different releases; this is the one integrators ask
|
|
693
|
+
/// about, because it decides whether disarm's normalization agrees with the host
|
|
694
|
+
/// platform's.
|
|
695
|
+
fn unicode_version() -> String {
|
|
696
|
+
api::UNICODE_VERSION.to_owned()
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/// Whether a key stored under an earlier release still compares equal (#645). A
|
|
700
|
+
/// monotonic counter, not a version: two artifacts reporting the same value produce the
|
|
701
|
+
/// same key for the same input. Meaningless in isolation, by design.
|
|
702
|
+
fn key_schema_version() -> u32 {
|
|
703
|
+
api::KEY_SCHEMA_VERSION
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/// `Disarm._confusables_version` — the bundled `confusables.txt` release (#560).
|
|
707
|
+
fn confusables_version() -> String {
|
|
708
|
+
api::CONFUSABLES_VERSION.to_owned()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/// `Disarm._list_scripts` — every script disarm knows, as stable UCD identifiers.
|
|
712
|
+
fn list_scripts() -> Vec<String> {
|
|
713
|
+
api::list_scripts().into_iter().map(str::to_owned).collect()
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/// `Disarm._list_context_langs` — language codes with context-aware support.
|
|
717
|
+
fn list_context_langs() -> Vec<String> {
|
|
718
|
+
api::list_context_langs()
|
|
719
|
+
.into_iter()
|
|
720
|
+
.map(str::to_owned)
|
|
721
|
+
.collect()
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── Anomaly detection (#389) ──────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
/// Collect a `Vec<String>` lexicon into a `HashSet<String>` for O(1) membership
|
|
727
|
+
/// lookups. Shared by `has_anomalies` and `inspect_anomalies`.
|
|
728
|
+
fn collect_lexicon(lexicon: Vec<String>) -> HashSet<String> {
|
|
729
|
+
// Delegates to api::lexicon, which lowercases entries so a title-cased wordlist
|
|
730
|
+
// still matches the detector's lowercased decoded words.
|
|
731
|
+
api::lexicon(lexicon)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/// A reusable, pre-built lexicon (`Disarm::Lexicon`) — the `HashSet<String>` is
|
|
735
|
+
/// collected once at construction so repeated `has_anomalies?`/`inspect_anomalies`
|
|
736
|
+
/// calls over the same word list skip the per-call Array→HashSet rebuild (HAI-SDLC
|
|
737
|
+
/// 6.1). Mirrors the Python binding's reusable lexicon handle.
|
|
738
|
+
#[magnus::wrap(class = "Disarm::Lexicon", free_immediately, size)]
|
|
739
|
+
struct Lexicon {
|
|
740
|
+
inner: HashSet<String>,
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/// `Disarm::Lexicon.new(words)` — build the internal `HashSet<String>` once from an
|
|
744
|
+
/// Array/Set of words (reusing `collect_lexicon`).
|
|
745
|
+
fn lexicon_new(words: Vec<String>) -> Lexicon {
|
|
746
|
+
Lexicon {
|
|
747
|
+
inner: collect_lexicon(words),
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/// `Disarm._has_anomalies?(text, lexicon)` — `lexicon` is an array of common words.
|
|
752
|
+
fn has_anomalies(text: Wtf8Text, lexicon: Vec<String>) -> bool {
|
|
753
|
+
api::has_anomalies(&text, &collect_lexicon(lexicon))
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/// `Disarm._has_anomalies_lex(text, lexicon)` — the reuse path: takes a pre-built
|
|
757
|
+
/// `Disarm::Lexicon`, so the `HashSet` is shared rather than rebuilt per call.
|
|
758
|
+
fn has_anomalies_lex(text: Wtf8Text, lex: &Lexicon) -> bool {
|
|
759
|
+
api::has_anomalies(&text, &lex.inner)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// The flat tuple return is intentional: `lib/disarm.rb` maps it to a named hash.
|
|
763
|
+
// A dedicated magnus struct would require registering a Ruby class just for this
|
|
764
|
+
// internal boundary, which is more boilerplate than the tuple costs.
|
|
765
|
+
#[allow(clippy::type_complexity)]
|
|
766
|
+
type AnomalyReportTuple = (
|
|
767
|
+
bool,
|
|
768
|
+
Vec<String>,
|
|
769
|
+
Vec<(String, String, usize, usize, String, String)>,
|
|
770
|
+
Option<String>,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
/// Run `api::inspect_anomalies` and flatten its report into the tuple shape the
|
|
774
|
+
/// Ruby layer maps to a hash. Shared by the Array and `Disarm::Lexicon` entrypoints.
|
|
775
|
+
fn inspect_anomalies_impl(text: &str, lex: &HashSet<String>) -> AnomalyReportTuple {
|
|
776
|
+
let r = api::inspect_anomalies(text, lex);
|
|
777
|
+
let findings = r
|
|
778
|
+
.findings
|
|
779
|
+
.into_iter()
|
|
780
|
+
.map(|f| {
|
|
781
|
+
let reason = f.reason();
|
|
782
|
+
(
|
|
783
|
+
f.kind.as_str().to_string(),
|
|
784
|
+
f.token,
|
|
785
|
+
f.start,
|
|
786
|
+
f.end,
|
|
787
|
+
f.detail,
|
|
788
|
+
reason,
|
|
789
|
+
)
|
|
790
|
+
})
|
|
791
|
+
.collect();
|
|
792
|
+
let kinds = r.kinds.iter().map(|k| k.as_str().to_string()).collect();
|
|
793
|
+
(r.anomalous, kinds, findings, r.reason)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/// `Disarm._inspect_anomalies(text, lexicon)` — `[anomalous, kinds, findings,
|
|
797
|
+
/// reason]` where each finding is `[kind, token, start, end, detail, reason]` (the
|
|
798
|
+
/// Ruby layer maps it to a hash).
|
|
799
|
+
fn inspect_anomalies(text: Wtf8Text, lexicon: Vec<String>) -> AnomalyReportTuple {
|
|
800
|
+
inspect_anomalies_impl(&text, &collect_lexicon(lexicon))
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/// `Disarm._inspect_anomalies_lex(text, lexicon)` — the reuse path: the same tuple
|
|
804
|
+
/// shape as `inspect_anomalies`, but taking a pre-built `Disarm::Lexicon`.
|
|
805
|
+
fn inspect_anomalies_lex(text: Wtf8Text, lex: &Lexicon) -> AnomalyReportTuple {
|
|
806
|
+
inspect_anomalies_impl(&text, &lex.inner)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── Pipeline (#404 phase 2) ───────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
/// A reusable, pre-built policy pipeline (`Disarm::Pipeline`) — the profile's
|
|
812
|
+
/// steps are validated and assembled once at construction (`get_pipeline`), so
|
|
813
|
+
/// repeated `process` calls over the same profile skip the per-call profile
|
|
814
|
+
/// lookup/validation. Mirrors the `Disarm::Lexicon` reusable handle (and the
|
|
815
|
+
/// Python binding's pipeline handle).
|
|
816
|
+
#[magnus::wrap(class = "Disarm::Pipeline", free_immediately, size)]
|
|
817
|
+
struct Pipeline {
|
|
818
|
+
inner: api::Pipeline,
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/// `Disarm::Pipeline#process(text)` — run the pre-built pipeline over `text`.
|
|
822
|
+
fn pipeline_process(rb_self: &Pipeline, text: Wtf8Text) -> Result<String, Error> {
|
|
823
|
+
rb_self.inner.process(&text).map_err(|e| map_err(&e))
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/// `Disarm._get_pipeline(profile)` — build a reusable `Disarm::Pipeline` for a
|
|
827
|
+
/// named policy profile. Fails (Disarm::InvalidArgument) on an unknown profile.
|
|
828
|
+
fn get_pipeline(profile: String) -> Result<Pipeline, Error> {
|
|
829
|
+
Ok(Pipeline {
|
|
830
|
+
inner: api::get_pipeline(&profile).map_err(|e| map_err(&e))?,
|
|
831
|
+
})
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// `name = "disarm"` so the exported init symbol is `Init_disarm` (matching the
|
|
835
|
+
// `disarm.so` the gem loads), independent of the `disarm-ruby` package name.
|
|
836
|
+
#[magnus::init(name = "disarm")]
|
|
837
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
838
|
+
let module = ruby.define_module("Disarm")?;
|
|
839
|
+
|
|
840
|
+
// Raw, `_`-prefixed shims wrapped by the idiomatic Ruby layer (#357).
|
|
841
|
+
module.define_singleton_method("_transliterate", function!(transliterate, 1))?;
|
|
842
|
+
module.define_singleton_method("_transliterate_opts", function!(transliterate_opts, 3))?;
|
|
843
|
+
module.define_singleton_method(
|
|
844
|
+
"_normalize_confusables",
|
|
845
|
+
function!(normalize_confusables, 3),
|
|
846
|
+
)?;
|
|
847
|
+
module.define_singleton_method("_confusable?", function!(is_confusable, 2))?;
|
|
848
|
+
module.define_singleton_method("_slugify", function!(slugify, 13))?;
|
|
849
|
+
module.define_singleton_method("_demojize", function!(demojize, 2))?;
|
|
850
|
+
module.define_singleton_method("_canonicalize_strict", function!(canonicalize_strict, 1))?;
|
|
851
|
+
module.define_singleton_method("_strip_format", function!(strip_format, 1))?;
|
|
852
|
+
module.define_singleton_method("_strip_obfuscation", function!(strip_obfuscation, 1))?;
|
|
853
|
+
module.define_singleton_method("_canonicalize", function!(canonicalize, 1))?;
|
|
854
|
+
|
|
855
|
+
// Key-derivation presets (#404 Group A parity backfill).
|
|
856
|
+
module.define_singleton_method("_search_key", function!(search_key, 2))?;
|
|
857
|
+
module.define_singleton_method("_sort_key", function!(sort_key, 2))?;
|
|
858
|
+
module.define_singleton_method("_catalog_key", function!(catalog_key, 3))?;
|
|
859
|
+
|
|
860
|
+
// No options / no symbols, but still wrapped by the Ruby layer so a wrong-type
|
|
861
|
+
// argument surfaces as Disarm::InvalidArgument rather than a raw TypeError —
|
|
862
|
+
// keeping `rescue Disarm::Error` exhaustive across the whole public surface.
|
|
863
|
+
module.define_singleton_method("_strip_accents", function!(strip_accents, 1))?;
|
|
864
|
+
module.define_singleton_method("_fold_case", function!(fold_case, 1))?;
|
|
865
|
+
module.define_singleton_method(
|
|
866
|
+
"_is_case_fold_stable?",
|
|
867
|
+
function!(is_case_fold_stable, 1),
|
|
868
|
+
)?;
|
|
869
|
+
module.define_singleton_method("_find_key_collisions", function!(find_key_collisions, 3))?;
|
|
870
|
+
module.define_singleton_method("_suspicious_hostname?", function!(suspicious_hostname, 1))?;
|
|
871
|
+
module.define_singleton_method("_analyze_hostname", function!(analyze_hostname, 2))?;
|
|
872
|
+
|
|
873
|
+
// Normalization + text-cleaning primitives (#375 parity backfill).
|
|
874
|
+
module.define_singleton_method("_normalize", function!(normalize, 2))?;
|
|
875
|
+
module.define_singleton_method("_normalized?", function!(is_normalized, 2))?;
|
|
876
|
+
module.define_singleton_method("_collapse_whitespace", function!(collapse_whitespace, 1))?;
|
|
877
|
+
module.define_singleton_method("_strip_control_chars", function!(strip_control_chars, 1))?;
|
|
878
|
+
module.define_singleton_method(
|
|
879
|
+
"_strip_zero_width_chars",
|
|
880
|
+
function!(strip_zero_width_chars, 1),
|
|
881
|
+
)?;
|
|
882
|
+
module.define_singleton_method("_strip_bidi", function!(strip_bidi, 1))?;
|
|
883
|
+
module.define_singleton_method("_strip_tags", function!(strip_tags, 1))?;
|
|
884
|
+
module.define_singleton_method(
|
|
885
|
+
"_strip_variation_selectors",
|
|
886
|
+
function!(strip_variation_selectors, 1),
|
|
887
|
+
)?;
|
|
888
|
+
module.define_singleton_method("_strip_noncharacters", function!(strip_noncharacters, 1))?;
|
|
889
|
+
module.define_singleton_method("_strip_pua", function!(strip_pua, 1))?;
|
|
890
|
+
module.define_singleton_method("_strip_zalgo", function!(strip_zalgo, 2))?;
|
|
891
|
+
module.define_singleton_method("_zalgo?", function!(is_zalgo, 2))?;
|
|
892
|
+
|
|
893
|
+
// Grapheme-cluster operations (#375 parity backfill).
|
|
894
|
+
module.define_singleton_method("_grapheme_len", function!(grapheme_len, 1))?;
|
|
895
|
+
module.define_singleton_method("_grapheme_split", function!(grapheme_split, 1))?;
|
|
896
|
+
module.define_singleton_method("_grapheme_truncate", function!(grapheme_truncate, 2))?;
|
|
897
|
+
module.define_singleton_method("_grapheme_width", function!(grapheme_width, 2))?;
|
|
898
|
+
module.define_singleton_method("_terminal_width", function!(terminal_width, 2))?;
|
|
899
|
+
|
|
900
|
+
// Filenames, reverse transliteration, and script analysis (#375).
|
|
901
|
+
module.define_singleton_method("_sanitize_filename", function!(sanitize_filename, 6))?;
|
|
902
|
+
module.define_singleton_method(
|
|
903
|
+
"_reverse_transliterate",
|
|
904
|
+
function!(reverse_transliterate, 2),
|
|
905
|
+
)?;
|
|
906
|
+
module.define_singleton_method("_find_untranslatable", function!(find_untranslatable, 3))?;
|
|
907
|
+
module.define_singleton_method(
|
|
908
|
+
"_unmapped_confusables",
|
|
909
|
+
function!(unmapped_confusables, 1),
|
|
910
|
+
)?;
|
|
911
|
+
module.define_singleton_method(
|
|
912
|
+
"_find_unmapped_confusables",
|
|
913
|
+
function!(find_unmapped_confusables, 2),
|
|
914
|
+
)?;
|
|
915
|
+
module.define_singleton_method("_ml_normalize", function!(ml_normalize, 4))?;
|
|
916
|
+
module.define_singleton_method("_detect_scripts", function!(detect_scripts, 1))?;
|
|
917
|
+
module.define_singleton_method("_is_mixed_script?", function!(is_mixed_script, 1))?;
|
|
918
|
+
module.define_singleton_method("_has_bidi_conflict?", function!(has_bidi_conflict, 1))?;
|
|
919
|
+
module.define_singleton_method("_has_bidi_control?", function!(has_bidi_control, 1))?;
|
|
920
|
+
module.define_singleton_method("_inspect_auto_lang", function!(inspect_auto_lang, 1))?;
|
|
921
|
+
|
|
922
|
+
// Metadata introspection (#404 phase 3 parity backfill).
|
|
923
|
+
module.define_singleton_method("_lang_info", function!(lang_info, 1))?;
|
|
924
|
+
module.define_singleton_method("_script_info", function!(script_info, 1))?;
|
|
925
|
+
module.define_singleton_method(
|
|
926
|
+
"_confusables_version",
|
|
927
|
+
function!(confusables_version, 0),
|
|
928
|
+
)?;
|
|
929
|
+
module.define_singleton_method("_unicode_version", function!(unicode_version, 0))?;
|
|
930
|
+
module.define_singleton_method(
|
|
931
|
+
"_key_schema_version",
|
|
932
|
+
function!(key_schema_version, 0),
|
|
933
|
+
)?;
|
|
934
|
+
module.define_singleton_method("_list_scripts", function!(list_scripts, 0))?;
|
|
935
|
+
module.define_singleton_method("_list_context_langs", function!(list_context_langs, 0))?;
|
|
936
|
+
|
|
937
|
+
module.define_singleton_method("_has_anomalies?", function!(has_anomalies, 2))?;
|
|
938
|
+
module.define_singleton_method("_inspect_anomalies", function!(inspect_anomalies, 2))?;
|
|
939
|
+
|
|
940
|
+
// Reusable lexicon handle (HAI-SDLC 6.1): build the HashSet once, reuse it
|
|
941
|
+
// across calls. `Disarm::Lexicon.new(words)` wraps the Rust `Lexicon`; the
|
|
942
|
+
// `_lex` variants take it directly so the membership set is shared, not rebuilt.
|
|
943
|
+
let lexicon = module.define_class("Lexicon", ruby.class_object())?;
|
|
944
|
+
lexicon.define_singleton_method("new", function!(lexicon_new, 1))?;
|
|
945
|
+
module.define_singleton_method("_has_anomalies_lex", function!(has_anomalies_lex, 2))?;
|
|
946
|
+
module.define_singleton_method(
|
|
947
|
+
"_inspect_anomalies_lex",
|
|
948
|
+
function!(inspect_anomalies_lex, 2),
|
|
949
|
+
)?;
|
|
950
|
+
|
|
951
|
+
// Reusable pipeline handle (#404 phase 2): build the profile's steps once via
|
|
952
|
+
// `Disarm.get_pipeline(profile)` (routed through the `_get_pipeline` shim) and
|
|
953
|
+
// reuse the `Disarm::Pipeline` across calls. `process` is the Rust-defined
|
|
954
|
+
// instance method on the wrapped handle. Mirrors `Disarm::Lexicon` above.
|
|
955
|
+
let pipeline = module.define_class("Pipeline", ruby.class_object())?;
|
|
956
|
+
pipeline.define_method("process", method!(pipeline_process, 1))?;
|
|
957
|
+
module.define_singleton_method("_get_pipeline", function!(get_pipeline, 1))?;
|
|
958
|
+
Ok(())
|
|
959
|
+
}
|