From b1d11216a7c53dce9760522b5426e664fd6f0b74 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 20 Oct 2026 22:45:59 +1100 Subject: [PATCH 1/4] test: pin typed nulls in the shared corpus so every binding reads them alike Nine normalized vectors (a typed null of each kind reads as null and is written back as the plain null, as a frame writer writes it) and 22 malformed ones (padding that is not zero or the wrong length, a tag or an envelope with no typed null). Rust, Go and Node read the corpus directly. Refs #493 --- packages/aead/src/leaf.rs | 19 +++- testdata/value-conformance.json | 193 ++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 3 deletions(-) diff --git a/packages/aead/src/leaf.rs b/packages/aead/src/leaf.rs index 1d789848..c0319f87 100644 --- a/packages/aead/src/leaf.rs +++ b/packages/aead/src/leaf.rs @@ -973,16 +973,29 @@ mod tests { assert!(checked > 20, "only {checked} scalar vectors"); // A normalized vector reads, and writes back as its canonical bytes. + // A typed null is written back as the plain null, as a frame writer + // writes it, and its typed form back as itself. + let mut typed_nulls = 0; for v in corpus["normalized"].as_array().expect("normalized") { let name = v["name"].as_str().expect("name"); let bytes = hex(v["transport"].as_str().expect("transport")); let canonical = hex(v["reencode"].as_str().expect("reencode")); - let ((ty, value), used) = read(&bytes, Form::Frame).expect(name); + let (item, used) = read_item(&bytes, Form::Frame).expect(name); assert_eq!(used, bytes.len(), "{name}"); let mut out = Vec::new(); - write(ty, value, Form::Frame, &mut out).expect(name); + match item { + Item::Scalar(ty, value) => write(ty, value, Form::Frame, &mut out).expect(name), + Item::TypedNull(ty) => { + write(None, Physical::Null, Form::Frame, &mut out).expect(name); + let mut typed = Vec::new(); + write_typed_null(ty, &mut typed); + assert_eq!(typed, bytes, "{name}"); + typed_nulls += 1; + } + } assert_eq!(out, canonical, "{name}"); } + assert!(typed_nulls > 5, "only {typed_nulls} typed null vectors"); for v in corpus["malformed"].as_array().expect("malformed") { let name = v["name"].as_str().expect("name"); @@ -991,7 +1004,7 @@ mod tests { continue; } // Refused outright, or a valid scalar followed by trailing bytes. - match read(&bytes, Form::Frame) { + match read_item(&bytes, Form::Frame) { Err(_) => {} Ok((_, used)) => assert!(used < bytes.len(), "{name}"), } diff --git a/testdata/value-conformance.json b/testdata/value-conformance.json index 4248e625..56241cc6 100644 --- a/testdata/value-conformance.json +++ b/testdata/value-conformance.json @@ -439,6 +439,117 @@ "transport": "f3c8", "rule": "ADR 0001 section 3: the envelope is id, parameter and a physical scalar; a parameter and payload cut off are refused" } +, + { + "name": "typed-null-truncated", + "transport": "15", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-no-padding", + "transport": "1506", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-short-padding", + "transport": "15060000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-nonzero-padding", + "transport": "1506000100", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-trailing", + "transport": "150600000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-of-null", + "transport": "1500", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-of-undefined", + "transport": "1501", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-of-bool", + "transport": "1502", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-of-text", + "transport": "150a", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-of-plain-bytes", + "transport": "150b000000000000000000000000000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-nested", + "transport": "151506000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-of-array", + "transport": "15f000000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-envelope-over-shorthand", + "transport": "15f3020004000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-envelope-timestamp-nanos", + "transport": "15f3030310000000000000000000000000000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-envelope-id-zero", + "transport": "15f3000004000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-uuid-over-int", + "transport": "15f3050004000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-uuid-with-length", + "transport": "15f305000b10000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-timestamp-over-float", + "transport": "15f303010900000000000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-unknown-over-bytes", + "transport": "15f3c8000b000000000000000000000000000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-unknown-over-bool", + "transport": "15f3c80002", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-inside-envelope", + "transport": "f3c800150c", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-0xf4", + "transport": "15f4c80004000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + } ], "normalized": [ { @@ -450,6 +561,88 @@ "reencode_value": "0.00", "rule": "ADR 0001 section 3: a decimal negative zero reads back as zero with its scale kept, and zero is always written without the sign" } +, + { + "name": "typed-null-uint32", + "kind": "null", + "value": null, + "transport": "1506000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-int8", + "kind": "null", + "value": null, + "transport": "150c", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-float64", + "kind": "null", + "value": null, + "transport": "150900000000000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-date", + "kind": "null", + "value": null, + "transport": "1512000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-timestamp", + "kind": "null", + "value": null, + "transport": "15130000000000000000000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-decimal", + "kind": "null", + "value": null, + "transport": "1514000000000000000000000000000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-timestamp-millis", + "kind": "null", + "value": null, + "transport": "15f303010500000000000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-uuid", + "kind": "null", + "value": null, + "transport": "15f305000b000000000000000000000000000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + }, + { + "name": "typed-null-unknown-over-float32", + "kind": "null", + "value": null, + "transport": "15f3c80908000000", + "reencode": "00", + "reencode_value": null, + "rule": "Typed null (vitaminc#493): an absent value of a fixed-width type, padded to that type's leaf length so its sealed length matches a present value's. A frame is never sealed, so it reads as null and is written back as the plain null" + } ], "ciphertext_malformed": [ { From e9c3837b9189615556825995b6609b89f1979d5d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 20 Oct 2026 22:45:59 +1100 Subject: [PATCH 2/4] feat(vcffi): write a nil fixed-width pointer as a typed null, and read typed nulls, so Go hides absent values too A nil *int32, *float64, *time.Time, *vcvalue.Date, *vcvalue.Decimal, *vcvalue.UUID (a named type over one included) now encodes as a typed null, padded to the length of a value of its type, as Rust writes an Option's None. vcvalue.TypedNull and Encoder.TypedNull cover a caller whose Go type cannot say what is missing. A nil with no fixed-width type (an interface, a *bool, a *string, an Encryptable) stays the plain null. Unmarshal reads a typed null as strictly as the Rust codec and decodes it as nil, as Rust's frame writer writes it back as the plain null. The committed guest is rebuilt from this tree so the differential fuzz targets compare Go with the Rust that reads typed nulls, and their walkers learn the projection. Refs #493 --- bindings/go/vcencrypt/README.md | 3 +- .../go/vcencrypt/differential_fuzz_test.go | 22 +- bindings/go/vcencrypt/typed_null_test.go | 89 +++++++ .../go/vcencrypt/wasm/vitaminc_guest.wasm | Bin 174038 -> 177018 bytes bindings/go/vcffi/README.md | 27 ++ bindings/go/vcffi/decode.go | 4 + bindings/go/vcffi/encoder.go | 68 +++++- bindings/go/vcffi/fuzz_test.go | 27 +- bindings/go/vcffi/scalars.go | 128 +++++++++- bindings/go/vcffi/transport.go | 4 + bindings/go/vcffi/typed_null_test.go | 231 ++++++++++++++++++ bindings/go/vcvalue/doc.go | 4 +- bindings/go/vcvalue/null.go | 48 ++++ bindings/go/vcvalue/scalars.go | 1 + 14 files changed, 639 insertions(+), 17 deletions(-) create mode 100644 bindings/go/vcencrypt/typed_null_test.go create mode 100644 bindings/go/vcffi/typed_null_test.go create mode 100644 bindings/go/vcvalue/null.go diff --git a/bindings/go/vcencrypt/README.md b/bindings/go/vcencrypt/README.md index d1ff1063..24b6af14 100644 --- a/bindings/go/vcencrypt/README.md +++ b/bindings/go/vcencrypt/README.md @@ -231,7 +231,8 @@ these find inputs where both are self-consistent but differ from each other. - **`FuzzDifferentialValue`** checks that both sides accept or reject the same value bytes. When both accept, it checks that Rust re-encodes the - input exactly and that Go's re-encoding matches, allowing only Go's + input exactly, apart from a typed null, which both sides write back as + the plain null, and that Go's re-encoding matches, allowing only Go's `undefined` to `null` projection. - **`FuzzDifferentialCipherText`** checks that both sides accept or reject the same ciphertext bytes. Fuzzed seals never authenticate, so there is no diff --git a/bindings/go/vcencrypt/differential_fuzz_test.go b/bindings/go/vcencrypt/differential_fuzz_test.go index bda8e61e..f8c3bffa 100644 --- a/bindings/go/vcencrypt/differential_fuzz_test.go +++ b/bindings/go/vcencrypt/differential_fuzz_test.go @@ -144,8 +144,9 @@ func requireGoMatchesRust(t *testing.T, data, rustOut, goOut []byte) { } // rustCanonical is what Rust re-encodes an accepted value as: the input, -// with a decimal negative zero read back as zero (ADR 0001). Rust keeps -// Undefined and envelope timestamps as they are. +// with a decimal negative zero read back as zero (ADR 0001) and a typed null +// written as the plain null, since a frame is never sealed (vitaminc#493). +// Rust keeps Undefined and envelope timestamps as they are. func rustCanonical(data []byte) []byte { return rewrite(data, false) } @@ -153,7 +154,8 @@ func rustCanonical(data []byte) []byte { // rewrite walks an accepted value tree following the transport format // independently of either decoder, so a walk that falls out of step with it // produces the wrong bytes and fails the comparison rather than passing. It -// always normalizes a decimal negative zero; with goProjection it also +// always normalizes a decimal negative zero and writes a typed null as the +// plain null; with goProjection it also // applies Go's two projections. vcffi's fuzz tests have the same walker, but // this is a separate Go module and cannot import test code. func rewrite(data []byte, goProjection bool) []byte { @@ -226,6 +228,20 @@ func rewrite(data []byte, goProjection bool) []byte { case 0xF2: // Passthrough out = append(out, tag) walk() + case 0x15: // a typed null: both sides write it back as the plain null + widths := map[byte]int{0x0C: 1, 0x0D: 1, 0x0E: 2, 0x0F: 2, 0x04: 4, 0x06: 4, 0x08: 4, + 0x12: 4, 0x05: 8, 0x07: 8, 0x09: 8, 0x13: 12, 0x10: 16, 0x11: 16, 0x14: 16, 0x0B: 16} + header := take(1) + if len(header) == 1 && header[0] == 0xF3 { + // id and parameter, then the physical tag the width follows. + if envelope := take(3); len(envelope) == 3 { + header = envelope[2:] + } + } + if len(header) == 1 { + take(widths[header[0]] - 1) + } + out = append(out, 0x00) case 0xF3: // the envelope: id, parameter, then a physical scalar header := take(2) if goProjection && len(header) == 2 && header[0] == 3 && header[1] <= 2 { diff --git a/bindings/go/vcencrypt/typed_null_test.go b/bindings/go/vcencrypt/typed_null_test.go new file mode 100644 index 00000000..ea5c80c0 --- /dev/null +++ b/bindings/go/vcencrypt/typed_null_test.go @@ -0,0 +1,89 @@ +package vcencrypt_test + +import ( + "testing" + "time" + + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// optional is a record whose optional fields are pointers, the Go side of +// Rust's Option. +type optional struct { + Born *vcvalue.Date + Age *int32 + Score *float64 + Seen *time.Time + Price *vcvalue.Decimal + ID *vcvalue.UUID +} + +// Through the real guest: a nil fixed-width field seals to a leaf of the +// same length as a present one (vitaminc#493), so the ciphertext does not +// show which optional values are absent, and it decrypts back as nil. +func TestANilFixedWidthFieldSealsAsLongAsAPresentOne(t *testing.T) { + cipher := newCipher(t) + aad := []byte("typed-null") + born := vcvalue.Date{Year: 1815, Month: 12, Day: 10} + age := int32(36) + score := 1.5 + seen := time.Unix(1_700_000_000, 5).UTC() + price := vcvalue.Decimal{Scale: 2, Coefficient: [12]byte{150}} + id := vcvalue.UUID{0xAB} + + lengths := func(v optional) map[string]int { + ct, err := cipher.Encrypt(t.Context(), v, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + out := map[string]int{} + for key, leaf := range ct.(map[string]any) { + out[key] = len(leaf.(vcvalue.Sealed)) + } + got, err := cipher.Decrypt(t.Context(), ct, aad) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if v.Age == nil { + for _, f := range got.(vcvalue.Object) { + if f.Value != nil { + t.Fatalf("%s decrypted as %#v, want nil", f.Key, f.Value) + } + } + } + return out + } + present := lengths(optional{&born, &age, &score, &seen, &price, &id}) + absent := lengths(optional{}) + if len(present) != 6 { + t.Fatalf("sealed %d fields", len(present)) + } + for key, n := range present { + if absent[key] != n { + t.Fatalf("%s: absent seals to %d bytes, present to %d", key, absent[key], n) + } + } +} + +// A bool and a string keep the plain null: a bool is already as short as +// one, and text is variable width. +func TestANilBoolOrStringSealsThePlainNull(t *testing.T) { + cipher := newCipher(t) + aad := []byte("plain-null") + type rec struct { + On *bool + Name *string + } + ct, err := cipher.Encrypt(t.Context(), rec{}, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + on := true + present, err := cipher.Encrypt(t.Context(), rec{On: &on}, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if got, want := len(ct.(map[string]any)["On"].(vcvalue.Sealed)), len(present.(map[string]any)["On"].(vcvalue.Sealed)); got != want { + t.Fatalf("bool: absent %d, present %d", got, want) + } +} diff --git a/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm b/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm index cacd0e62bed99e6db7d0da7b8995be73e15eacee..6c2b63ce17ac224c54653e79d8d696e07e59ea1a 100755 GIT binary patch delta 33660 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 S_>2>?R+&aK2Hb6Jxc(o&357EN delta 30791 zcmd^o3z&`7_y1Y@J!j6$oEh_C#@x-BV=%5mm~qX#N=S;LBKKQ0R7m$SgK;OLm$o9t ztyB^XjZj7@WaKV{rXrD;ic*pPXYKbrb8)MF&+q>{|L6Jpe)l>1vi910uiIXG?R};{ zzYpHOBRJ>dSXbsK{B!hm;ycsPH$W9L9bzrHOrppc=N4%ny4IPm(yc~7kkkGbQ08Ar zoio50K;(34rT>B%j)($OwZlvhDM3VT7m7~Jyic??rASP0Y7|PL&Hz0K7_@)@w<|yk zMo!b50Rh1Q=4O#pJt&9=N1fB@CO5ePF&YIp9l#Wzxq||pxXf53V4vZb(Hb2)UX;INp=Dp75#u`_JCkONy z@bIvQ294-5c*LMVjt|P#JpAaxBOPm9!GIY!sNc|`j}36Fvr7X;JTq*hW4*b{8Ik#+ zsvGpERrisr65#DppFu+hJu>Lgk&X?npoxMU26@JiH*w&{Vj{gll3u0RlusYhN3?-9 z(WmqY{Y<~p5A-J;qvQ0FFvV`ML+lYd#a^*XjMl!?R%$b~Iod1QeC;L8)TA~`%bcg> zXp6NswAZyo+5&Bf_NKN}Tc|D5mTT{5?`m&r(_F>c8SSh#+4+m}cP+~~&H0-1m}WXx zI9EDnI^T2VIcGUnIafR9I*)5_JKu7yan5tT>YVL-#reG^oy(o?INx{XJ9C`VoeQ1w zow?2h&WX;Ko!@D1I2Sn=J6~_%TdbM? zaLshR?0Vfb+O^2F(zV>R!u75zc;N>@>s;@VoZmoHF@Fe1HhTtC5kF>`<13_^Zv}WF zPA@9Ra_Hu`tSpDaV@zDgrK#rg?jAJ8{L>m3_U}C&)gW?f_^bGKaa1l=E{a@@c{od zOLa4xhF8A|BGH`WDU9029X(#nh|BOgy#ZbqvJEoyt>z;kSutxzqp@wMP|dnE35L|y zneNa55jHV+mLQG20y8u8&WOX;B+9p;-$zWn7CFo#6%u?FdWW?}kEe-4qWd z-Vk1I)v()wO2EuV!fMd(X7SAU7Nu)*X80WX1+{DkmK|p;+mql&W@O&R=Ve#TUa)GgOH1pb4BfZGK(x zv5H5TT3*l^lr)?f`e$aJN@Kv}Z!5K-FU;zdo6zT|>1}>c`MwG}?I~)h0%**Jm7)UB zP3W5$BJb7kQAEXNbd~tVYH1;w$LrL8R(*ilzbqc#sei-6a>k;JQ(u>tQ6(+Fy@SC+ zoMwmkyUgF?I*MIcdAG*b(Wt;2=B*}*#>f>7M1&cZQa7_eWI$NHRSVbZISPdFH{BNM z_G&3qAQH)ox{pPo6H-v@$iOGy4Q+%`?_#jS;nloaEypZM(+)sPI6n>fopyd4@>}fu z3u#(`@H)K0OQ4TFa)gCR-S7_(c_K&s6VZ7NMj+RHV;YHGI%xJ!Y?t6<$mDX6 z$)SO$0|gm!3m^}eClhZ;1lcsBX*tNIp+Tr?fI7pWe{H^4E5^NBjW5hceL3`$y>Ov#xi8Iot1t7R|^ShJ1U3k$LwMm5yV*D{P{EP=*)uG44Rw&9u( zO5bh-QFs-O%qxdEd=MfuyZqFi)MQNB<{l#9!V@(-n=Ty&)<1Czc#3rq}gRs9E1&Pn#tSLVv( z^nVm(y_S~_nGI{T{FkDMO@78*ZzBUtTUs9qR@3yRn(=_TNyN@auGKbY+;ibAh z`W}$&%*;m9ZQ-SV5MHBBw0TG4u*~C(iPyUGngYDz9|gD!L|KNG5sFfX>k4ow$n^xc z6ykaUTnceL0WO8Ot^k*UTu*>YA^sl~;4lBZ03Z3+0(|&C2=LxA0{qQ&1h`1Sloj9~ z{)Ym5f-(L7E5LVLVP8wd^FIo3wudy+=o4=K-NbEs7SX0RwX)eHHQqMH&U%gw=`|Go$ZvS<&bhJ zG%tBw%B@g{^zW627UpRM#L9)R+zN~t7>X&dr=a`{CtOTJxjU*K+!M_J@?_!kx%A^^ z_q4XHlwVQO-3zb8X+$Ys!;c|~F$7vmD8ᕻ>W{nk+UXUgfYog2)efr4i@h8+<< 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 Bb+iBg diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index bb6750f4..6488a73e 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -167,6 +167,33 @@ so that Rust and Node read it as a UUID. Matching explicit Encoder channels are available for custom `Encryptable` implementations. +### Absent values + +AES-GCM keeps the plaintext's length, so a one-byte null sealed in place of a +date would show which rows have no date. A nil pointer to a fixed-width type +(`*int32`, `*float64`, `*time.Time`, `*vcvalue.Date`, `*vcvalue.Decimal`, +`*vcvalue.UUID`, and so on, a named type over one included) is therefore +written as a **typed null**, padded to the length of a value of that type, as +Rust writes an `Option`'s `None` (vitaminc#493): + +```go +type Person struct { + Name string + Born *vcvalue.Date // nil seals as long as a date +} +``` + +A nil with no fixed-width type to go by is the plain null: a nil interface, a +nil `*bool` (a bool is already as short as a null), a nil `*string` or +`*[]byte` (variable width), and a nil pointer to an `Encryptable`, whose +encoding is its own. Where the Go type cannot say what is missing (a nil in a +`map[string]any`), write `vcvalue.TypedNull{Type: vcvalue.TypeDate}`, or call +`Encoder.TypedNull` from an `Encryptable`. + +A typed null decodes as `nil`, like every null. Out of the seal the padding +means nothing, and Rust's frame writer writes it back as the plain null, so +Go does too. + The codec reads `testdata/value-conformance.json` from the repo root directly, as do Rust and Node. Invalid scalar widths, date ranges, nanoseconds, decimal flags and scales return `ErrMalformed`. Value construction errors retain the diff --git a/bindings/go/vcffi/decode.go b/bindings/go/vcffi/decode.go index 13c45b98..8eaf3e45 100644 --- a/bindings/go/vcffi/decode.go +++ b/bindings/go/vcffi/decode.go @@ -12,6 +12,8 @@ import ( // Unmarshal decodes value transport bytes into Go natives: // // - Null and Undefined → nil (Go has no undefined analog); +// - a typed null → nil: an absent value is absent whatever its type +// once it is out of the seal, so it re-encodes as the plain null; // - Bool → bool; // - Int32 → int32; // - Int64 → int64; @@ -169,6 +171,8 @@ func decodeTagged(r *reader, tag byte, depth int) (any, error) { return vcvalue.Plain{V: inner}, nil case tagEnvelope: return decodeEnvelope(r) + case tagTypedNull: + return decodeTypedNull(r) default: return nil, ErrMalformed } diff --git a/bindings/go/vcffi/encoder.go b/bindings/go/vcffi/encoder.go index 3dfcad2a..1c0359f4 100644 --- a/bindings/go/vcffi/encoder.go +++ b/bindings/go/vcffi/encoder.go @@ -452,7 +452,10 @@ func Marshal(v any) ([]byte, error) { // - map[string]T → Map, keys SORTED for determinism; // - structs → Map by field name, honoring `vc:"name"` // tags (`vc:"-"` skips a field), in declaration order; -// - pointers → dereferenced (nil pointer → Null). +// - pointers → dereferenced; a nil pointer → a typed +// null if its element type is fixed width (see [fixedTypeOf]), Null +// otherwise; +// - vcvalue.TypedNull → a typed null of its type. // // Map keys are sorted because Go map iteration order is randomized; // sorting makes the encrypted structure deterministic. Struct fields keep @@ -523,6 +526,9 @@ func interceptMarkers(enc Encoder, v any) bool { case vcvalue.Tagged: enc.Tagged(x) return true + case vcvalue.TypedNull: + enc.TypedNull(x.Type) + return true case vcvalue.Plain: encodeAny(enc.Passthrough(), x.V) return true @@ -628,7 +634,14 @@ func encodeReflect(enc Encoder, rv reflect.Value) { encodeStruct(enc, rv) case reflect.Pointer, reflect.Interface: if rv.IsNil() { - enc.Null() + // A nil pointer still has its element type, so an absent + // fixed-width value is padded like Rust's Option. A nil + // interface has no type to go by. + if t, ok := fixedTypeOf(rv.Type()); ok && rv.Kind() == reflect.Pointer { + enc.TypedNull(t) + } else { + enc.Null() + } return } // Charge the dereference against the shared depth budget: it writes @@ -704,6 +717,57 @@ func encodeStruct(enc Encoder, rv reflect.Value) { _ = m.End() } +var ( + encryptableType = reflect.TypeOf((*Encryptable)(nil)).Elem() + timeType = reflect.TypeOf(time.Time{}) + fixedNamed = map[reflect.Type]vcvalue.FixedType{ + reflect.TypeOf(vcvalue.Int128{}): vcvalue.TypeInt128, + reflect.TypeOf(vcvalue.Uint128{}): vcvalue.TypeUint128, + reflect.TypeOf(vcvalue.Date{}): vcvalue.TypeDate, + reflect.TypeOf(vcvalue.Timestamp{}): vcvalue.TypeTimestamp, + reflect.TypeOf(vcvalue.Decimal{}): vcvalue.TypeDecimal, + reflect.TypeOf(vcvalue.UUID{}): vcvalue.TypeUUID, + timeType: vcvalue.TypeTimestamp, + } + fixedKinds = map[reflect.Kind]vcvalue.FixedType{ + reflect.Int8: vcvalue.TypeInt8, + reflect.Int16: vcvalue.TypeInt16, + reflect.Int32: vcvalue.TypeInt32, + reflect.Int: vcvalue.TypeInt64, + reflect.Int64: vcvalue.TypeInt64, + reflect.Uint8: vcvalue.TypeUint8, + reflect.Uint16: vcvalue.TypeUint16, + reflect.Uint32: vcvalue.TypeUint32, + reflect.Uint: vcvalue.TypeUint64, + reflect.Uint64: vcvalue.TypeUint64, + reflect.Uintptr: vcvalue.TypeUint64, + reflect.Float32: vcvalue.TypeFloat32, + reflect.Float64: vcvalue.TypeFloat64, + } +) + +// fixedTypeOf is the fixed-width type a pointer of type t points at, through +// any number of pointers, when encodeReflect would write every value of it +// with one fixed-width tag: the codec's int, uint and float kinds, and the +// fixed-width vcvalue and time types. A type that implements Encryptable +// chooses its own encoding, which may not be fixed width, so it has none. +func fixedTypeOf(t reflect.Type) (vcvalue.FixedType, bool) { + for t.Kind() == reflect.Pointer { + if t.Implements(encryptableType) { + return 0, false + } + t = t.Elem() + } + if t.Implements(encryptableType) { + return 0, false + } + if ft, ok := fixedNamed[t]; ok { + return ft, true + } + ft, ok := fixedKinds[t.Kind()] + return ft, ok +} + // interfaceOf is rv.Interface(), or nil where reflection forbids it // (values reached through unexported fields). var float32Type = reflect.TypeOf(float32(0)) diff --git a/bindings/go/vcffi/fuzz_test.go b/bindings/go/vcffi/fuzz_test.go index ed085f3f..e3f71620 100644 --- a/bindings/go/vcffi/fuzz_test.go +++ b/bindings/go/vcffi/fuzz_test.go @@ -54,9 +54,10 @@ func addCorpusSeeds(f *testing.F, wrap func([]byte) []byte) { } } -// canonicalForm is what an accepted value encoding re-encodes as. Three +// canonicalForm is what an accepted value encoding re-encodes as. Four // things may legitimately change, and nothing else: an Undefined tag (0x01) -// comes back as Null (0x00), Go's documented projection; a decimal negative +// comes back as Null (0x00), Go's documented projection; a typed null comes +// back as Null too, as Rust's frame writer writes it; a decimal negative // zero comes back as zero with its scale kept (ADR 0001); and a timestamp in // the 0xF3 envelope (seconds, milliseconds or microseconds) comes back in // the shorthand nanosecond form, because Go reads it as a time.Time. The @@ -132,9 +133,25 @@ func canonicalValue(r *reader, out *[]byte) { case tagPassthrough: *out = append(*out, tag) canonicalValue(r, out) + case tagTypedNull: + // The header names the type, which fixes the padding; it all comes + // back as the plain null. + header, _ := r.byteTag() + width := fixedWidth(header) + if header == tagEnvelope { + b, _ := r.take(3) + if len(b) == 3 { + width = fixedWidth(b[2]) + if b[2] == tagBytes { + width = 16 + } + } + } + _, _ = r.take(width - 1) + *out = append(*out, tagNull) default: // Null, booleans and the fixed-width extended scalars. - b, _ := r.take(scalarWidth(tag)) + b, _ := r.take(fixedWidth(tag)) *out = append(append(*out, tag), b...) } } @@ -147,13 +164,13 @@ func shorthandTimestamp(r *reader, unit byte) ([]byte, bool) { var count *big.Int switch tag { case tagInt8, tagInt16, tagInt32, tagInt64, tagInt128: - b, _ := r.take(scalarWidth(tag) + map[byte]int{tagInt32: 4, tagInt64: 8}[tag]) + b, _ := r.take(fixedWidth(tag)) if len(b) == 0 { return nil, false } count = signedLittle(b) case tagUint8, tagUint16, tagUint32, tagUint64, tagUint128: - b, _ := r.take(scalarWidth(tag) + map[byte]int{tagUint32: 4, tagUint64: 8}[tag]) + b, _ := r.take(fixedWidth(tag)) if len(b) == 0 { return nil, false } diff --git a/bindings/go/vcffi/scalars.go b/bindings/go/vcffi/scalars.go index d142f981..f5c76fe1 100644 --- a/bindings/go/vcffi/scalars.go +++ b/bindings/go/vcffi/scalars.go @@ -148,7 +148,7 @@ func (e Encoder) physical(v any) bool { } func decodeScalar(r *reader, tag byte) (any, error) { - b, err := r.take(scalarWidth(tag)) + b, err := r.take(fixedWidth(tag)) if err != nil { return nil, err } @@ -196,16 +196,18 @@ func decodeScalar(r *reader, tag byte) (any, error) { } } -// scalarWidth is the fixed payload width of an extended scalar tag, or zero -// for a tag decodeScalar does not handle. -func scalarWidth(tag byte) int { +// fixedWidth is the payload width of a fixed-width scalar tag, or zero for +// a tag whose payload is not fixed (or that has none). +func fixedWidth(tag byte) int { switch tag { case tagInt8, tagUint8: return 1 case tagInt16, tagUint16: return 2 - case tagDate: + case tagInt32, tagUint32, tagFloat32, tagDate: return 4 + case tagInt64, tagUint64, tagFloat64: + return 8 case tagTimestamp: return 12 case tagInt128, tagUint128, tagDecimal: @@ -213,3 +215,119 @@ func scalarWidth(tag byte) int { } return 0 } + +// numericTag reports whether tag is a plain int or float: what a typed null +// in the envelope may ride on under a timestamp unit or an unknown id. +func numericTag(tag byte, floats bool) bool { + switch tag { + case tagInt8, tagUint8, tagInt16, tagUint16, tagInt32, tagUint32, tagInt64, tagUint64, + tagInt128, tagUint128: + return true + case tagFloat32, tagFloat64: + return floats + } + return false +} + +// fixedTags is the tag each FixedType is written with, all but the UUID, +// which travels in the envelope. +var fixedTags = map[vcvalue.FixedType]byte{ + vcvalue.TypeInt8: tagInt8, + vcvalue.TypeInt16: tagInt16, + vcvalue.TypeInt32: tagInt32, + vcvalue.TypeInt64: tagInt64, + vcvalue.TypeInt128: tagInt128, + vcvalue.TypeUint8: tagUint8, + vcvalue.TypeUint16: tagUint16, + vcvalue.TypeUint32: tagUint32, + vcvalue.TypeUint64: tagUint64, + vcvalue.TypeUint128: tagUint128, + vcvalue.TypeFloat32: tagFloat32, + vcvalue.TypeFloat64: tagFloat64, + vcvalue.TypeDate: tagDate, + vcvalue.TypeTimestamp: tagTimestamp, + vcvalue.TypeDecimal: tagDecimal, +} + +// TypedNull records an absent value of a fixed-width type: a null padded to +// the length of a value of t, so the two seal to the same length. Encode +// writes one for a nil pointer to a fixed-width type; this channel is for a +// caller that knows the type some other way. An invalid t fails the +// encode. +func (e Encoder) TypedNull(t vcvalue.FixedType) { + if e.st.err != nil { + return + } + var header []byte + var payload int + if t == vcvalue.TypeUUID { + header, payload = []byte{tagEnvelope, logicalUUID, 0, tagBytes}, 16 + } else if tag, ok := fixedTags[t]; ok { + header, payload = []byte{tag}, fixedWidth(tag) + } else { + e.st.fail(vcvalue.ErrInvalidFixedType) + return + } + e.push(tagTypedNull) + e.push(header...) + // One byte of the value's length went to tagTypedNull. + e.push(make([]byte, payload-1)...) + e.complete() +} + +// decodeTypedNull reads a typed null after its tag, as strictly as the Rust +// codec does, so every absence has one encoding: a header a value of some +// fixed-width type starts with, then exactly the zero padding to that +// value's length. It decodes as nil. A frame is never sealed, so the padding +// means nothing once the value is out, and Rust's frame writer writes the +// plain null for it too. +func decodeTypedNull(r *reader) (any, error) { + tag, err := r.byteTag() + if err != nil { + return nil, err + } + var payload int + switch { + case tag == tagEnvelope: + h, err := r.take(3) + if err != nil { + return nil, err + } + id, param, physical := h[0], h[1], h[2] + switch id { + case logicalTimestamp: + // The nanosecond unit has the shorthand Timestamp tag. + if param > unitMicros || !numericTag(physical, false) { + return nil, ErrMalformed + } + payload = fixedWidth(physical) + case logicalUUID: + if param != 0 || physical != tagBytes { + return nil, ErrMalformed + } + payload = 16 + case 0, logicalUndefined, logicalDate, logicalDecimal: + return nil, ErrMalformed + default: + // A logical id this build does not know, over an int or float. + if !numericTag(physical, true) { + return nil, ErrMalformed + } + payload = fixedWidth(physical) + } + case tag == tagDate || tag == tagTimestamp || tag == tagDecimal || numericTag(tag, true): + payload = fixedWidth(tag) + default: + return nil, ErrMalformed + } + padding, err := r.take(payload - 1) + if err != nil { + return nil, err + } + for _, b := range padding { + if b != 0 { + return nil, ErrMalformed + } + } + return nil, nil +} diff --git a/bindings/go/vcffi/transport.go b/bindings/go/vcffi/transport.go index 83f4d0ee..6ad1a9bb 100644 --- a/bindings/go/vcffi/transport.go +++ b/bindings/go/vcffi/transport.go @@ -35,6 +35,10 @@ const ( tagDate = 0x12 tagTimestamp = 0x13 tagDecimal = 0x14 + // tagTypedNull is an absent value of a fixed-width type, padded to that + // type's leaf length: the tag, the header a value of the type starts + // with, then zeros (vitaminc#493). + tagTypedNull = 0x15 tagArray = 0xF0 tagObject = 0xF1 // tagPassthrough wraps one value node that travels in the clear diff --git a/bindings/go/vcffi/typed_null_test.go b/bindings/go/vcffi/typed_null_test.go new file mode 100644 index 00000000..db54d418 --- /dev/null +++ b/bindings/go/vcffi/typed_null_test.go @@ -0,0 +1,231 @@ +package vcffi + +import ( + "bytes" + "encoding/hex" + "errors" + "testing" + "time" + + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// typedNullAnswers pins each FixedType's typed null: the cross-language +// bytes the Rust codec writes for the same type (vitaminc-aead's leaf +// tests), beside a value of the type, which must be as long. +var typedNullAnswers = []struct { + t vcvalue.FixedType + hex string + present any +}{ + {vcvalue.TypeInt8, "150c", int8(-1)}, + {vcvalue.TypeInt16, "150e00", int16(-1)}, + {vcvalue.TypeInt32, "1504000000", int32(-1)}, + {vcvalue.TypeInt64, "1505" + zeros(7), int64(-1)}, + {vcvalue.TypeInt128, "1510" + zeros(15), vcvalue.Int128{1}}, + {vcvalue.TypeUint8, "150d", uint8(1)}, + {vcvalue.TypeUint16, "150f00", uint16(1)}, + {vcvalue.TypeUint32, "1506000000", uint32(1)}, + {vcvalue.TypeUint64, "1507" + zeros(7), uint64(1)}, + {vcvalue.TypeUint128, "1511" + zeros(15), vcvalue.Uint128{1}}, + {vcvalue.TypeFloat32, "1508000000", float32(1.5)}, + {vcvalue.TypeFloat64, "1509" + zeros(7), 1.5}, + {vcvalue.TypeDate, "1512000000", vcvalue.Date{Year: 1999, Month: 12, Day: 31}}, + {vcvalue.TypeTimestamp, "1513" + zeros(11), time.Unix(1_700_000_000, 5).UTC()}, + {vcvalue.TypeDecimal, "1514" + zeros(15), vcvalue.Decimal{Scale: 2, Coefficient: [12]byte{150}}}, + {vcvalue.TypeUUID, "15f305000b" + zeros(15), vcvalue.UUID{0xAB}}, +} + +func zeros(n int) string { return hex.EncodeToString(make([]byte, n)) } + +func TestTypedNullKnownAnswers(t *testing.T) { + for _, row := range typedNullAnswers { + got, err := Marshal(vcvalue.TypedNull{Type: row.t}) + if err != nil { + t.Fatalf("%v: %v", row.t, err) + } + if hex.EncodeToString(got) != row.hex { + t.Fatalf("%v: got %x, want %s", row.t, got, row.hex) + } + present, err := Marshal(row.present) + if err != nil { + t.Fatalf("%v: %v", row.t, err) + } + // A frame gives bytes a length prefix; a typed null of a UUID has + // none, because its sealed leaf has none. + want := len(present) + if row.t == vcvalue.TypeUUID { + want -= 4 + } + if len(got) != want { + t.Fatalf("%v: typed null is %d bytes, a value %d", row.t, len(got), want) + } + // It reads back as nil, like every null, and writes back as one. + v, err := Unmarshal(got) + if err != nil || v != nil { + t.Fatalf("%v: decoded %#v, %v", row.t, v, err) + } + } + if len(typedNullAnswers) != int(vcvalue.TypeUUID) { + t.Fatalf("pinned %d of %d types", len(typedNullAnswers), vcvalue.TypeUUID) + } +} + +func TestAnInvalidFixedTypeFailsTheEncode(t *testing.T) { + for _, ft := range []vcvalue.FixedType{0, vcvalue.TypeUUID + 1, 255} { + if _, err := Marshal(vcvalue.TypedNull{Type: ft}); !errors.Is(err, vcvalue.ErrInvalidFixedType) { + t.Fatalf("%v: got %v", ft, err) + } + } +} + +type selfEncoded int32 + +func (s selfEncoded) EncryptValue(enc Encoder) error { + enc.String("custom") + return nil +} + +// A nil pointer to a fixed-width type is a typed null, the Go side of +// Rust's Option; a nil with no fixed-width type to go by is the plain +// null. +func TestANilPointerIsATypedNullWhenItsTypeIsFixedWidth(t *testing.T) { + type age int32 + cases := []struct { + name string + v any + want string + }{ + {"*int8", (*int8)(nil), "150c"}, + {"*int", (*int)(nil), "1505" + zeros(7)}, + {"*uint", (*uint)(nil), "1507" + zeros(7)}, + {"*float32", (*float32)(nil), "1508000000"}, + {"*time.Time", (*time.Time)(nil), "1513" + zeros(11)}, + {"*vcvalue.Date", (*vcvalue.Date)(nil), "1512000000"}, + {"*vcvalue.Timestamp", (*vcvalue.Timestamp)(nil), "1513" + zeros(11)}, + {"*vcvalue.Decimal", (*vcvalue.Decimal)(nil), "1514" + zeros(15)}, + {"*vcvalue.UUID", (*vcvalue.UUID)(nil), "15f305000b" + zeros(15)}, + {"*vcvalue.Int128", (*vcvalue.Int128)(nil), "1510" + zeros(15)}, + {"named *age", (*age)(nil), "1504000000"}, + {"**int32", (**int32)(nil), "1504000000"}, + // No fixed width: the plain null. + {"*bool", (*bool)(nil), "00"}, + {"*string", (*string)(nil), "00"}, + {"*[]byte", (*[]byte)(nil), "00"}, + {"*[16]byte", (*[16]byte)(nil), "00"}, + {"*struct", (*struct{ A int32 })(nil), "00"}, + {"*Encryptable", (*selfEncoded)(nil), "00"}, + {"nil", nil, "00"}, + } + for _, c := range cases { + got, err := Marshal(c.v) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if hex.EncodeToString(got) != c.want { + t.Fatalf("%s: got %x, want %s", c.name, got, c.want) + } + } +} + +func TestAStructsNilFieldsAreTypedNulls(t *testing.T) { + type person struct { + Name string + Born *vcvalue.Date + Age *int32 + Tags []string + Any any + } + got, err := Marshal(person{Name: "Ada"}) + if err != nil { + t.Fatal(err) + } + v, err := Unmarshal(got) + if err != nil { + t.Fatal(err) + } + for _, f := range v.(vcvalue.Object) { + if (f.Key == "Born" || f.Key == "Age" || f.Key == "Any") && f.Value != nil { + t.Fatalf("%s decoded %#v", f.Key, f.Value) + } + } + // Born and Age carry their padding; Any has no type to go by, and a nil + // slice is an empty sequence, as it always was. + if !bytes.Contains(got, []byte{tagTypedNull, tagDate, 0, 0, 0}) || + !bytes.Contains(got, []byte{tagTypedNull, tagInt32, 0, 0, 0}) { + t.Fatalf("no typed nulls in %x", got) + } +} + +// The explicit channel, for an Encryptable that knows the type. +type maybeAge struct{ v *int32 } + +func (m maybeAge) EncryptValue(enc Encoder) error { + if m.v == nil { + enc.TypedNull(vcvalue.TypeInt32) + } else { + enc.Int32(*m.v) + } + return nil +} + +func TestTheTypedNullChannel(t *testing.T) { + got, err := Marshal(maybeAge{}) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(got) != "1504000000" { + t.Fatalf("got %x", got) + } + // It completes exactly one slot, like every terminal. + enc := func(e Encoder) error { + s := e.Seq() + s.Elem().TypedNull(vcvalue.TypeUint8) + s.Elem().Null() + return s.End() + } + got, err = Marshal(encryptableFunc(enc)) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(got) != "f002000000150d00" { + t.Fatalf("got %x", got) + } +} + +type encryptableFunc func(Encoder) error + +func (f encryptableFunc) EncryptValue(e Encoder) error { return f(e) } + +// Every absence has one encoding: the decoder refuses anything a writer +// does not write. The shared corpus pins the same refusals for every +// language; these are the Go-specific edges around them. +func TestATypedNullIsReadStrictly(t *testing.T) { + refused := []string{ + "15", "1504", "15040000", "1504000001", "150400000000", + "1500", "1502", "150a", "150b" + zeros(15), "1515040000", + "15f3", "15f303", "15f30301", "15f3030304" + zeros(3), "15f3030409" + zeros(7), + "15f3050104" + zeros(3), "15f3c8000a", "15f3c8000b" + zeros(15), + "f0010000001502", + } + for _, h := range refused { + raw, _ := hex.DecodeString(h) + if _, err := Unmarshal(raw); err != ErrMalformed { + t.Fatalf("%s: got %v, want ErrMalformed", h, err) + } + } + accepted := []string{ + "15f3030004000000", // seconds over an int32 + "15f3030210" + zeros(15), // microseconds over an int128 + "15f3c8ff09" + zeros(7), // an unknown id, any parameter, over a float64 + "f0010000001506000000", // inside an array + "f2150c", // as passthrough + "f101000000010000006115120000" + "00", // as an object value + } + for _, h := range accepted { + raw, _ := hex.DecodeString(h) + if _, err := Unmarshal(raw); err != nil { + t.Fatalf("%s: %v", h, err) + } + } +} diff --git a/bindings/go/vcvalue/doc.go b/bindings/go/vcvalue/doc.go index 628dfb92..612b7f86 100644 --- a/bindings/go/vcvalue/doc.go +++ b/bindings/go/vcvalue/doc.go @@ -12,7 +12,9 @@ // - Sealed is one encrypted leaf (version || nonce || ciphertext || tag), // the only frozen byte format in the model. SealedEmptySeq and // SealedEmptyMap are its authenticated marker siblings for empty -// composites; an absent value is a sealed null leaf (ADR 0001). This +// composites; an absent value is a sealed null leaf (ADR 0001), padded +// to the length of a value when its type is fixed width (TypedNull, +// and a nil pointer to such a type; vitaminc#493). This // sealed family is vitaminc-encrypt's materialization of the model: another binding (a stack-encrypt SDK, // say) defines its own distinct sealed-leaf types and wires them to the // codec via vcffi's LeafSet — a stack-encrypt leaf is never a Sealed. diff --git a/bindings/go/vcvalue/null.go b/bindings/go/vcvalue/null.go new file mode 100644 index 00000000..a9ebfd29 --- /dev/null +++ b/bindings/go/vcvalue/null.go @@ -0,0 +1,48 @@ +package vcvalue + +// FixedType names a fixed-width type: one whose every value seals to a leaf +// of the same length. An absent value of such a type is written as a typed +// null, padded to that length, so a sealed absence cannot be told from a +// sealed value by its length, as a plain null (one byte) could be. +// +// bool is fixed width too, but its leaf is already as short as a null's, +// and text and bytes are variable width, so neither has a FixedType. A UUID's +// sixteen bytes are fixed by its type, so it has one. +type FixedType uint8 + +// The fixed-width types, each named for the Go type it is written from. +// TypeInt64 and TypeUint64 cover int and uint too, as the codec writes them. +const ( + TypeInt8 FixedType = iota + 1 + TypeInt16 + TypeInt32 + TypeInt64 + TypeInt128 + TypeUint8 + TypeUint16 + TypeUint32 + TypeUint64 + TypeUint128 + TypeFloat32 + TypeFloat64 + TypeDate + TypeTimestamp + TypeDecimal + TypeUUID +) + +// Valid reports whether t is one of the named fixed-width types. +func (t FixedType) Valid() bool { + return t >= TypeInt8 && t <= TypeUUID +} + +// TypedNull is an absent value of a fixed-width type, for a caller whose Go +// type cannot say what is missing: a nil in a map[string]any or an []any. +// A nil pointer to a fixed-width type (*int32, *time.Time, *Date, …) is +// written as a typed null without it. +// +// It only encodes. A typed null decodes as nil, like every null: once +// decrypted, an absent value is absent whatever its type. +type TypedNull struct { + Type FixedType +} diff --git a/bindings/go/vcvalue/scalars.go b/bindings/go/vcvalue/scalars.go index ec1033e7..7eebaef2 100644 --- a/bindings/go/vcvalue/scalars.go +++ b/bindings/go/vcvalue/scalars.go @@ -15,6 +15,7 @@ var ( ErrNonFiniteDecimal = errors.New("vcvalue: decimal must be finite") ErrInvalidUUID = errors.New("vcvalue: invalid UUID") ErrInvalidTagged = errors.New("vcvalue: a tagged value must hold a physical scalar under an unknown logical id") + ErrInvalidFixedType = errors.New("vcvalue: not a fixed-width type") ) // Int128 is a signed two's-complement integer in little-endian byte order. From 4611025a5cc8a3d5e87165bf9557d96a6e6b76ba Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 20 Oct 2026 22:55:27 +1100 Subject: [PATCH 3/4] fix(vcffi): address review of typed nulls: one table for markers and their padding, sharper tests and docs - Drive interceptMarkers and the numeric kinds of encodeReflect from the same tables fixedTypeOf reads, so a new marker type cannot be written one way and padded another; a test checks every fixed-width entry against a sample value, and fails for a marker added without one. fixedTypeOf now runs only for a nil pointer and is cached per type. - Share the UUID envelope header between Encoder.UUID and its typed null, and check Encoder.TypedNull's type with FixedType.Valid. - Make the corpus's uuid-with-length vector the full 24 bytes, so it tests a length prefix rather than truncation. - Check the *string half of the plain-null test. - Say in the README that only named int and float types are padded, that a value re-encrypted from any or vcvalue.Object seals its nils as the plain null, and that a binding must update its guest with this module. - Hoist the differential walker's width tables to one shared table. Refs #493 --- .../go/vcencrypt/differential_fuzz_test.go | 22 +- bindings/go/vcencrypt/typed_null_test.go | 10 + bindings/go/vcffi/README.md | 18 +- bindings/go/vcffi/encoder.go | 234 ++++++++++-------- bindings/go/vcffi/scalars.go | 20 +- bindings/go/vcffi/typed_null_test.go | 57 +++++ testdata/value-conformance.json | 2 +- 7 files changed, 239 insertions(+), 124 deletions(-) diff --git a/bindings/go/vcencrypt/differential_fuzz_test.go b/bindings/go/vcencrypt/differential_fuzz_test.go index f8c3bffa..34dba19e 100644 --- a/bindings/go/vcencrypt/differential_fuzz_test.go +++ b/bindings/go/vcencrypt/differential_fuzz_test.go @@ -129,6 +129,21 @@ func corpusCiphertexts(f *testing.F) [][]byte { return out } +// intWidths is the payload width of each int tag, which a timestamp in the +// envelope rides on. +var intWidths = map[byte]int{0x0C: 1, 0x0D: 1, 0x0E: 2, 0x0F: 2, 0x04: 4, 0x06: 4, 0x05: 8, 0x07: 8, 0x10: 16, 0x11: 16} + +// typedNullWidths is the payload width of each header a typed null may +// carry: an int, a float, a date, a timestamp, a decimal, and bytes, which +// only a UUID's typed null carries, sixteen wide. +var typedNullWidths = func() map[byte]int { + m := map[byte]int{0x08: 4, 0x12: 4, 0x09: 8, 0x13: 12, 0x14: 16, 0x0B: 16} + for tag, width := range intWidths { + m[tag] = width + } + return m +}() + // requireGoMatchesRust fails unless goOut is rustOut with exactly Go's // documented projections applied: an Undefined value-node tag (0x01) turned // into Null (0x00), and a timestamp in the 0xF3 envelope (seconds, @@ -229,8 +244,6 @@ func rewrite(data []byte, goProjection bool) []byte { out = append(out, tag) walk() case 0x15: // a typed null: both sides write it back as the plain null - widths := map[byte]int{0x0C: 1, 0x0D: 1, 0x0E: 2, 0x0F: 2, 0x04: 4, 0x06: 4, 0x08: 4, - 0x12: 4, 0x05: 8, 0x07: 8, 0x09: 8, 0x13: 12, 0x10: 16, 0x11: 16, 0x14: 16, 0x0B: 16} header := take(1) if len(header) == 1 && header[0] == 0xF3 { // id and parameter, then the physical tag the width follows. @@ -239,7 +252,7 @@ func rewrite(data []byte, goProjection bool) []byte { } } if len(header) == 1 { - take(widths[header[0]] - 1) + take(typedNullWidths[header[0]] - 1) } out = append(out, 0x00) case 0xF3: // the envelope: id, parameter, then a physical scalar @@ -269,8 +282,7 @@ func shorthandTimestamp(data []byte, pos *int, unit byte) ([]byte, bool) { return nil, false } tag := data[*pos] - widths := map[byte]int{0x0C: 1, 0x0D: 1, 0x0E: 2, 0x0F: 2, 0x04: 4, 0x06: 4, 0x05: 8, 0x07: 8, 0x10: 16, 0x11: 16} - width, ok := widths[tag] + width, ok := intWidths[tag] if !ok || *pos+1+width > len(data) { return nil, false } diff --git a/bindings/go/vcencrypt/typed_null_test.go b/bindings/go/vcencrypt/typed_null_test.go index ea5c80c0..dea9829e 100644 --- a/bindings/go/vcencrypt/typed_null_test.go +++ b/bindings/go/vcencrypt/typed_null_test.go @@ -86,4 +86,14 @@ func TestANilBoolOrStringSealsThePlainNull(t *testing.T) { if got, want := len(ct.(map[string]any)["On"].(vcvalue.Sealed)), len(present.(map[string]any)["On"].(vcvalue.Sealed)); got != want { t.Fatalf("bool: absent %d, present %d", got, want) } + // A nil *string seals the plain null: as long as an empty string, and + // shorter than any other. + empty := "" + withName, err := cipher.Encrypt(t.Context(), rec{Name: &empty}, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if got, want := len(ct.(map[string]any)["Name"].(vcvalue.Sealed)), len(withName.(map[string]any)["Name"].(vcvalue.Sealed)); got != want { + t.Fatalf("string: absent %d, empty %d", got, want) + } } diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index 6488a73e..f46702ab 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -172,7 +172,8 @@ implementations. AES-GCM keeps the plaintext's length, so a one-byte null sealed in place of a date would show which rows have no date. A nil pointer to a fixed-width type (`*int32`, `*float64`, `*time.Time`, `*vcvalue.Date`, `*vcvalue.Decimal`, -`*vcvalue.UUID`, and so on, a named type over one included) is therefore +`*vcvalue.UUID`, and so on, and a named int or float type such as +`type Age int32`) is therefore written as a **typed null**, padded to the length of a value of that type, as Rust writes an `Option`'s `None` (vitaminc#493): @@ -185,14 +186,23 @@ type Person struct { A nil with no fixed-width type to go by is the plain null: a nil interface, a nil `*bool` (a bool is already as short as a null), a nil `*string` or -`*[]byte` (variable width), and a nil pointer to an `Encryptable`, whose -encoding is its own. Where the Go type cannot say what is missing (a nil in a +`*[]byte` (variable width), a nil pointer to an `Encryptable`, whose +encoding is its own, and a named type over a `vcvalue` or `time` type (`type +Birthday vcvalue.Date`), which reflection writes field by field, not as a +date. Where the Go type cannot say what is missing (a nil in a `map[string]any`), write `vcvalue.TypedNull{Type: vcvalue.TypeDate}`, or call `Encoder.TypedNull` from an `Encryptable`. A typed null decodes as `nil`, like every null. Out of the seal the padding means nothing, and Rust's frame writer writes it back as the plain null, so -Go does too. +Go does too. A decrypted value therefore no longer says what type an absent +field was: hold optional fields in a typed struct (`Born *vcvalue.Date`) +before encrypting again. A value re-encrypted from `any` or `vcvalue.Object` +seals its nils as the plain null, and its absent values show by length. + +Reading a typed null takes a guest built from the vitaminc release that adds +it (vitaminc#493); an older one refuses the frame. A binding that ships its +own guest must update it together with this module. The codec reads `testdata/value-conformance.json` from the repo root directly, as do Rust and Node. Invalid scalar widths, date ranges, nanoseconds, decimal diff --git a/bindings/go/vcffi/encoder.go b/bindings/go/vcffi/encoder.go index 1c0359f4..db9dff10 100644 --- a/bindings/go/vcffi/encoder.go +++ b/bindings/go/vcffi/encoder.go @@ -8,6 +8,7 @@ import ( "reflect" "slices" "strings" + "sync" "time" "unicode/utf8" @@ -501,39 +502,8 @@ func encodeAny(enc Encoder, v any) { // pointer, which still satisfies the assertion but would dereference nil // in typical implementations; it encodes as Null like every other nil. func interceptMarkers(enc Encoder, v any) bool { - switch x := v.(type) { - case vcvalue.Int128: - enc.Int128(x) - return true - case vcvalue.Uint128: - enc.UInt128(x) - return true - case vcvalue.Date: - enc.Date(x) - return true - case time.Time: - enc.Timestamp(x) - return true - case vcvalue.Timestamp: - enc.TimestampParts(x) - return true - case vcvalue.Decimal: - enc.Decimal(x) - return true - case vcvalue.UUID: - enc.UUID(x) - return true - case vcvalue.Tagged: - enc.Tagged(x) - return true - case vcvalue.TypedNull: - enc.TypedNull(x.Type) - return true - case vcvalue.Plain: - encodeAny(enc.Passthrough(), x.V) - return true - case vcvalue.Object: - encodeObject(enc, x) + if m, ok := markers[reflect.TypeOf(v)]; ok { + m.encode(enc, v) return true } if e, ok := v.(Encryptable); ok { @@ -574,40 +544,13 @@ func encodeReflect(enc Encoder, rv reflect.Value) { return } + if k, ok := numericKinds[rv.Kind()]; ok { + k.encode(enc, rv) + return + } switch rv.Kind() { case reflect.Bool: enc.Bool(rv.Bool()) - case reflect.Int8: - enc.Int8(int8(rv.Int())) - case reflect.Int16: - enc.Int16(int16(rv.Int())) - case reflect.Int32: - enc.Int32(int32(rv.Int())) - case reflect.Int, reflect.Int64: - enc.Int64(rv.Int()) - case reflect.Uint8: - enc.UInt8(uint8(rv.Uint())) - case reflect.Uint16: - enc.UInt16(uint16(rv.Uint())) - case reflect.Uint32: - enc.UInt32(uint32(rv.Uint())) - case reflect.Uint, reflect.Uint64, reflect.Uintptr: - // Every unsigned word-or-wider kind keeps its unsigned identity as - // UInt64 (no fit-check: a bare uint always maps to UINT64 now). - enc.UInt64(rv.Uint()) - case reflect.Float32: - // rv.Float() widens to float64, and the hardware widening sets the - // quiet bit of a signaling NaN. Converting to the built-in float32 - // instead copies the bits (reflect special-cases float32 to float32, - // Go issue 36400), so a named float32 type keeps them too. Only a - // value that cannot be read as an interface falls back to widening. - if f, ok := interfaceOf(rv.Convert(float32Type)).(float32); ok { - enc.Float32(f) - } else { - enc.Float32(float32(rv.Float())) - } - case reflect.Float64: - enc.Float64(rv.Float()) case reflect.String: enc.String(rv.String()) case reflect.Slice: @@ -637,7 +580,9 @@ func encodeReflect(enc Encoder, rv reflect.Value) { // A nil pointer still has its element type, so an absent // fixed-width value is padded like Rust's Option. A nil // interface has no type to go by. - if t, ok := fixedTypeOf(rv.Type()); ok && rv.Kind() == reflect.Pointer { + if rv.Kind() != reflect.Pointer { + enc.Null() + } else if t, ok := fixedTypeOf(rv.Type()); ok { enc.TypedNull(t) } else { enc.Null() @@ -717,61 +662,136 @@ func encodeStruct(enc Encoder, rv reflect.Value) { _ = m.End() } +// marker is how interceptMarkers writes a type it handles by exact type, +// and the fixed-width type of its typed null, if it has one (zero if not). +// One table for both, so a new marker type cannot be written one way and +// padded, or not, another. +type marker struct { + fixed vcvalue.FixedType + encode func(Encoder, any) +} + +// numeric is how encodeReflect writes an int, uint or float kind, and the +// fixed-width type of its typed null: the same one-table rule as marker. +type numeric struct { + fixed vcvalue.FixedType + encode func(Encoder, reflect.Value) +} + var ( encryptableType = reflect.TypeOf((*Encryptable)(nil)).Elem() - timeType = reflect.TypeOf(time.Time{}) - fixedNamed = map[reflect.Type]vcvalue.FixedType{ - reflect.TypeOf(vcvalue.Int128{}): vcvalue.TypeInt128, - reflect.TypeOf(vcvalue.Uint128{}): vcvalue.TypeUint128, - reflect.TypeOf(vcvalue.Date{}): vcvalue.TypeDate, - reflect.TypeOf(vcvalue.Timestamp{}): vcvalue.TypeTimestamp, - reflect.TypeOf(vcvalue.Decimal{}): vcvalue.TypeDecimal, - reflect.TypeOf(vcvalue.UUID{}): vcvalue.TypeUUID, - timeType: vcvalue.TypeTimestamp, - } - fixedKinds = map[reflect.Kind]vcvalue.FixedType{ - reflect.Int8: vcvalue.TypeInt8, - reflect.Int16: vcvalue.TypeInt16, - reflect.Int32: vcvalue.TypeInt32, - reflect.Int: vcvalue.TypeInt64, - reflect.Int64: vcvalue.TypeInt64, - reflect.Uint8: vcvalue.TypeUint8, - reflect.Uint16: vcvalue.TypeUint16, - reflect.Uint32: vcvalue.TypeUint32, - reflect.Uint: vcvalue.TypeUint64, - reflect.Uint64: vcvalue.TypeUint64, - reflect.Uintptr: vcvalue.TypeUint64, - reflect.Float32: vcvalue.TypeFloat32, - reflect.Float64: vcvalue.TypeFloat64, + float32Type = reflect.TypeOf(float32(0)) + + // markers is filled in init: Plain and Object encode by calling back + // into encodeAny, which reads the table. + markers map[reflect.Type]marker + + numericKinds = map[reflect.Kind]numeric{ + reflect.Int8: {vcvalue.TypeInt8, func(e Encoder, rv reflect.Value) { e.Int8(int8(rv.Int())) }}, + reflect.Int16: {vcvalue.TypeInt16, func(e Encoder, rv reflect.Value) { e.Int16(int16(rv.Int())) }}, + reflect.Int32: {vcvalue.TypeInt32, func(e Encoder, rv reflect.Value) { e.Int32(int32(rv.Int())) }}, + reflect.Int: {vcvalue.TypeInt64, func(e Encoder, rv reflect.Value) { e.Int64(rv.Int()) }}, + reflect.Int64: {vcvalue.TypeInt64, func(e Encoder, rv reflect.Value) { e.Int64(rv.Int()) }}, + reflect.Uint8: {vcvalue.TypeUint8, func(e Encoder, rv reflect.Value) { e.UInt8(uint8(rv.Uint())) }}, + reflect.Uint16: {vcvalue.TypeUint16, func(e Encoder, rv reflect.Value) { + e.UInt16(uint16(rv.Uint())) + }}, + reflect.Uint32: {vcvalue.TypeUint32, func(e Encoder, rv reflect.Value) { + e.UInt32(uint32(rv.Uint())) + }}, + // Every unsigned word-or-wider kind keeps its unsigned identity as + // UInt64 (no fit-check: a bare uint always maps to UINT64). + reflect.Uint: {vcvalue.TypeUint64, func(e Encoder, rv reflect.Value) { e.UInt64(rv.Uint()) }}, + reflect.Uint64: {vcvalue.TypeUint64, func(e Encoder, rv reflect.Value) { e.UInt64(rv.Uint()) }}, + reflect.Uintptr: {vcvalue.TypeUint64, func(e Encoder, rv reflect.Value) { e.UInt64(rv.Uint()) }}, + reflect.Float32: {vcvalue.TypeFloat32, func(e Encoder, rv reflect.Value) { + // rv.Float() widens to float64, and the hardware widening sets + // the quiet bit of a signaling NaN. Converting to the built-in + // float32 instead copies the bits (reflect special-cases float32 + // to float32, Go issue 36400), so a named float32 type keeps + // them too. Only a value that cannot be read as an interface + // falls back to widening. + if f, ok := interfaceOf(rv.Convert(float32Type)).(float32); ok { + e.Float32(f) + } else { + e.Float32(float32(rv.Float())) + } + }}, + reflect.Float64: {vcvalue.TypeFloat64, func(e Encoder, rv reflect.Value) { e.Float64(rv.Float()) }}, } + + // fixedTypes caches fixedTypeOf by pointer type: every nil pointer of a + // record type would otherwise pay its reflection again. + fixedTypes sync.Map ) +func init() { + markers = map[reflect.Type]marker{ + reflect.TypeOf(vcvalue.Int128{}): {vcvalue.TypeInt128, func(e Encoder, v any) { + e.Int128(v.(vcvalue.Int128)) + }}, + reflect.TypeOf(vcvalue.Uint128{}): {vcvalue.TypeUint128, func(e Encoder, v any) { + e.UInt128(v.(vcvalue.Uint128)) + }}, + reflect.TypeOf(vcvalue.Date{}): {vcvalue.TypeDate, func(e Encoder, v any) { + e.Date(v.(vcvalue.Date)) + }}, + reflect.TypeOf(time.Time{}): {vcvalue.TypeTimestamp, func(e Encoder, v any) { + e.Timestamp(v.(time.Time)) + }}, + reflect.TypeOf(vcvalue.Timestamp{}): {vcvalue.TypeTimestamp, func(e Encoder, v any) { + e.TimestampParts(v.(vcvalue.Timestamp)) + }}, + reflect.TypeOf(vcvalue.Decimal{}): {vcvalue.TypeDecimal, func(e Encoder, v any) { + e.Decimal(v.(vcvalue.Decimal)) + }}, + reflect.TypeOf(vcvalue.UUID{}): {vcvalue.TypeUUID, func(e Encoder, v any) { + e.UUID(v.(vcvalue.UUID)) + }}, + // A tagged value's physical type is in the value, not the type. + reflect.TypeOf(vcvalue.Tagged{}): {0, func(e Encoder, v any) { e.Tagged(v.(vcvalue.Tagged)) }}, + reflect.TypeOf(vcvalue.TypedNull{}): {0, func(e Encoder, v any) { + e.TypedNull(v.(vcvalue.TypedNull).Type) + }}, + reflect.TypeOf(vcvalue.Plain{}): {0, func(e Encoder, v any) { + encodeAny(e.Passthrough(), v.(vcvalue.Plain).V) + }}, + reflect.TypeOf(vcvalue.Object{}): {0, func(e Encoder, v any) { + encodeObject(e, v.(vcvalue.Object)) + }}, + } +} + // fixedTypeOf is the fixed-width type a pointer of type t points at, through -// any number of pointers, when encodeReflect would write every value of it -// with one fixed-width tag: the codec's int, uint and float kinds, and the -// fixed-width vcvalue and time types. A type that implements Encryptable -// chooses its own encoding, which may not be fixed width, so it has none. +// any number of pointers, when encodeReflect writes every value of it with +// one fixed-width tag: the int, uint and float kinds (a named type over one +// included) and the fixed-width marker types, exactly (a named type over a +// marker is a struct to reflection, written field by field). A type that +// implements Encryptable chooses its own encoding, which may not be fixed +// width, so it has none. func fixedTypeOf(t reflect.Type) (vcvalue.FixedType, bool) { - for t.Kind() == reflect.Pointer { - if t.Implements(encryptableType) { - return 0, false + if cached, ok := fixedTypes.Load(t); ok { + ft := cached.(vcvalue.FixedType) + return ft, ft != 0 + } + var ft vcvalue.FixedType + elem := t + for elem.Kind() == reflect.Pointer && !elem.Implements(encryptableType) { + elem = elem.Elem() + } + if !elem.Implements(encryptableType) { + if m, ok := markers[elem]; ok { + ft = m.fixed + } else { + ft = numericKinds[elem.Kind()].fixed } - t = t.Elem() } - if t.Implements(encryptableType) { - return 0, false - } - if ft, ok := fixedNamed[t]; ok { - return ft, true - } - ft, ok := fixedKinds[t.Kind()] - return ft, ok + fixedTypes.Store(t, ft) + return ft, ft != 0 } // interfaceOf is rv.Interface(), or nil where reflection forbids it // (values reached through unexported fields). -var float32Type = reflect.TypeOf(float32(0)) - func interfaceOf(rv reflect.Value) any { if !rv.CanInterface() { return nil diff --git a/bindings/go/vcffi/scalars.go b/bindings/go/vcffi/scalars.go index f5c76fe1..449cda13 100644 --- a/bindings/go/vcffi/scalars.go +++ b/bindings/go/vcffi/scalars.go @@ -78,10 +78,14 @@ func (e Encoder) UUID(u vcvalue.UUID) { if e.st.err != nil { return } - e.push(tagEnvelope, logicalUUID, 0) + e.push(uuidEnvelope[:]...) e.Bytes(u[:]) } +// uuidEnvelope is the envelope header a UUID starts with, and the header of +// a UUID's typed null: the Uuid logical type, no parameter, over bytes. +var uuidEnvelope = [3]byte{tagEnvelope, logicalUUID, 0} + // Tagged records a physical scalar under a logical id this build does not // know, in the 0xF3 envelope: what Unmarshal produced for such a value, so // it goes back out unchanged. An id this build knows, or a value that is @@ -230,7 +234,7 @@ func numericTag(tag byte, floats bool) bool { } // fixedTags is the tag each FixedType is written with, all but the UUID, -// which travels in the envelope. +// whose header is uuidEnvelope over tagBytes. var fixedTags = map[vcvalue.FixedType]byte{ vcvalue.TypeInt8: tagInt8, vcvalue.TypeInt16: tagInt16, @@ -258,15 +262,17 @@ func (e Encoder) TypedNull(t vcvalue.FixedType) { if e.st.err != nil { return } + if !t.Valid() { + e.st.fail(vcvalue.ErrInvalidFixedType) + return + } var header []byte var payload int if t == vcvalue.TypeUUID { - header, payload = []byte{tagEnvelope, logicalUUID, 0, tagBytes}, 16 - } else if tag, ok := fixedTags[t]; ok { - header, payload = []byte{tag}, fixedWidth(tag) + header, payload = append(uuidEnvelope[:], tagBytes), len(vcvalue.UUID{}) } else { - e.st.fail(vcvalue.ErrInvalidFixedType) - return + tag := fixedTags[t] + header, payload = []byte{tag}, fixedWidth(tag) } e.push(tagTypedNull) e.push(header...) diff --git a/bindings/go/vcffi/typed_null_test.go b/bindings/go/vcffi/typed_null_test.go index db54d418..6d53d7a0 100644 --- a/bindings/go/vcffi/typed_null_test.go +++ b/bindings/go/vcffi/typed_null_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "errors" + "reflect" "testing" "time" @@ -229,3 +230,59 @@ func TestATypedNullIsReadStrictly(t *testing.T) { } } } + +// Every fixed-width marker and kind writes its values with the header its +// typed null carries, so the one table cannot pad a type as something it is +// not. A marker added without a sample here fails the test. +func TestEveryFixedTypeIsWrittenWithItsTypedNullsHeader(t *testing.T) { + samples := map[reflect.Type]any{ + reflect.TypeOf(vcvalue.Int128{}): vcvalue.Int128{1}, + reflect.TypeOf(vcvalue.Uint128{}): vcvalue.Uint128{1}, + reflect.TypeOf(vcvalue.Date{}): vcvalue.Date{Year: 2000, Month: 1, Day: 1}, + reflect.TypeOf(time.Time{}): time.Unix(7, 0), + reflect.TypeOf(vcvalue.Timestamp{}): vcvalue.Timestamp{Seconds: 7}, + reflect.TypeOf(vcvalue.Decimal{}): vcvalue.Decimal{Scale: 1}, + reflect.TypeOf(vcvalue.UUID{}): vcvalue.UUID{1}, + } + check := func(name string, ft vcvalue.FixedType, sample any) { + null, err := Marshal(vcvalue.TypedNull{Type: ft}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + value, err := Marshal(sample) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + // The typed null's header (after its tag) begins the value's. + header := null[1:] + for len(header) > 0 && header[len(header)-1] == 0 { + header = header[:len(header)-1] + } + if !bytes.HasPrefix(value, header) { + t.Fatalf("%s: value %x does not start with its typed null's header %x", name, value, header) + } + } + for typ, m := range markers { + if m.fixed == 0 { + continue + } + sample, ok := samples[typ] + if !ok { + t.Fatalf("no sample for the fixed-width marker %v", typ) + } + check(typ.String(), m.fixed, sample) + } + for kind, n := range numericKinds { + check(kind.String(), n.fixed, reflect.Zero(kindTypes[kind]).Interface()) + } +} + +var kindTypes = map[reflect.Kind]reflect.Type{ + reflect.Int8: reflect.TypeOf(int8(0)), reflect.Int16: reflect.TypeOf(int16(0)), + reflect.Int32: reflect.TypeOf(int32(0)), reflect.Int: reflect.TypeOf(0), + reflect.Int64: reflect.TypeOf(int64(0)), reflect.Uint8: reflect.TypeOf(uint8(0)), + reflect.Uint16: reflect.TypeOf(uint16(0)), reflect.Uint32: reflect.TypeOf(uint32(0)), + reflect.Uint: reflect.TypeOf(uint(0)), reflect.Uint64: reflect.TypeOf(uint64(0)), + reflect.Uintptr: reflect.TypeOf(uintptr(0)), reflect.Float32: reflect.TypeOf(float32(0)), + reflect.Float64: reflect.TypeOf(float64(0)), +} diff --git a/testdata/value-conformance.json b/testdata/value-conformance.json index 56241cc6..6847181b 100644 --- a/testdata/value-conformance.json +++ b/testdata/value-conformance.json @@ -522,7 +522,7 @@ }, { "name": "typed-null-uuid-with-length", - "transport": "15f305000b10000000", + "transport": "15f305000b10000000000000000000000000000000000000", "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" }, { From 8735de788de088b7dd74af52fd49af60bf692e56 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 21 Oct 2026 11:37:14 +1100 Subject: [PATCH 4/4] feat(vcffi): let an Encryptable declare its fixed width, bound the nil-pointer type walk, and pin the typed-null edges the review found A pointer type that points at itself (type P *P) never reaches a non-pointer, so fixedTypeOf looped forever on a nil value of it and Marshal never returned. The walk is now bounded by the same depth budget that bounds a value cycle, and past it the type keeps the plain null. An Encryptable that always writes one value of one fixed-width type had no way to say so, so its nil pointer leaked absence by length where a plain *int64 did not. FixedWidth is the Go side of Rust's Encrypt::FIXED: a nil pointer to a type that implements it is a typed null of the type it declares, and an invalid declaration fails the encode rather than write the plain null in its place. The shared corpus gains three refusals every binding now checks: a uuid typed null with a nonzero parameter, and a typed null in the envelope under the undefined or decimal id, which Go accepted only because no vector reached those arms. The nil-pointer table covers every fixed-width type, a non-nil pointer to a nil one, a pointer to a pointer to an Encryptable with either receiver, and reads the per-type cache; the header test fails for a sample whose marker lost its fixed type; and the guest tests seal an optional field of all sixteen types and a nil fixed-width pointer inside a passthrough. --- bindings/go/vcencrypt/typed_null_test.go | 82 ++++++++-- bindings/go/vcffi/README.md | 21 +++ bindings/go/vcffi/encoder.go | 78 ++++++++-- bindings/go/vcffi/typed_null_test.go | 188 +++++++++++++++++++---- testdata/value-conformance.json | 15 ++ 5 files changed, 326 insertions(+), 58 deletions(-) diff --git a/bindings/go/vcencrypt/typed_null_test.go b/bindings/go/vcencrypt/typed_null_test.go index dea9829e..e2c69d8d 100644 --- a/bindings/go/vcencrypt/typed_null_test.go +++ b/bindings/go/vcencrypt/typed_null_test.go @@ -1,18 +1,29 @@ package vcencrypt_test import ( + "reflect" "testing" "time" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) -// optional is a record whose optional fields are pointers, the Go side of -// Rust's Option. +// optional is a record with an optional field of every fixed-width type, +// as pointers, the Go side of Rust's Option. type optional struct { + I8 *int8 + I16 *int16 + I32 *int32 + I64 *int64 + I128 *vcvalue.Int128 + U8 *uint8 + U16 *uint16 + U32 *uint32 + U64 *uint64 + U128 *vcvalue.Uint128 + F32 *float32 + F64 *float64 Born *vcvalue.Date - Age *int32 - Score *float64 Seen *time.Time Price *vcvalue.Decimal ID *vcvalue.UUID @@ -24,12 +35,24 @@ type optional struct { func TestANilFixedWidthFieldSealsAsLongAsAPresentOne(t *testing.T) { cipher := newCipher(t) aad := []byte("typed-null") - born := vcvalue.Date{Year: 1815, Month: 12, Day: 10} - age := int32(36) - score := 1.5 - seen := time.Unix(1_700_000_000, 5).UTC() - price := vcvalue.Decimal{Scale: 2, Coefficient: [12]byte{150}} - id := vcvalue.UUID{0xAB} + var ( + i8 int8 = -1 + i16 int16 = -300 + i32 int32 = 36 + i64 int64 = 1 << 40 + i128 = vcvalue.Int128{1} + u8 uint8 = 1 + u16 uint16 = 300 + u32 uint32 = 70_000 + u64 uint64 = 1 << 40 + u128 = vcvalue.Uint128{1} + f32 float32 = 1.5 + f64 = 2.5 + born = vcvalue.Date{Year: 1815, Month: 12, Day: 10} + seen = time.Unix(1_700_000_000, 5).UTC() + price = vcvalue.Decimal{Scale: 2, Coefficient: [12]byte{150}} + id = vcvalue.UUID{0xAB} + ) lengths := func(v optional) map[string]int { ct, err := cipher.Encrypt(t.Context(), v, aad) @@ -44,7 +67,7 @@ func TestANilFixedWidthFieldSealsAsLongAsAPresentOne(t *testing.T) { if err != nil { t.Fatalf("Decrypt: %v", err) } - if v.Age == nil { + if v.I32 == nil { for _, f := range got.(vcvalue.Object) { if f.Value != nil { t.Fatalf("%s decrypted as %#v, want nil", f.Key, f.Value) @@ -53,9 +76,12 @@ func TestANilFixedWidthFieldSealsAsLongAsAPresentOne(t *testing.T) { } return out } - present := lengths(optional{&born, &age, &score, &seen, &price, &id}) + present := lengths(optional{ + &i8, &i16, &i32, &i64, &i128, &u8, &u16, &u32, &u64, &u128, &f32, &f64, + &born, &seen, &price, &id, + }) absent := lengths(optional{}) - if len(present) != 6 { + if len(present) != 16 { t.Fatalf("sealed %d fields", len(present)) } for key, n := range present { @@ -65,6 +91,36 @@ func TestANilFixedWidthFieldSealsAsLongAsAPresentOne(t *testing.T) { } } +// A nil fixed-width pointer inside vcvalue.Plain is a typed null in the +// frame the guest reads, and the guest writes it back as the plain null: +// a passthrough is never sealed, so the padding would hide nothing there, +// and it reads as nil in the ciphertext and after a decrypt. +func TestANilFixedWidthPointerInAPassthroughIsThePlainNull(t *testing.T) { + cipher := newCipher(t) + aad := []byte("plain-typed-null") + ct, err := cipher.Encrypt(t.Context(), map[string]any{ + "score": vcvalue.Plain{V: (*int32)(nil)}, + "email": "ada@example.com", + }, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if score := ct.(map[string]any)["score"]; score != (vcvalue.Plain{V: nil}) { + t.Fatalf("passthrough in the ciphertext: %#v", score) + } + got, err := cipher.Decrypt(t.Context(), ct, aad) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + want := vcvalue.Object{ + {Key: "email", Value: "ada@example.com"}, + {Key: "score", Value: vcvalue.Plain{V: nil}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + // A bool and a string keep the plain null: a bool is already as short as // one, and text is variable width. func TestANilBoolOrStringSealsThePlainNull(t *testing.T) { diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index f46702ab..4eb37a11 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -193,6 +193,27 @@ date. Where the Go type cannot say what is missing (a nil in a `map[string]any`), write `vcvalue.TypedNull{Type: vcvalue.TypeDate}`, or call `Encoder.TypedNull` from an `Encryptable`. +An `Encryptable` whose `EncryptValue` always writes one value of one +fixed-width type can say so by implementing `FixedWidth`, the Go side of +Rust's `Encrypt::FIXED`. A nil pointer to it is then a typed null of that +type: + +```go +type Money struct{ cents int64 } + +func (m Money) EncryptValue(enc vcffi.Encoder) error { enc.Int64(m.cents); return nil } +func (Money) FixedType() vcvalue.FixedType { return vcvalue.TypeInt64 } + +type Order struct { + Refund *Money // nil seals as long as a Money +} +``` + +`FixedType` is called once per type, on a pointer to a zero value, so it must +answer from the type alone. A type that returns an invalid `FixedType` fails +the encode with `vcvalue.ErrInvalidFixedType` rather than write the plain +null in its place. + A typed null decodes as `nil`, like every null. Out of the seal the padding means nothing, and Rust's frame writer writes it back as the plain null, so Go does too. A decrypted value therefore no longer says what type an absent diff --git a/bindings/go/vcffi/encoder.go b/bindings/go/vcffi/encoder.go index db9dff10..28f17c4d 100644 --- a/bindings/go/vcffi/encoder.go +++ b/bindings/go/vcffi/encoder.go @@ -27,6 +27,22 @@ type Encryptable interface { EncryptValue(enc Encoder) error } +// FixedWidth is the Go side of Rust's `Encrypt::FIXED`: an Encryptable whose +// EncryptValue always writes one value of one fixed-width type (a money type +// that writes its cents with Int64, say). A nil pointer to such a type is +// written as a typed null of that type, so a sealed absence is as long as a +// sealed value. A nil pointer to an Encryptable that does not implement it +// is the plain null, since its encoding is its own. +// +// FixedType is called once per type, on a pointer to a zero value, so it +// must answer from the type alone. A type that returns an invalid FixedType +// fails the encode with vcvalue.ErrInvalidFixedType rather than write the +// plain null in its place. +type FixedWidth interface { + Encryptable + FixedType() vcvalue.FixedType +} + // encState is the buffer and first-error shared by an Encoder and every // sub-encoder it hands out. Errors accumulate: once one is recorded, further // channel calls are no-ops, so consumer code can chain writes without @@ -500,7 +516,8 @@ func encodeAny(enc Encoder, v any) { // the record's structure. // - Encryptable hands control to the user type — except a typed nil // pointer, which still satisfies the assertion but would dereference nil -// in typical implementations; it encodes as Null like every other nil. +// in typical implementations; it encodes as Null like every other nil, +// or as a typed null when the type declares its width (FixedWidth). func interceptMarkers(enc Encoder, v any) bool { if m, ok := markers[reflect.TypeOf(v)]; ok { m.encode(enc, v) @@ -508,7 +525,11 @@ func interceptMarkers(enc Encoder, v any) bool { } if e, ok := v.(Encryptable); ok { if ev := reflect.ValueOf(e); ev.Kind() == reflect.Pointer && ev.IsNil() { - enc.Null() + if t, ok := fixedTypeOf(ev.Type()); ok { + enc.TypedNull(t) + } else { + enc.Null() + } return true } if err := e.EncryptValue(enc); err != nil { @@ -680,6 +701,7 @@ type numeric struct { var ( encryptableType = reflect.TypeOf((*Encryptable)(nil)).Elem() + fixedWidthType = reflect.TypeOf((*FixedWidth)(nil)).Elem() float32Type = reflect.TypeOf(float32(0)) // markers is filled in init: Plain and Object encode by calling back @@ -762,32 +784,64 @@ func init() { } } +// fixedAnswer is what fixedTypeOf caches: whether the type has a fixed-width +// type at all, and which. A type declared through FixedWidth is kept even +// when it is invalid, so that the encode fails instead of writing the plain +// null in its place. +type fixedAnswer struct { + ft vcvalue.FixedType + has bool +} + // fixedTypeOf is the fixed-width type a pointer of type t points at, through // any number of pointers, when encodeReflect writes every value of it with // one fixed-width tag: the int, uint and float kinds (a named type over one // included) and the fixed-width marker types, exactly (a named type over a // marker is a struct to reflection, written field by field). A type that // implements Encryptable chooses its own encoding, which may not be fixed -// width, so it has none. +// width, so it has none unless it says so through FixedWidth. func fixedTypeOf(t reflect.Type) (vcvalue.FixedType, bool) { if cached, ok := fixedTypes.Load(t); ok { - ft := cached.(vcvalue.FixedType) - return ft, ft != 0 + a := cached.(fixedAnswer) + return a.ft, a.has } - var ft vcvalue.FixedType + var a fixedAnswer elem := t - for elem.Kind() == reflect.Pointer && !elem.Implements(encryptableType) { + // A pointer type that points at itself (type P *P) never reaches a + // non-pointer, so the walk is bounded by the same depth budget that + // bounds a value cycle. Past it the type has no fixed width. + for depth := 0; elem.Kind() == reflect.Pointer && !elem.Implements(encryptableType); depth++ { + if depth == maxDepth { + fixedTypes.Store(t, a) + return 0, false + } elem = elem.Elem() } - if !elem.Implements(encryptableType) { + switch { + case elem.Implements(fixedWidthType): + a = fixedAnswer{zeroValueOf(elem).Interface().(FixedWidth).FixedType(), true} + case elem.Implements(encryptableType): + // Its encoding is its own. + default: if m, ok := markers[elem]; ok { - ft = m.fixed + a.ft = m.fixed } else { - ft = numericKinds[elem.Kind()].fixed + a.ft = numericKinds[elem.Kind()].fixed } + a.has = a.ft != 0 + } + fixedTypes.Store(t, a) + return a.ft, a.has +} + +// zeroValueOf is a zero value of t that a method can be called on: for a +// pointer type, a pointer to a zero value rather than nil, so a method with +// a value receiver does not dereference nil. +func zeroValueOf(t reflect.Type) reflect.Value { + if t.Kind() == reflect.Pointer { + return reflect.New(t.Elem()) } - fixedTypes.Store(t, ft) - return ft, ft != 0 + return reflect.Zero(t) } // interfaceOf is rv.Interface(), or nil where reflection forbids it diff --git a/bindings/go/vcffi/typed_null_test.go b/bindings/go/vcffi/typed_null_test.go index 6d53d7a0..8f902e6b 100644 --- a/bindings/go/vcffi/typed_null_test.go +++ b/bindings/go/vcffi/typed_null_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "errors" + "fmt" "reflect" "testing" "time" @@ -41,31 +42,33 @@ func zeros(n int) string { return hex.EncodeToString(make([]byte, n)) } func TestTypedNullKnownAnswers(t *testing.T) { for _, row := range typedNullAnswers { - got, err := Marshal(vcvalue.TypedNull{Type: row.t}) - if err != nil { - t.Fatalf("%v: %v", row.t, err) - } - if hex.EncodeToString(got) != row.hex { - t.Fatalf("%v: got %x, want %s", row.t, got, row.hex) - } - present, err := Marshal(row.present) - if err != nil { - t.Fatalf("%v: %v", row.t, err) - } - // A frame gives bytes a length prefix; a typed null of a UUID has - // none, because its sealed leaf has none. - want := len(present) - if row.t == vcvalue.TypeUUID { - want -= 4 - } - if len(got) != want { - t.Fatalf("%v: typed null is %d bytes, a value %d", row.t, len(got), want) - } - // It reads back as nil, like every null, and writes back as one. - v, err := Unmarshal(got) - if err != nil || v != nil { - t.Fatalf("%v: decoded %#v, %v", row.t, v, err) - } + t.Run(fmt.Sprint(row.t), func(t *testing.T) { + got, err := Marshal(vcvalue.TypedNull{Type: row.t}) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(got) != row.hex { + t.Fatalf("got %x, want %s", got, row.hex) + } + present, err := Marshal(row.present) + if err != nil { + t.Fatal(err) + } + // A frame gives bytes a length prefix; a typed null of a UUID + // has none, because its sealed leaf has none. + want := len(present) + if row.t == vcvalue.TypeUUID { + want -= 4 + } + if len(got) != want { + t.Fatalf("typed null is %d bytes, a value %d", len(got), want) + } + // It reads back as nil, like every null, and writes back as one. + v, err := Unmarshal(got) + if err != nil || v != nil { + t.Fatalf("decoded %#v, %v", v, err) + } + }) } if len(typedNullAnswers) != int(vcvalue.TypeUUID) { t.Fatalf("pinned %d of %d types", len(typedNullAnswers), vcvalue.TypeUUID) @@ -87,28 +90,51 @@ func (s selfEncoded) EncryptValue(enc Encoder) error { return nil } +// An Encryptable with a pointer receiver: only *ptrEncoded implements it. +type ptrEncoded int32 + +func (p *ptrEncoded) EncryptValue(enc Encoder) error { + enc.String("custom") + return nil +} + +// A pointer type that points at itself never reaches a non-pointer. +type selfPointer *selfPointer + // A nil pointer to a fixed-width type is a typed null, the Go side of // Rust's Option; a nil with no fixed-width type to go by is the plain -// null. +// null. Each case is encoded twice, since the second encode reads the +// per-type cache. func TestANilPointerIsATypedNullWhenItsTypeIsFixedWidth(t *testing.T) { type age int32 + var missing *int32 cases := []struct { name string v any want string }{ {"*int8", (*int8)(nil), "150c"}, + {"*int16", (*int16)(nil), "150e00"}, + {"*int32", (*int32)(nil), "1504000000"}, + {"*int64", (*int64)(nil), "1505" + zeros(7)}, {"*int", (*int)(nil), "1505" + zeros(7)}, + {"*uint8", (*uint8)(nil), "150d"}, + {"*uint16", (*uint16)(nil), "150f00"}, + {"*uint32", (*uint32)(nil), "1506000000"}, + {"*uint64", (*uint64)(nil), "1507" + zeros(7)}, {"*uint", (*uint)(nil), "1507" + zeros(7)}, {"*float32", (*float32)(nil), "1508000000"}, + {"*float64", (*float64)(nil), "1509" + zeros(7)}, {"*time.Time", (*time.Time)(nil), "1513" + zeros(11)}, {"*vcvalue.Date", (*vcvalue.Date)(nil), "1512000000"}, {"*vcvalue.Timestamp", (*vcvalue.Timestamp)(nil), "1513" + zeros(11)}, {"*vcvalue.Decimal", (*vcvalue.Decimal)(nil), "1514" + zeros(15)}, {"*vcvalue.UUID", (*vcvalue.UUID)(nil), "15f305000b" + zeros(15)}, {"*vcvalue.Int128", (*vcvalue.Int128)(nil), "1510" + zeros(15)}, + {"*vcvalue.Uint128", (*vcvalue.Uint128)(nil), "1511" + zeros(15)}, {"named *age", (*age)(nil), "1504000000"}, {"**int32", (**int32)(nil), "1504000000"}, + {"&(nil *int32)", &missing, "1504000000"}, // No fixed width: the plain null. {"*bool", (*bool)(nil), "00"}, {"*string", (*string)(nil), "00"}, @@ -116,16 +142,105 @@ func TestANilPointerIsATypedNullWhenItsTypeIsFixedWidth(t *testing.T) { {"*[16]byte", (*[16]byte)(nil), "00"}, {"*struct", (*struct{ A int32 })(nil), "00"}, {"*Encryptable", (*selfEncoded)(nil), "00"}, + {"**Encryptable", (**selfEncoded)(nil), "00"}, + {"*pointer-receiver Encryptable", (*ptrEncoded)(nil), "00"}, + {"**pointer-receiver Encryptable", (**ptrEncoded)(nil), "00"}, + {"*any", (*any)(nil), "00"}, + {"self-pointing type", selfPointer(nil), "00"}, {"nil", nil, "00"}, } for _, c := range cases { - got, err := Marshal(c.v) - if err != nil { - t.Fatalf("%s: %v", c.name, err) - } - if hex.EncodeToString(got) != c.want { - t.Fatalf("%s: got %x, want %s", c.name, got, c.want) - } + t.Run(c.name, func(t *testing.T) { + for range 2 { + got, err := Marshal(c.v) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(got) != c.want { + t.Fatalf("got %x, want %s", got, c.want) + } + } + }) + } +} + +// An Encryptable that always writes one value of one fixed-width type: +// a FixedWidth, the Go side of Rust's Encrypt::FIXED. +type money struct{ cents int64 } + +func (m money) EncryptValue(enc Encoder) error { + enc.Int64(m.cents) + return nil +} + +func (money) FixedType() vcvalue.FixedType { return vcvalue.TypeInt64 } + +// The same, declared on the pointer. +type ptrMoney struct{ cents int64 } + +func (m *ptrMoney) EncryptValue(enc Encoder) error { + enc.Int64(m.cents) + return nil +} + +func (*ptrMoney) FixedType() vcvalue.FixedType { return vcvalue.TypeInt64 } + +// A FixedWidth that declares a type that is not one. +type badWidth struct{} + +func (badWidth) EncryptValue(enc Encoder) error { + enc.String("custom") + return nil +} + +func (badWidth) FixedType() vcvalue.FixedType { return 0 } + +func TestANilPointerToAFixedWidthEncryptableIsATypedNullOfItsType(t *testing.T) { + want := "1505" + zeros(7) + for _, c := range []struct { + name string + v any + }{ + {"*money", (*money)(nil)}, + {"**money", (**money)(nil)}, + {"*ptrMoney", (*ptrMoney)(nil)}, + {"**ptrMoney", (**ptrMoney)(nil)}, + } { + t.Run(c.name, func(t *testing.T) { + got, err := Marshal(c.v) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(got) != want { + t.Fatalf("got %x, want %s", got, want) + } + }) + } + // A present value is as long, and still written by EncryptValue. + present, err := Marshal(&money{cents: 150}) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(present) != "059600000000000000" { + t.Fatalf("present: got %x", present) + } + // Through a struct field, where a nil would otherwise show by length. + type order struct{ Refund *money } + absent, err := Marshal(order{}) + if err != nil { + t.Fatal(err) + } + if !bytes.HasSuffix(absent, []byte{tagTypedNull, tagInt64, 0, 0, 0, 0, 0, 0, 0}) { + t.Fatalf("absent: %x", absent) + } + // A declared type that is not a fixed-width type fails the encode, and + // never writes the plain null in its place. A present value is not + // asked. + if _, err := Marshal((*badWidth)(nil)); !errors.Is(err, vcvalue.ErrInvalidFixedType) { + t.Fatalf("invalid declared type: got %v", err) + } + if _, err := Marshal(badWidth{}); err != nil { + t.Fatalf("present: %v", err) } } @@ -262,6 +377,13 @@ func TestEveryFixedTypeIsWrittenWithItsTypedNullsHeader(t *testing.T) { t.Fatalf("%s: value %x does not start with its typed null's header %x", name, value, header) } } + // Every sample is of a fixed-width marker, so a marker that loses its + // fixed type is caught, not skipped. + for typ := range samples { + if markers[typ].fixed == 0 { + t.Fatalf("%v has a sample but no typed null", typ) + } + } for typ, m := range markers { if m.fixed == 0 { continue diff --git a/testdata/value-conformance.json b/testdata/value-conformance.json index 6847181b..97475e1f 100644 --- a/testdata/value-conformance.json +++ b/testdata/value-conformance.json @@ -515,6 +515,16 @@ "transport": "15f3000004000000", "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" }, + { + "name": "typed-null-envelope-undefined", + "transport": "15f3010004000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, + { + "name": "typed-null-envelope-decimal", + "transport": "15f3040004000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, { "name": "typed-null-uuid-over-int", "transport": "15f3050004000000", @@ -525,6 +535,11 @@ "transport": "15f305000b10000000000000000000000000000000000000", "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" }, + { + "name": "typed-null-uuid-with-parameter", + "transport": "15f305010b000000000000000000000000000000", + "rule": "Typed null (vitaminc#493): every absence has one encoding, so a typed null is TYPED_NULL, the header a value of a fixed-width type starts with, then zero padding to that value's length, and nothing else" + }, { "name": "typed-null-timestamp-over-float", "transport": "15f303010900000000000000",