Skip to main content

vitaminc_aead_napi/
scalar.rs

1//! Host scalars as the ints and bytes their logical types are stored over,
2//! and back: BigInt widths, and the text of a calendar date, a timestamp,
3//! a decimal and a UUID.
4//!
5//! Nothing here touches a `napi_env`, so it is all unit-tested natively.
6
7use std::fmt;
8
9use chrono::{DateTime, NaiveDate, Utc};
10use rust_decimal::Decimal;
11use vitaminc_aead::plaintext::kinds::{chrono as kinds_chrono, decimal as kinds_decimal};
12use vitaminc_aead::{Int, Logical, TimeUnit};
13
14/// Host scalar conversion errors. Node receives a TypeError with the same
15/// stable code; errors contain no user plaintext.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
17#[non_exhaustive]
18pub enum ConversionError {
19    #[error("BigInt is outside the signed/unsigned 128-bit range")]
20    IntegerRange,
21    #[error("invalid calendar date")]
22    InvalidDate,
23    #[error("invalid or out-of-range timestamp")]
24    InvalidTimestamp,
25    #[error("invalid or out-of-range decimal")]
26    InvalidDecimal,
27    #[error("decimal must be finite")]
28    NonFiniteDecimal,
29    #[error("invalid UUID")]
30    InvalidUuid,
31}
32
33impl ConversionError {
34    pub fn code(self) -> &'static str {
35        match self {
36            Self::IntegerRange => "ERR_INTEGER_RANGE",
37            Self::InvalidDate => "ERR_INVALID_DATE",
38            Self::InvalidTimestamp => "ERR_INVALID_TIMESTAMP",
39            Self::InvalidDecimal => "ERR_INVALID_DECIMAL",
40            Self::NonFiniteDecimal => "ERR_NON_FINITE_DECIMAL",
41            Self::InvalidUuid => "ERR_INVALID_UUID",
42        }
43    }
44}
45
46/// A BigInt as the narrowest int that holds it. Smallest width first,
47/// preferring signed when both signed and unsigned at that width fit. This
48/// rule is frozen for JS BigInt host conversion.
49pub(crate) fn bigint(negative: bool, words: &[u64]) -> Result<Int, ConversionError> {
50    if words
51        .get(2..)
52        .is_some_and(|tail| tail.iter().any(|&word| word != 0))
53    {
54        return Err(ConversionError::IntegerRange);
55    }
56    let magnitude = u128::from(words.first().copied().unwrap_or(0))
57        .wrapping_add(u128::from(words.get(1).copied().unwrap_or(0)) << 64);
58    if negative && magnitude != 0 {
59        if magnitude > (1u128 << 127) {
60            return Err(ConversionError::IntegerRange);
61        }
62        Ok(signed((magnitude as i128).wrapping_neg()))
63    } else {
64        Ok(unsigned(magnitude))
65    }
66}
67
68fn signed(value: i128) -> Int {
69    if let Ok(v) = i8::try_from(value) {
70        Int::I8(v)
71    } else if let Ok(v) = i16::try_from(value) {
72        Int::I16(v)
73    } else if let Ok(v) = i32::try_from(value) {
74        Int::I32(v)
75    } else if let Ok(v) = i64::try_from(value) {
76        Int::I64(v)
77    } else {
78        Int::I128(value)
79    }
80}
81
82fn unsigned(value: u128) -> Int {
83    if let Ok(v) = i8::try_from(value) {
84        Int::I8(v)
85    } else if let Ok(v) = u8::try_from(value) {
86        Int::U8(v)
87    } else if let Ok(v) = i16::try_from(value) {
88        Int::I16(v)
89    } else if let Ok(v) = u16::try_from(value) {
90        Int::U16(v)
91    } else if let Ok(v) = i32::try_from(value) {
92        Int::I32(v)
93    } else if let Ok(v) = u32::try_from(value) {
94        Int::U32(v)
95    } else if let Ok(v) = i64::try_from(value) {
96        Int::I64(v)
97    } else if let Ok(v) = u64::try_from(value) {
98        Int::U64(v)
99    } else if let Ok(v) = i128::try_from(value) {
100        Int::I128(v)
101    } else {
102        Int::U128(value)
103    }
104}
105
106/// A nanosecond count as the narrowest int the leaf codec can carry it in.
107fn nanos_int(nanos: i128) -> Int {
108    match i64::try_from(nanos) {
109        Ok(v) => Int::I64(v),
110        Err(_) => Int::I128(nanos),
111    }
112}
113
114/// `YYYY-MM-DD` as days since 1970-01-01.
115pub(crate) fn date(text: &str) -> Result<Int, ConversionError> {
116    let value =
117        NaiveDate::parse_from_str(text, "%Y-%m-%d").map_err(|_| ConversionError::InvalidDate)?;
118    if !displays_as(&value, text) {
119        return Err(ConversionError::InvalidDate);
120    }
121    Ok(Int::I32(kinds_chrono::date_days(value)))
122}
123
124/// Whether `value` displays as exactly `text`. The display is compared as it
125/// is written rather than collected into a `String`, so no unwiped copy of
126/// the plaintext is left behind.
127fn displays_as(value: &impl fmt::Display, text: &str) -> bool {
128    struct Rest<'a>(&'a str);
129    impl fmt::Write for Rest<'_> {
130        fn write_str(&mut self, part: &str) -> fmt::Result {
131            self.0 = self.0.strip_prefix(part).ok_or(fmt::Error)?;
132            Ok(())
133        }
134    }
135    let mut rest = Rest(text);
136    fmt::write(&mut rest, format_args!("{value}")).is_ok() && rest.0.is_empty()
137}
138
139/// A day count as `YYYY-MM-DD`, if it is a date chrono can represent.
140pub(crate) fn date_text(days: Int) -> Option<String> {
141    kinds_chrono::date(days).map(|date| date.to_string())
142}
143
144/// RFC 3339 text, or an ISO 8601 expanded year outside 0000-9999, as
145/// nanoseconds since the epoch. A leap second has no count and is refused,
146/// as the leaf codec refuses it.
147pub(crate) fn timestamp(text: &str) -> Result<Int, ConversionError> {
148    // RFC 3339 only spells years 0000-9999. Outside that range decryption
149    // emits an ISO 8601 expanded year (`+12000-…`, `-0005-…`), so accept that
150    // form too or a decrypted timestamp could not be encrypted again.
151    let parsed = if text.starts_with(['+', '-']) {
152        DateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f%#z")
153    } else {
154        DateTime::parse_from_rfc3339(text)
155    };
156    let instant = parsed
157        .map(|value| value.with_timezone(&Utc))
158        .map_err(|_| ConversionError::InvalidTimestamp)?;
159    kinds_chrono::timestamp_nanos(instant)
160        .map(nanos_int)
161        .ok_or(ConversionError::InvalidTimestamp)
162}
163
164/// A JavaScript `Date`'s millisecond count, as the `I64` it is stored as
165/// under `Timestamp(Millis)`. Refused when it is not a whole number of
166/// milliseconds (`Invalid Date` is NaN) or lies outside chrono's range.
167pub(crate) fn date_millis(ms: f64) -> Result<Int, ConversionError> {
168    if !ms.is_finite() || ms.fract() != 0.0 {
169        return Err(ConversionError::InvalidTimestamp);
170    }
171    // `as` saturates, and anything that saturates is out of range anyway.
172    let count = Int::I64(ms as i64);
173    if Logical::Timestamp(TimeUnit::Millis).admits(&vitaminc_aead::Physical::Int(count)) {
174        Ok(count)
175    } else {
176        Err(ConversionError::InvalidTimestamp)
177    }
178}
179
180/// The millisecond count a stored timestamp has as a JavaScript `Date`, if
181/// it is whole milliseconds. Every stored timestamp is inside chrono's
182/// range, which is narrower than `Date`'s, so the count always fits.
183pub(crate) fn timestamp_js_millis(unit: TimeUnit, count: Int) -> Option<f64> {
184    let nanos = vitaminc_aead::plaintext::kinds::timestamp_nanos(unit, count)?;
185    let millis = kinds_chrono::timestamp_count(TimeUnit::Millis, nanos)?;
186    Some(millis as f64)
187}
188
189/// A stored timestamp as RFC 3339 text with nanoseconds, for the
190/// `VcTimestamp` wrapper.
191pub(crate) fn timestamp_text(unit: TimeUnit, count: Int) -> Option<String> {
192    kinds_chrono::timestamp(unit, count)
193        .map(|instant| instant.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true))
194}
195
196/// Fixed-point decimal text as a scale and mantissa.
197pub(crate) fn decimal(text: &str) -> Result<(u8, Int), ConversionError> {
198    let unsigned = text.strip_prefix(['+', '-']).unwrap_or(text);
199    // Compared in place, without a lowercase copy of the plaintext.
200    let non_finite = ["nan", "inf", "infinity"];
201    if non_finite.iter().any(|s| unsigned.eq_ignore_ascii_case(s)) {
202        return Err(ConversionError::NonFiniteDecimal);
203    }
204    // Fixed-point only, matching Go. Refuse separators/exponents rather than
205    // letting a library parser choose rounding or representation policy.
206    let mut parts = unsigned.split('.');
207    let integral = parts.next().unwrap_or("");
208    let fraction = parts.next();
209    if integral.is_empty()
210        || !integral.bytes().all(|c| c.is_ascii_digit())
211        || fraction.is_some_and(|part| {
212            part.is_empty() || part.len() > 28 || !part.bytes().all(|c| c.is_ascii_digit())
213        })
214        || parts.next().is_some()
215    {
216        return Err(ConversionError::InvalidDecimal);
217    }
218    // A negative zero parses as zero, which is what the leaf codec stores.
219    let (scale, mantissa) = Decimal::from_str_exact(text)
220        .map(kinds_decimal::parts)
221        .map_err(|_| ConversionError::InvalidDecimal)?;
222    Ok((scale, Int::I128(mantissa)))
223}
224
225/// A stored decimal as fixed-point text with its scale, for the `VcDecimal`
226/// wrapper.
227pub(crate) fn decimal_text(scale: u8, mantissa: Int) -> Option<String> {
228    kinds_decimal::decimal(scale, mantissa).map(|value| value.to_string())
229}
230
231/// Hyphenated hexadecimal UUID text as its sixteen bytes. Case is accepted
232/// either way; braces, URNs and other spellings are not.
233pub(crate) fn uuid(text: &str) -> Result<[u8; 16], ConversionError> {
234    let bytes = text.as_bytes();
235    if bytes.len() != 36 || [8, 13, 18, 23].iter().any(|&i| bytes[i] != b'-') {
236        return Err(ConversionError::InvalidUuid);
237    }
238    let mut out = [0u8; 16];
239    let mut digits = bytes.iter().filter(|&&b| b != b'-');
240    for byte in &mut out {
241        let hi = digits.next().and_then(|&d| (d as char).to_digit(16));
242        let lo = digits.next().and_then(|&d| (d as char).to_digit(16));
243        match (hi, lo) {
244            (Some(hi), Some(lo)) => *byte = (hi * 16 + lo) as u8,
245            _ => return Err(ConversionError::InvalidUuid),
246        }
247    }
248    Ok(out)
249}
250
251/// Sixteen bytes as lowercase hyphenated UUID text.
252pub(crate) fn uuid_text(bytes: &[u8]) -> Option<String> {
253    let bytes: &[u8; 16] = bytes.try_into().ok()?;
254    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
255    Some(format!(
256        "{}-{}-{}-{}-{}",
257        &hex[0..8],
258        &hex[8..12],
259        &hex[12..16],
260        &hex[16..20],
261        &hex[20..32]
262    ))
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use vitaminc_aead::plaintext::kinds::{MAX_TIMESTAMP_NANOS, MIN_TIMESTAMP_NANOS};
269
270    fn width(int: Int) -> &'static str {
271        match int {
272            Int::I8(_) => "i8",
273            Int::U8(_) => "u8",
274            Int::I16(_) => "i16",
275            Int::U16(_) => "u16",
276            Int::I32(_) => "i32",
277            Int::U32(_) => "u32",
278            Int::I64(_) => "i64",
279            Int::U64(_) => "u64",
280            Int::I128(_) => "i128",
281            Int::U128(_) => "u128",
282        }
283    }
284
285    #[test]
286    fn bigint_boundaries_choose_the_smallest_width() {
287        let positive = [
288            (0, "i8"),
289            (127, "i8"),
290            (128, "u8"),
291            (255, "u8"),
292            (256, "i16"),
293            (32767, "i16"),
294            (32768, "u16"),
295            (65535, "u16"),
296            (65536, "i32"),
297            (i32::MAX as u128, "i32"),
298            (i32::MAX as u128 + 1, "u32"),
299            (u32::MAX as u128, "u32"),
300            (u32::MAX as u128 + 1, "i64"),
301            (i64::MAX as u128, "i64"),
302            (i64::MAX as u128 + 1, "u64"),
303            (u64::MAX as u128, "u64"),
304            (u64::MAX as u128 + 1, "i128"),
305            (i128::MAX as u128, "i128"),
306            (i128::MAX as u128 + 1, "u128"),
307            (u128::MAX, "u128"),
308        ];
309        for (value, expected) in positive {
310            let words = [value as u64, (value >> 64) as u64];
311            let int = bigint(false, &words).expect("in range");
312            assert_eq!(width(int), expected, "{value}");
313            assert_eq!(int.to_u128(), Some(value));
314        }
315        let negative = [
316            (128u128, "i8"),
317            (129, "i16"),
318            (32768, "i16"),
319            (32769, "i32"),
320            (1 << 31, "i32"),
321            ((1 << 31) + 1, "i64"),
322            (1 << 63, "i64"),
323            ((1 << 63) + 1, "i128"),
324            (1 << 127, "i128"),
325        ];
326        for (magnitude, expected) in negative {
327            let int =
328                bigint(true, &[magnitude as u64, (magnitude >> 64) as u64]).expect("in range");
329            assert_eq!(width(int), expected, "-{magnitude}");
330            assert_eq!(int.to_i128(), Some((magnitude as i128).wrapping_neg()));
331        }
332        assert_eq!(bigint(true, &[0, 1 << 63]), Ok(Int::I128(i128::MIN)));
333        assert_eq!(bigint(false, &[]), Ok(Int::I8(0)));
334        assert_eq!(bigint(true, &[0]), Ok(Int::I8(0)), "negative zero");
335        assert_eq!(
336            bigint(false, &[1, 0, 0]),
337            Ok(Int::I8(1)),
338            "leading zero word"
339        );
340        assert_eq!(
341            bigint(true, &[1, 1 << 63]),
342            Err(ConversionError::IntegerRange)
343        );
344        assert_eq!(
345            bigint(false, &[0, 0, 1]),
346            Err(ConversionError::IntegerRange)
347        );
348    }
349
350    #[test]
351    fn dates_are_day_counts_and_round_trip_as_text() {
352        assert_eq!(date("1970-01-01"), Ok(Int::I32(0)));
353        assert_eq!(date("2024-02-29"), Ok(Int::I32(19782)));
354        assert_eq!(date("0000-01-01"), Ok(Int::I32(-719_528)));
355        for text in ["2024-02-29", "0000-01-01", "9999-12-31"] {
356            assert_eq!(date_text(date(text).expect("valid")).as_deref(), Some(text));
357        }
358        for invalid in ["2023-02-29", "2024-2-9", "", "2024-01-01T00:00:00Z"] {
359            assert_eq!(
360                date(invalid),
361                Err(ConversionError::InvalidDate),
362                "{invalid}"
363            );
364        }
365        assert_eq!(date_text(Int::I128(i128::MAX)), None);
366    }
367
368    #[test]
369    fn a_value_displays_as_text_only_when_every_byte_matches() {
370        assert!(displays_as(&"2024-02-29", "2024-02-29"));
371        assert!(displays_as(&"", ""));
372        assert!(!displays_as(&"2024-02-29", "2024-02-2"), "text too short");
373        assert!(!displays_as(&"2024-02-29", "2024-02-290"), "text too long");
374        assert!(!displays_as(&"2024-02-29", "2024-02-28"), "a byte differs");
375    }
376
377    #[test]
378    fn timestamps_are_nanosecond_counts_and_refuse_leap_seconds() {
379        assert_eq!(
380            timestamp("1970-01-01T00:00:00.123Z"),
381            Ok(Int::I64(123_000_000))
382        );
383        assert_eq!(
384            timestamp("1969-12-31T23:59:59.999999999Z"),
385            Ok(Int::I64(-1))
386        );
387        // ADR 0001 section 3: a leap second has no count and is refused.
388        assert_eq!(
389            timestamp("2016-12-31T23:59:60.123456789Z"),
390            Err(ConversionError::InvalidTimestamp)
391        );
392        // Decryption spells years outside 0000-9999 in ISO 8601 expanded form;
393        // each must parse back to the same count, in i128 when i64 is too narrow.
394        for (text, expected_width) in [
395            ("+12000-01-01T00:00:00.000000001Z", "i128"),
396            ("-0005-03-01T12:00:00.000000001Z", "i128"),
397            ("2262-04-11T23:47:16.854775807Z", "i64"),
398            ("2262-04-11T23:47:16.854775808Z", "i128"),
399        ] {
400            let count = timestamp(text).expect("valid");
401            assert_eq!(width(count), expected_width, "{text}");
402            assert_eq!(
403                timestamp_text(TimeUnit::Nanos, count).as_deref(),
404                Some(text)
405            );
406        }
407        assert!(matches!(
408            timestamp("-0005-03-01T12:00:00+01:00"),
409            Ok(count) if timestamp_text(TimeUnit::Nanos, count).as_deref()
410                == Some("-0005-03-01T11:00:00.000000000Z")
411        ));
412        for invalid in [
413            "+12000-01-01",
414            "+12000-13-01T00:00:00Z",
415            "-0005-03-01T12:00:00",
416            "bad",
417        ] {
418            assert_eq!(
419                timestamp(invalid),
420                Err(ConversionError::InvalidTimestamp),
421                "{invalid}"
422            );
423        }
424    }
425
426    #[test]
427    fn a_js_date_is_its_millisecond_count() {
428        assert_eq!(date_millis(-1.0), Ok(Int::I64(-1)));
429        assert_eq!(date_millis(0.0), Ok(Int::I64(0)));
430        assert_eq!(
431            date_millis(1_700_000_000_000.0),
432            Ok(Int::I64(1_700_000_000_000))
433        );
434        for invalid in [
435            f64::NAN,
436            f64::INFINITY,
437            f64::NEG_INFINITY,
438            0.5,
439            // Inside `Date`'s range but outside chrono's.
440            8_640_000_000_000_000.0,
441            -8_640_000_000_000_000.0,
442        ] {
443            assert_eq!(
444                date_millis(invalid),
445                Err(ConversionError::InvalidTimestamp),
446                "{invalid}"
447            );
448        }
449    }
450
451    #[test]
452    fn a_stored_timestamp_is_a_js_date_only_when_it_is_whole_milliseconds() {
453        assert_eq!(
454            timestamp_js_millis(TimeUnit::Millis, Int::I64(123)),
455            Some(123.0)
456        );
457        assert_eq!(
458            timestamp_js_millis(TimeUnit::Nanos, Int::I64(123_000_000)),
459            Some(123.0)
460        );
461        assert_eq!(
462            timestamp_js_millis(TimeUnit::Micros, Int::I64(-1_000)),
463            Some(-1.0)
464        );
465        // Sub-millisecond precision needs the wrapper.
466        assert_eq!(timestamp_js_millis(TimeUnit::Nanos, Int::I64(-1)), None);
467        assert_eq!(timestamp_js_millis(TimeUnit::Micros, Int::I64(1)), None);
468        // chrono's whole range fits a `Date`: its limits are about ±8.2e15
469        // milliseconds, inside `Date`'s ±8.64e15.
470        for limit in [MIN_TIMESTAMP_NANOS, MAX_TIMESTAMP_NANOS] {
471            let ms = timestamp_js_millis(TimeUnit::Millis, Int::I64((limit / 1_000_000) as i64))
472                .expect("whole milliseconds at the bound");
473            assert!(ms.abs() <= 8_640_000_000_000_000.0, "{ms}");
474        }
475        // Out of chrono's range entirely.
476        assert_eq!(
477            timestamp_js_millis(TimeUnit::Nanos, Int::I128(i128::MAX)),
478            None
479        );
480        assert_eq!(timestamp_text(TimeUnit::Nanos, Int::I128(i128::MAX)), None);
481    }
482
483    #[test]
484    fn decimals_are_exact_and_negative_zero_is_zero() {
485        for text in ["NaN", "+nan", "-NaN", "inf", "+Infinity", "-Infinity"] {
486            assert_eq!(
487                decimal(text),
488                Err(ConversionError::NonFiniteDecimal),
489                "{text}"
490            );
491        }
492        for text in [
493            "",
494            "1e2",
495            "1_000",
496            "1.5_0",
497            ".1",
498            "1.",
499            "1.2.3",
500            " 1",
501            "1a",
502            "1.a",
503            "0.00000000000000000000000000001",
504            "79228162514264337593543950336",
505        ] {
506            assert_eq!(
507                decimal(text),
508                Err(ConversionError::InvalidDecimal),
509                "{text}"
510            );
511        }
512        assert_eq!(decimal("1.50"), Ok((2, Int::I128(150))));
513        assert_eq!(decimal("+1.50"), Ok((2, Int::I128(150))));
514        assert_eq!(decimal("-1.50"), Ok((2, Int::I128(-150))));
515        // ADR 0001 section 3: a negative zero reads back as zero, scale kept.
516        assert_eq!(decimal("-0.00"), Ok((2, Int::I128(0))));
517        assert_eq!(decimal_text(2, Int::I128(0)).as_deref(), Some("0.00"));
518        for text in [
519            "1.50",
520            "-79228162514264337593543950335",
521            "0.0000000000000000000000000001",
522        ] {
523            let (scale, mantissa) = decimal(text).expect("valid");
524            assert_eq!(decimal_text(scale, mantissa).as_deref(), Some(text));
525        }
526        assert_eq!(decimal_text(29, Int::I128(1)), None);
527    }
528
529    #[test]
530    fn uuids_are_sixteen_bytes_in_hyphenated_hex() {
531        let text = "123e4567-e89b-12d3-a456-426614174000";
532        let bytes = uuid(text).expect("valid");
533        assert_eq!(bytes[0..4], [0x12, 0x3e, 0x45, 0x67]);
534        assert_eq!(uuid_text(&bytes).as_deref(), Some(text));
535        assert_eq!(uuid(&text.to_uppercase()), Ok(bytes));
536        for invalid in [
537            "",
538            "123e4567e89b12d3a456426614174000",
539            "{123e4567-e89b-12d3-a456-426614174000}",
540            "123e4567-e89b-12d3-a456-42661417400g",
541            "123e4567-e89b-12d3-a456-4266141740000",
542            "123e4567-e89b-12d3-a456-42661417400",
543            "123e4567-e89b-12d3-a456-4266141740\u{e9}",
544        ] {
545            assert_eq!(
546                uuid(invalid),
547                Err(ConversionError::InvalidUuid),
548                "{invalid:?}"
549            );
550        }
551        assert_eq!(uuid_text(&[0; 15]), None);
552    }
553
554    #[test]
555    fn every_error_has_a_stable_code_and_a_message() {
556        for error in [
557            ConversionError::IntegerRange,
558            ConversionError::InvalidDate,
559            ConversionError::InvalidTimestamp,
560            ConversionError::InvalidDecimal,
561            ConversionError::NonFiniteDecimal,
562            ConversionError::InvalidUuid,
563        ] {
564            assert!(error.code().starts_with("ERR_"));
565            assert!(!error.to_string().is_empty());
566        }
567    }
568}