From f84a59620e4806bfdf0d34d2385bc584ac3352b2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 9 Sep 2026 15:27:00 +0100 Subject: [PATCH 01/11] FEAT: Read the terms a 51Did was created under A 51Did created for marketing may only be used by a receiver that has accepted the terms it was created under, and until now the identifier did not say which terms those are. The answer had to travel beside it, which works in OpenRTB, where the Terms Document Locator has somewhere to go, and nowhere else, because an identifier passed as a query string parameter arrives on its own and any hop can drop a sidecar without the identifier looking any different. The specification adds one byte after the match key, before the creator context, holding an index into a table it publishes. Index 0 says the terms are not stated in the identifier and index 1 is the Model Terms for Marketing, version 2, at https://m4ow.uk/mtm/2.txt. It is an index rather than a version number so that a later document can live at any address, and an index rather than the address itself because a receiver has to know the exact document in force when the identifier was made. This adds three members to FodId, matching the package surface page. terms() answers a named Terms value, terms_index() answers the raw byte, and terms_url() answers the address as an Option, being None for index 0 and for an index this crate does not know. An index this crate does not know is Terms::Unknown and never Terms::NotStated. Zero says no terms are stated, whilst an unknown index says terms are stated that this crate cannot name, and a receiver confusing the two would read an identifier created under terms as one created under none. The raw index stays available so a caller meeting a newer index can say which one it could not read. The address is answered and never fetched. Existing identifiers are unaffected. A payload issued before the terms existed ends at the match key, and a missing byte reads as index 0, which is exactly what such an identifier means, so absence and zero need no telling apart and no presence flag exists. Every test that passed before passes unchanged. The byte follows the match key, so where it sits moves with the match key length the identifier type requires, being 37 for a probabilistic or hashed email identifier and 21 for a random one. A reserved type has no defined match key length and takes every remaining byte as its value, so a reserved identifier states no terms until that length is assigned. Depends on 51Degrees/specifications#27, which must merge first. --- fodid/README.md | 31 ++++- fodid/src/fodid.rs | 137 ++++++++++++++++++++- fodid/src/lib.rs | 40 +++++- fodid/tests/fodid_tests.rs | 246 ++++++++++++++++++++++++++++++++++++- 4 files changed, 442 insertions(+), 12 deletions(-) diff --git a/fodid/README.md b/fodid/README.md index ea6a9f7..9653dee 100644 --- a/fodid/README.md +++ b/fodid/README.md @@ -49,6 +49,24 @@ and meaning of the match key: - `IdType::Random` carries a 16-byte server-generated GUID. - `IdType::Reserved` is not yet assigned and is parsed best effort. +## The terms the identifier was created under + +The byte after the match key says which terms document the 51Did was created +under, so that the terms travel with the identifier rather than beside it. It +is an index into a table published in the +[specification](https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md) +and is not a version number, read through `FodId::terms` as a named `Terms` +value, through `FodId::terms_index` as the index itself, and through +`FodId::terms_url` as the address of the document. + +An identifier issued before the terms existed ends at the match key, and a +missing byte is index 0, being `Terms::NotStated`, so absence and zero say the +same thing. An index added to the specification after this release is +`Terms::Unknown` and never `Terms::NotStated`, because terms are stated and +this crate cannot say which, and the index itself stays available so a caller +can say which one it could not read. This crate answers with the address and +never fetches it. + ## Payload layout | Offset | Length | Field | @@ -57,11 +75,15 @@ and meaning of the match key: | 1 | 4 | LicenseId (`u32` little endian) | | 5 | 32 | Value: SHA-256 (Probabilistic, HashedEmail) | | 5 | 16 | Value: GUID (Random) | +| 37 | 1 | Terms, an index (Probabilistic, HashedEmail) | +| 21 | 1 | Terms, an index (Random) | These lengths are lower bounds. The payload must hold the 5 byte header before the type can be read, and then the value the type requires, being 16 GUID bytes for a random identifier and 32 hash bytes for a probabilistic or -hashed email one. A payload may carry more bytes after the value, which this +hashed email one. The terms byte follows the value, so where it sits depends +on the value length the type requires, and a payload that ends at the value +carries none. A payload may carry more bytes after the terms, which this crate accepts and leaves in place. There is no upper bound on a 51Did in this crate, so a reader built today keeps reading identifiers issued in a newer, longer shape. @@ -89,6 +111,12 @@ fn read(base64_from_cloud_service: &str, public_pem: &str) -> Result<(), fodid:: let license_id = fod_id.license_id(); // u32 let match_key = fod_id.match_key(); // the match key bytes (SHA-256 or GUID) + // The terms the identifier was created under, and the address of that + // document where this crate knows the index. + let terms = fod_id.terms(); // Terms + let terms_index = fod_id.terms_index(); // u8 + let terms_url = fod_id.terms_url(); // Option<&'static str> + // Inherited OWID level fields and operations, available through Deref. let domain = fod_id.domain(); let round_trip = fod_id.as_base64()?; @@ -98,6 +126,7 @@ fn read(base64_from_cloud_service: &str, public_pem: &str) -> Result<(), fodid:: == SignatureStatus::Valid; let _ = (flags, license_id, match_key, domain, round_trip, genuine); + let _ = (terms, terms_index, terms_url); Ok(()) } ``` diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index fbcbb80..c938d4c 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -106,6 +106,82 @@ impl IdType { } } +/// The terms document a 51Did was created under, carried in the byte that +/// follows the match key, so that the terms travel with the identifier +/// rather than beside it. +/// +/// The byte is an index into the table below and is not a version number. +/// An index is used so that a later document can live at any address, rather +/// than only at an address the specification could compose from a number. +/// +/// | Index | Document | Address | +/// |------:|--------------------------------------|-----------------------------| +/// | 0 | Not stated in the identifier | None | +/// | 1 | Model Terms for Marketing, version 2 | `https://m4ow.uk/mtm/2.txt` | +/// +/// A new terms document is a new index in that table, and every package has +/// to be released to know it, which is the cost of a receiver being able to +/// trust what it reads. An index is never reused or repointed once +/// published, because repointing one would rewrite what an identifier +/// already issued says it agreed to. +/// +/// An identifier issued before the terms existed has a payload that ends at +/// the match key, and a missing byte is read as index 0, so absence and zero +/// say the same thing and neither has to be told apart from the other. +/// +/// The table is published at +/// , +/// which is the authority rather than this summary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Terms { + /// Index 0. The terms are not stated in the identifier, which is also + /// how an identifier issued before the byte existed reads. + /// + /// This does not mean the identifier is unrestricted. It means the + /// identifier does not carry the answer, so the answer has to come from + /// what accompanies it, being the Terms Document Locator in an OpenRTB + /// request or whatever the surrounding protocol provides. The usage + /// says where an identifier may go and the terms say which document it + /// was created under, and a receiver needs both. + NotStated, + /// Index 1. The Model Terms for Marketing, version 2, whose address is + /// answered by [`terms_url`](FodId::terms_url). + ModelTermsForMarketingVersion2, + /// An index added to the specification after this release, which this + /// crate cannot name. + /// + /// It is not [`Terms::NotStated`], because terms are stated and this + /// crate cannot say which, and a caller treating the two alike would + /// read an identifier created under terms as one created under none. + /// Read the index itself with [`terms_index`](FodId::terms_index), then + /// either update this crate or refuse the identifier. + Unknown, +} + +impl Terms { + /// Decode the terms from the index byte that follows the match key. An + /// index this crate does not know decodes as [`Terms::Unknown`] and + /// never as [`Terms::NotStated`]. + fn from_index(index: u8) -> Terms { + match index { + 0 => Terms::NotStated, + 1 => Terms::ModelTermsForMarketingVersion2, + _ => Terms::Unknown, + } + } + + /// The address of the terms document, or `None` where there is none to + /// give, being index 0 and an index this crate does not know. The + /// address is answered and never fetched, and the caller decides what + /// to do with it. + fn url(self) -> Option<&'static str> { + match self { + Terms::NotStated | Terms::Unknown => None, + Terms::ModelTermsForMarketingVersion2 => Some("https://m4ow.uk/mtm/2.txt"), + } + } +} + /// A parsed 51Did: an [`Owid`] envelope whose payload encodes the fields of a /// 51Degrees identifier. /// @@ -120,13 +196,22 @@ impl IdType { /// | 1 | 4 | LicenseId (`u32` little endian) | /// | 5 | 32 | Match key: SHA-256 (Probabilistic, HashedEmail) | /// | 5 | 16 | Match key: GUID (Random) | +/// | 37 | 1 | Terms, an index (Probabilistic, HashedEmail) | +/// | 21 | 1 | Terms, an index (Random) | /// /// The match key is read through [`match_key`](FodId::match_key). For a /// [`IdType::Random`] identifier it is a GUID, otherwise a SHA-256. /// +/// The terms byte follows the match key, so where it sits depends on the +/// match key length the type requires. It is read through +/// [`terms`](FodId::terms), [`terms_index`](FodId::terms_index) and +/// [`terms_url`](FodId::terms_url), and a payload that ends at the match key +/// carries no terms byte and reads as [`Terms::NotStated`]. +/// /// The lengths in the table are minimums. A payload may carry more bytes -/// after the match key, which this reader accepts and leaves in place, reachable -/// through [`payload`](Owid::payload). There is no upper bound in this crate. +/// after the fields above, which this reader accepts and leaves in place, +/// reachable through [`payload`](Owid::payload). There is no upper bound in +/// this crate. /// /// `FodId` [`Deref`]s to [`Owid`], so the OWID level fields and operations /// (`domain()`, `date()`, `payload()`, `signature()`, `as_base64`, @@ -143,6 +228,7 @@ pub struct FodId { flags: u8, license_id: u32, match_key: Vec, + terms_index: u8, } impl FodId { @@ -190,8 +276,10 @@ impl FodId { /// [`IdType::Random`], [`MATCH_KEY_LENGTH`] for /// [`IdType::Probabilistic`] and [`IdType::HashedEmail`]). A /// [`IdType::Reserved`] payload has no defined value length and is read - /// best effort. Bytes after the value are accepted and left in the - /// payload, because a longer payload is a newer shape rather than a fault. + /// best effort, taking every byte after the header as its value, so a + /// reserved identifier states no terms until that length is assigned. + /// Bytes after the value are accepted and left in the payload, because a + /// longer payload is a newer shape rather than a fault. /// /// # Errors /// @@ -228,11 +316,20 @@ impl FodId { }); } let match_key = payload[MATCH_KEY_OFFSET..MATCH_KEY_OFFSET + value_length].to_vec(); + // The terms index is the byte after the match key. A payload issued + // before the terms existed ends at the match key, and a missing byte + // is index 0, which says the terms are not stated in the identifier, + // so absence and zero are one answer. + let terms_index = payload + .get(MATCH_KEY_OFFSET + value_length) + .copied() + .unwrap_or(0); Ok(FodId { owid, flags, license_id, match_key, + terms_index, }) } @@ -272,6 +369,38 @@ impl FodId { self.match_key() } + /// The terms document the identifier was created under, read from the + /// byte after the match key. A payload that ends at the match key reads + /// as [`Terms::NotStated`], so an identifier issued before the byte + /// existed answers as one that states no terms. See [`Terms`] for what + /// each value means. + pub fn terms(&self) -> Terms { + Terms::from_index(self.terms_index) + } + + /// The raw index behind [`terms`](FodId::terms), being the byte after + /// the match key, or 0 where the payload ends at the match key. + /// + /// This is the one raw value the surface carries, and it is here + /// because a caller meeting an index added after this release would + /// otherwise hold [`Terms::Unknown`] and no way to find out what it + /// stands for, so it could neither look the document up by hand nor + /// report which index it could not read. + pub fn terms_index(&self) -> u8 { + self.terms_index + } + + /// The address of the terms document the identifier was created under, + /// or `None` where there is none to give, being [`Terms::NotStated`] + /// and an index this crate does not know. + /// + /// The address is answered and never fetched, and it is never an empty + /// string and never built from the index, so `Some` means this crate + /// knows the document and the caller can rely on the address it holds. + pub fn terms_url(&self) -> Option<&'static str> { + self.terms().url() + } + /// A reference to the underlying OWID envelope. pub fn owid(&self) -> &Owid { &self.owid diff --git a/fodid/src/lib.rs b/fodid/src/lib.rs index 3e29bdb..9407578 100644 --- a/fodid/src/lib.rs +++ b/fodid/src/lib.rs @@ -57,6 +57,23 @@ //! - [`IdType::Random`] carries a 16-byte server-generated GUID. //! - [`IdType::Reserved`] is not yet assigned and is parsed best effort. //! +//! ## The terms the identifier was created under +//! +//! The byte after the match key says which terms document the 51Did was +//! created under, so that the terms travel with the identifier rather than +//! beside it. It is an index into a table published in the specification and +//! is not a version number, read through [`FodId::terms`] as a named +//! [`Terms`] value, through [`FodId::terms_index`] as the index itself, and +//! through [`FodId::terms_url`] as the address of the document. +//! +//! An identifier issued before the terms existed ends at the match key, and +//! a missing byte is index 0, being [`Terms::NotStated`], so absence and +//! zero say the same thing. An index added to the specification after this +//! release is [`Terms::Unknown`] and never [`Terms::NotStated`], because +//! terms are stated and this crate cannot say which, and the index itself +//! stays available so a caller can say which one it could not read. This +//! crate answers with the address and never fetches it. +//! //! ## Payload layout //! //! | Offset | Length | Field | @@ -65,14 +82,18 @@ //! | 1 | 4 | LicenseId (`u32` little endian) | //! | 5 | 32 | Value: SHA-256 (Probabilistic, HashedEmail) | //! | 5 | 16 | Value: GUID (Random) | +//! | 37 | 1 | Terms, an index (Probabilistic, HashedEmail) | +//! | 21 | 1 | Terms, an index (Random) | //! //! These lengths are lower bounds. The payload must hold the 5 byte header //! before the type can be read, and then the value the type requires, being //! 16 GUID bytes for a random identifier and 32 hash bytes for a -//! probabilistic or hashed email one. A payload may carry more bytes after -//! the value, and this crate accepts them and leaves them in place. There -//! is no upper bound on a 51Did in this crate, so a reader built today keeps -//! reading identifiers issued in a newer, longer shape. +//! probabilistic or hashed email one. The terms byte follows the value, so +//! where it sits depends on the value length the type requires, and a +//! payload that ends at the value carries none. A payload may carry more +//! bytes after the terms, and this crate accepts them and leaves them in +//! place. There is no upper bound on a 51Did in this crate, so a reader +//! built today keeps reading identifiers issued in a newer, longer shape. //! //! [`FodId`] [`Deref`](std::ops::Deref)s to the underlying [`Owid`], so //! a `FodId` can be used directly for all OWID level concerns (domain, date, @@ -141,6 +162,12 @@ //! let license_id: u32 = fod_id.license_id(); //! let match_key: &[u8] = fod_id.match_key(); // the match key to compare (32 or 16 bytes) //! +//! // The terms the identifier was created under, and the address of that +//! // document where this crate knows the index. +//! let terms = fod_id.terms(); +//! let terms_index: u8 = fod_id.terms_index(); +//! let terms_url: Option<&str> = fod_id.terms_url(); +//! //! // Inherited OWID level fields and operations, available through Deref. //! let domain = fod_id.domain(); //! let round_trip = fod_id.as_base64()?; @@ -149,6 +176,7 @@ //! let status = fod_id.verify_status_with_public_key(public_pem, &[]); //! let genuine = status == SignatureStatus::Valid; //! # let _ = (flags, id_type, license_id, match_key, domain, round_trip, genuine); +//! # let _ = (terms, terms_index, terms_url); //! # Ok(()) //! # } //! ``` @@ -261,8 +289,8 @@ mod fodid; pub use error::{Error, Result}; pub use fodid::{ - FodId, IdType, FLAGS_OFFSET, GUID_LENGTH, HEADER_LENGTH, LICENSE_ID_LENGTH, LICENSE_ID_OFFSET, - MATCH_KEY_LENGTH, MATCH_KEY_OFFSET, PAYLOAD_LENGTH, RANDOM_PAYLOAD_LENGTH, + FodId, IdType, Terms, FLAGS_OFFSET, GUID_LENGTH, HEADER_LENGTH, LICENSE_ID_LENGTH, + LICENSE_ID_OFFSET, MATCH_KEY_LENGTH, MATCH_KEY_OFFSET, PAYLOAD_LENGTH, RANDOM_PAYLOAD_LENGTH, }; // The obsolete names for the match key constants, re-exported so callers diff --git a/fodid/tests/fodid_tests.rs b/fodid/tests/fodid_tests.rs index eff1032..fb60ca8 100644 --- a/fodid/tests/fodid_tests.rs +++ b/fodid/tests/fodid_tests.rs @@ -28,7 +28,7 @@ //! no value came back, and the status names the reason. Reading never //! touches a key, so none of the failure cases here constructs one. -use fodid::{Creator, Crypto, Error, FodId, IdType, Owid, ParseStatus, SignatureStatus}; +use fodid::{Creator, Crypto, Error, FodId, IdType, Owid, ParseStatus, SignatureStatus, Terms}; const TEST_DOMAIN: &str = "51degrees.com"; @@ -77,6 +77,26 @@ fn typed_payload(flags: u8, value_len: usize) -> Vec { payload } +/// The terms index the specification gives to the Model Terms for Marketing, +/// version 2, and the address that index stands for. Both are written out +/// here rather than taken from the crate, so the test checks the crate +/// against the specification rather than against itself. +const MODEL_TERMS_INDEX: u8 = 1; +const MODEL_TERMS_URL: &str = "https://m4ow.uk/mtm/2.txt"; + +/// An index no release of this crate knows, standing in for one added to the +/// specification after this one. +const UNKNOWN_TERMS_INDEX: u8 = 200; + +/// The canonical payload with a terms index byte after the match key, which +/// is the shape issued once the terms exist. The byte is appended by hand, +/// because the offset it lands at is what these tests are checking. +fn payload_with_terms(index: u8) -> Vec { + let mut payload = canonical_payload(); + payload.push(index); + payload +} + /// Generates a key pair and exposes the PEM forms, used to set up each test. struct Fixture { public_pem: String, @@ -838,3 +858,227 @@ fn reserved_type_exposes_remaining_payload_best_effort() { let fod_id = assert_parsed(&result); assert!(fod_id.match_key().is_empty()); } + +#[test] +fn a_payload_ending_at_the_match_key_states_no_terms() { + // An identifier issued before the terms existed ends at the match key. + // A missing byte is index 0, which says the terms are not stated in the + // identifier, so such an identifier reads exactly as it did before the + // byte existed and answers index 0. + let fixture = Fixture::new(); + let result = FodId::from_base64(&fixture.signed_owid_base64(canonical_payload())); + let fod_id = assert_parsed(&result); + + assert_eq!(fod_id.terms(), Terms::NotStated); + assert_eq!(fod_id.terms_index(), 0); + assert_eq!(fod_id.terms_url(), None); + + // Every field that was readable before is unchanged. + assert_eq!(fod_id.flags(), CANONICAL_FLAGS); + assert_eq!(fod_id.license_id(), CANONICAL_LICENSE_ID); + assert_eq!(fod_id.match_key(), &canonical_hash()); + // The canonical flags byte selects the hashed email type in bits 6-7. + assert_eq!(fod_id.id_type(), IdType::HashedEmail); +} + +#[test] +fn an_explicit_zero_reads_the_same_as_a_missing_terms_byte() { + // Absence and zero say the same thing, so nothing has to tell them + // apart and no presence flag is needed. + let fixture = Fixture::new(); + let absent = FodId::from_base64(&fixture.signed_owid_base64(canonical_payload())).unwrap(); + let zero = FodId::from_base64(&fixture.signed_owid_base64(payload_with_terms(0))).unwrap(); + + assert_eq!(absent.terms(), zero.terms()); + assert_eq!(absent.terms_index(), zero.terms_index()); + assert_eq!(absent.terms_url(), zero.terms_url()); + assert_eq!(zero.terms(), Terms::NotStated); + + // The payloads still differ by the byte, which stays in place. + assert_eq!(absent.payload().len() + 1, zero.payload().len()); +} + +#[test] +fn index_one_is_the_model_terms_for_marketing_and_carries_its_address() { + let fixture = Fixture::new(); + let payload = payload_with_terms(MODEL_TERMS_INDEX); + let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); + let fod_id = assert_parsed(&result); + + assert_eq!(fod_id.terms(), Terms::ModelTermsForMarketingVersion2); + assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX); + assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL)); + assert_eq!(fod_id.match_key(), &canonical_hash()); +} + +#[test] +fn an_index_this_crate_does_not_know_is_reported_and_has_no_address() { + // A caller meeting an index added after this release has to be able to + // say which index it could not read, so the raw index is answered + // whether or not the named value is known. + let fixture = Fixture::new(); + let payload = payload_with_terms(UNKNOWN_TERMS_INDEX); + let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); + let fod_id = assert_parsed(&result); + + assert_eq!(fod_id.terms_index(), UNKNOWN_TERMS_INDEX); + assert_eq!(fod_id.terms(), Terms::Unknown); + assert_eq!(fod_id.terms_url(), None); + assert_ne!(fod_id.terms(), Terms::NotStated); +} + +#[test] +fn no_terms_stated_and_an_unknown_index_are_told_apart() { + // Zero says no terms are stated, whilst an unknown index says terms are + // stated that this crate cannot name. A caller that confused the two + // would read an identifier created under terms as one created under + // none, so the two answer differently in all three members. + let fixture = Fixture::new(); + let stated_none = + FodId::from_base64(&fixture.signed_owid_base64(payload_with_terms(0))).unwrap(); + let unknown = + FodId::from_base64(&fixture.signed_owid_base64(payload_with_terms(UNKNOWN_TERMS_INDEX))) + .unwrap(); + + assert_ne!(stated_none.terms(), unknown.terms()); + assert_ne!(stated_none.terms_index(), unknown.terms_index()); + assert_eq!(stated_none.terms(), Terms::NotStated); + assert_eq!(unknown.terms(), Terms::Unknown); + + // Neither carries an address, and that shared answer is the reason the + // named value and the index have to be read to tell them apart. + assert_eq!(stated_none.terms_url(), None); + assert_eq!(unknown.terms_url(), None); +} + +#[test] +fn every_terms_index_decodes_as_the_specification_publishes_it() { + // The published table holds two indexes today. Everything else is an + // index added later, whatever its value, including the one immediately + // after the last published index and the largest a byte can hold. + let fixture = Fixture::new(); + let cases = [ + (0u8, Terms::NotStated, None), + ( + 1, + Terms::ModelTermsForMarketingVersion2, + Some(MODEL_TERMS_URL), + ), + (2, Terms::Unknown, None), + (UNKNOWN_TERMS_INDEX, Terms::Unknown, None), + (255, Terms::Unknown, None), + ]; + for (index, expected_terms, expected_url) in cases { + let payload = payload_with_terms(index); + let fod_id = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap(); + + assert_eq!(fod_id.terms(), expected_terms, "index {index}"); + assert_eq!(fod_id.terms_index(), index, "index {index}"); + assert_eq!(fod_id.terms_url(), expected_url, "index {index}"); + } +} + +#[test] +fn the_terms_byte_is_read_after_the_match_key_for_both_match_key_lengths() { + // The terms byte follows the match key, so where it sits moves with the + // length the identifier type requires. Reading it at a fixed offset + // would take a hash byte for a random identifier. + let fixture = Fixture::new(); + let cases = [ + ( + PROBABILISTIC_FLAGS, + IdType::Probabilistic, + fodid::MATCH_KEY_LENGTH, + ), + (RANDOM_FLAGS, IdType::Random, fodid::GUID_LENGTH), + ( + HASHED_EMAIL_FLAGS, + IdType::HashedEmail, + fodid::MATCH_KEY_LENGTH, + ), + ]; + for (flags, id_type, value_len) in cases { + let mut payload = typed_payload(flags, value_len); + payload.push(MODEL_TERMS_INDEX); + let fod_id = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap(); + + assert_eq!(fod_id.id_type(), id_type); + assert_eq!(fod_id.match_key().len(), value_len, "{id_type:?}"); + assert_eq!(fod_id.match_key()[0], 0x50, "{id_type:?}"); + assert_eq!( + fod_id.match_key()[value_len - 1], + 0x50 + (value_len as u8 - 1), + "{id_type:?}" + ); + assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX, "{id_type:?}"); + assert_eq!( + fod_id.terms(), + Terms::ModelTermsForMarketingVersion2, + "{id_type:?}" + ); + assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL), "{id_type:?}"); + } +} + +#[test] +fn a_creator_context_after_the_terms_leaves_both_the_match_key_and_terms_read() { + // The terms sit between the match key and the creator context, so a + // payload carrying a context proves the byte is taken from the right + // offset rather than from the end of the payload. The context bytes are + // arbitrary here, because their meaning belongs to the issuer and this + // crate leaves them in the payload untouched. + let fixture = Fixture::new(); + for context_len in [1usize, 40, 300] { + let mut payload = payload_with_terms(MODEL_TERMS_INDEX); + payload.extend((0..context_len).map(|i| 0xC0 | (i as u8 & 0x0F))); + let expected_payload = payload.clone(); + + let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); + let fod_id = assert_parsed(&result); + + assert_eq!(fod_id.match_key(), &canonical_hash(), "{context_len}"); + assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX, "{context_len}"); + assert_eq!( + fod_id.terms(), + Terms::ModelTermsForMarketingVersion2, + "{context_len}" + ); + assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL), "{context_len}"); + assert_eq!(fod_id.license_id(), CANONICAL_LICENSE_ID, "{context_len}"); + assert_eq!( + fod_id.payload(), + expected_payload.as_slice(), + "{context_len}" + ); + } +} + +#[test] +fn a_reserved_identifier_states_no_terms() { + // A reserved type has no defined match key length, so every byte after + // the header is read as its value best effort and there is no byte the + // terms could be taken from. Index 0 is the answer, being the same one + // an identifier that ends at its match key gives. + let fixture = Fixture::new(); + let mut payload = typed_payload(RESERVED_FLAGS, 8); + payload.push(MODEL_TERMS_INDEX); + let fod_id = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap(); + + assert_eq!(fod_id.id_type(), IdType::Reserved); + assert_eq!(fod_id.terms(), Terms::NotStated); + assert_eq!(fod_id.terms_index(), 0); + assert_eq!(fod_id.terms_url(), None); +} + +#[test] +fn the_terms_survive_a_base64_round_trip() { + let fixture = Fixture::new(); + let payload = payload_with_terms(MODEL_TERMS_INDEX); + let fod_id = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap(); + + let round_tripped = FodId::from_base64(&fod_id.as_base64().unwrap()).unwrap(); + + assert_eq!(round_tripped.terms(), fod_id.terms()); + assert_eq!(round_tripped.terms_index(), fod_id.terms_index()); + assert_eq!(round_tripped.terms_url(), fod_id.terms_url()); +} From 718d9fdb4c41029fb59fd0a1f1a6ad98e2fe9260 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 9 Sep 2026 15:36:13 +0100 Subject: [PATCH 02/11] FEAT: Use the agreed Terms member name across the packages The specification does not name the values, and the six packages were about to invent six different names for the same three concepts, so the names are now fixed across all of them. The concepts are NotStated for index 0, ModelTermsForMarketing2 for index 1 and Unknown for an index the package does not know, each cased the way its own language cases an enumeration member, which is NotStated, ModelTermsForMarketing2 and Unknown in Rust. Only the index 1 member changes here, from ModelTermsForMarketingVersion2. The read also says plainly why a reserved identifier states no terms, since a reserved type has no assigned match key length and takes every byte after the header as its value, so there is no byte left for the terms to be taken from. Index 0 is the right answer there and not a fault, and the next reader should not have to work that out. --- fodid/src/fodid.rs | 12 +++++++++--- fodid/tests/fodid_tests.rs | 12 ++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index c938d4c..c60a035 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -146,7 +146,7 @@ pub enum Terms { NotStated, /// Index 1. The Model Terms for Marketing, version 2, whose address is /// answered by [`terms_url`](FodId::terms_url). - ModelTermsForMarketingVersion2, + ModelTermsForMarketing2, /// An index added to the specification after this release, which this /// crate cannot name. /// @@ -165,7 +165,7 @@ impl Terms { fn from_index(index: u8) -> Terms { match index { 0 => Terms::NotStated, - 1 => Terms::ModelTermsForMarketingVersion2, + 1 => Terms::ModelTermsForMarketing2, _ => Terms::Unknown, } } @@ -177,7 +177,7 @@ impl Terms { fn url(self) -> Option<&'static str> { match self { Terms::NotStated | Terms::Unknown => None, - Terms::ModelTermsForMarketingVersion2 => Some("https://m4ow.uk/mtm/2.txt"), + Terms::ModelTermsForMarketing2 => Some("https://m4ow.uk/mtm/2.txt"), } } } @@ -320,6 +320,12 @@ impl FodId { // before the terms existed ends at the match key, and a missing byte // is index 0, which says the terms are not stated in the identifier, // so absence and zero are one answer. + // + // A reserved type has no assigned match key length, so its value is + // every byte after the header and there is no byte left for the + // terms to be taken from. Such an identifier reads as index 0, which + // is correct rather than a fault, and it stops being a special case + // as soon as a reserved type is assigned a length. let terms_index = payload .get(MATCH_KEY_OFFSET + value_length) .copied() diff --git a/fodid/tests/fodid_tests.rs b/fodid/tests/fodid_tests.rs index fb60ca8..e2f906b 100644 --- a/fodid/tests/fodid_tests.rs +++ b/fodid/tests/fodid_tests.rs @@ -905,7 +905,7 @@ fn index_one_is_the_model_terms_for_marketing_and_carries_its_address() { let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); let fod_id = assert_parsed(&result); - assert_eq!(fod_id.terms(), Terms::ModelTermsForMarketingVersion2); + assert_eq!(fod_id.terms(), Terms::ModelTermsForMarketing2); assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX); assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL)); assert_eq!(fod_id.match_key(), &canonical_hash()); @@ -959,11 +959,7 @@ fn every_terms_index_decodes_as_the_specification_publishes_it() { let fixture = Fixture::new(); let cases = [ (0u8, Terms::NotStated, None), - ( - 1, - Terms::ModelTermsForMarketingVersion2, - Some(MODEL_TERMS_URL), - ), + (1, Terms::ModelTermsForMarketing2, Some(MODEL_TERMS_URL)), (2, Terms::Unknown, None), (UNKNOWN_TERMS_INDEX, Terms::Unknown, None), (255, Terms::Unknown, None), @@ -1013,7 +1009,7 @@ fn the_terms_byte_is_read_after_the_match_key_for_both_match_key_lengths() { assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX, "{id_type:?}"); assert_eq!( fod_id.terms(), - Terms::ModelTermsForMarketingVersion2, + Terms::ModelTermsForMarketing2, "{id_type:?}" ); assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL), "{id_type:?}"); @@ -1040,7 +1036,7 @@ fn a_creator_context_after_the_terms_leaves_both_the_match_key_and_terms_read() assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX, "{context_len}"); assert_eq!( fod_id.terms(), - Terms::ModelTermsForMarketingVersion2, + Terms::ModelTermsForMarketing2, "{context_len}" ); assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL), "{context_len}"); From 6ca413be5f71175928c74ec6d07a1c91d02a267c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 9 Sep 2026 18:35:44 +0100 Subject: [PATCH 03/11] DOC: Describe the terms byte as it is, rather than as a change No reader has ever seen a 51Did without the terms byte, so describing the field as a change from a previous state gives a reader history they cannot use. Every rule the history was wrapped around is kept and reworded to describe the payload instead. A payload that ends at the match key carries no terms byte and a missing byte is index 0, so absence and zero say the same thing. The byte sits after the match key, so where it sits follows the match key length the type selects, and a reserved identifier has no byte left for it and reads as index 0. --- fodid/README.md | 6 +++--- fodid/src/fodid.rs | 21 ++++++++++----------- fodid/src/lib.rs | 4 ++-- fodid/tests/fodid_tests.rs | 10 +++++----- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/fodid/README.md b/fodid/README.md index 9653dee..e306ef0 100644 --- a/fodid/README.md +++ b/fodid/README.md @@ -59,9 +59,9 @@ and is not a version number, read through `FodId::terms` as a named `Terms` value, through `FodId::terms_index` as the index itself, and through `FodId::terms_url` as the address of the document. -An identifier issued before the terms existed ends at the match key, and a -missing byte is index 0, being `Terms::NotStated`, so absence and zero say the -same thing. An index added to the specification after this release is +An identifier whose payload ends at the match key carries no terms byte, and +a missing byte is index 0, being `Terms::NotStated`, so absence and zero say +the same thing. An index added to the specification after this release is `Terms::Unknown` and never `Terms::NotStated`, because terms are stated and this crate cannot say which, and the index itself stays available so a caller can say which one it could not read. This crate answers with the address and diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index c60a035..899248b 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -125,9 +125,9 @@ impl IdType { /// published, because repointing one would rewrite what an identifier /// already issued says it agreed to. /// -/// An identifier issued before the terms existed has a payload that ends at -/// the match key, and a missing byte is read as index 0, so absence and zero -/// say the same thing and neither has to be told apart from the other. +/// An identifier whose payload ends at the match key carries no terms byte, +/// and a missing byte is read as index 0, so absence and zero say the same +/// thing and neither has to be told apart from the other. /// /// The table is published at /// , @@ -135,7 +135,7 @@ impl IdType { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Terms { /// Index 0. The terms are not stated in the identifier, which is also - /// how an identifier issued before the byte existed reads. + /// how an identifier whose payload ends at the match key reads. /// /// This does not mean the identifier is unrestricted. It means the /// identifier does not carry the answer, so the answer has to come from @@ -316,10 +316,10 @@ impl FodId { }); } let match_key = payload[MATCH_KEY_OFFSET..MATCH_KEY_OFFSET + value_length].to_vec(); - // The terms index is the byte after the match key. A payload issued - // before the terms existed ends at the match key, and a missing byte - // is index 0, which says the terms are not stated in the identifier, - // so absence and zero are one answer. + // The terms index is the byte after the match key, so where it sits + // follows the match key length the type selects. A missing byte is + // index 0, which says the terms are not stated in the identifier, so + // absence and zero are one answer. // // A reserved type has no assigned match key length, so its value is // every byte after the header and there is no byte left for the @@ -377,9 +377,8 @@ impl FodId { /// The terms document the identifier was created under, read from the /// byte after the match key. A payload that ends at the match key reads - /// as [`Terms::NotStated`], so an identifier issued before the byte - /// existed answers as one that states no terms. See [`Terms`] for what - /// each value means. + /// as [`Terms::NotStated`], which says no terms are stated in it. See + /// [`Terms`] for what each value means. pub fn terms(&self) -> Terms { Terms::from_index(self.terms_index) } diff --git a/fodid/src/lib.rs b/fodid/src/lib.rs index 9407578..319f961 100644 --- a/fodid/src/lib.rs +++ b/fodid/src/lib.rs @@ -66,8 +66,8 @@ //! [`Terms`] value, through [`FodId::terms_index`] as the index itself, and //! through [`FodId::terms_url`] as the address of the document. //! -//! An identifier issued before the terms existed ends at the match key, and -//! a missing byte is index 0, being [`Terms::NotStated`], so absence and +//! An identifier whose payload ends at the match key carries no terms byte, +//! and a missing byte is index 0, being [`Terms::NotStated`], so absence and //! zero say the same thing. An index added to the specification after this //! release is [`Terms::Unknown`] and never [`Terms::NotStated`], because //! terms are stated and this crate cannot say which, and the index itself diff --git a/fodid/tests/fodid_tests.rs b/fodid/tests/fodid_tests.rs index e2f906b..745376c 100644 --- a/fodid/tests/fodid_tests.rs +++ b/fodid/tests/fodid_tests.rs @@ -861,10 +861,10 @@ fn reserved_type_exposes_remaining_payload_best_effort() { #[test] fn a_payload_ending_at_the_match_key_states_no_terms() { - // An identifier issued before the terms existed ends at the match key. - // A missing byte is index 0, which says the terms are not stated in the - // identifier, so such an identifier reads exactly as it did before the - // byte existed and answers index 0. + // There is no byte after the match key to read. A missing byte is index + // 0, which says the terms are not stated in the identifier, so such an + // identifier answers index 0 and every other field reads as it does with + // the byte present. let fixture = Fixture::new(); let result = FodId::from_base64(&fixture.signed_owid_base64(canonical_payload())); let fod_id = assert_parsed(&result); @@ -873,7 +873,7 @@ fn a_payload_ending_at_the_match_key_states_no_terms() { assert_eq!(fod_id.terms_index(), 0); assert_eq!(fod_id.terms_url(), None); - // Every field that was readable before is unchanged. + // Every other field reads as it does with the byte present. assert_eq!(fod_id.flags(), CANONICAL_FLAGS); assert_eq!(fod_id.license_id(), CANONICAL_LICENSE_ID); assert_eq!(fod_id.match_key(), &canonical_hash()); From d313506f28be6002aa83cc22270ca00c7a965726 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 9 Sep 2026 19:35:00 +0100 Subject: [PATCH 04/11] FEAT: Refuse a payload version this crate cannot read, and answer the terms with their address Bits 4 and 5 of the flags byte are the payload version. This crate reads version 0 and refuses any other with Error::UnsupportedPayloadVersion, which carries the version it found so the message names it. No field is read under the layout this crate knows once the version says otherwise, because a later version exists precisely because a field moved, so reading such a payload here would answer with values that are wrong rather than absent. The version is not exposed, because either the crate read the layout or there is no identifier to read fields from. The terms are one member rather than three. FodId::terms answers with the address of the document the identifier was created under, and the crate turns the index into the address so a caller never handles the byte. The raw index and the separate address member are gone and the Terms enumeration is now pub(crate). An index of zero and an index this crate cannot name both answer with None, which a caller cannot tell apart, and that is deliberate because both say the identifier does not give the terms and the answer has to come from somewhere else. No address is ever built from an index, since that would name a document nobody wrote. The test fixtures are the creating side, so they write both new fields. The canonical flags byte carries version 0 and the canonical payload carries the terms of a personalized marketing identifier. A payload that ends at the match key is now a fixture of its own, since a reader takes it as an index of zero and no issuer would write one. fodid tests: 24 unit, 52 integration and 19 documentation tests pass, with 1 ignored, being the live cloud test. cargo fmt is clean and cargo clippy with -D warnings finds nothing. --- fodid/README.md | 47 +++++--- fodid/src/error.rs | 26 ++++- fodid/src/fodid.rs | 96 ++++++++++------ fodid/src/lib.rs | 44 ++++--- fodid/tests/fodid_tests.rs | 228 ++++++++++++++++++++++++------------- 5 files changed, 293 insertions(+), 148 deletions(-) diff --git a/fodid/README.md b/fodid/README.md index e306ef0..c1ec3d4 100644 --- a/fodid/README.md +++ b/fodid/README.md @@ -55,17 +55,40 @@ The byte after the match key says which terms document the 51Did was created under, so that the terms travel with the identifier rather than beside it. It is an index into a table published in the [specification](https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md) -and is not a version number, read through `FodId::terms` as a named `Terms` -value, through `FodId::terms_index` as the index itself, and through -`FodId::terms_url` as the address of the document. +and is not a version number. `FodId::terms` answers with the address of the +document, so a caller never handles the byte. + +| Index | Document | `FodId::terms` | +| --- | --- | --- | +| `0` | Not stated in the identifier | `None` | +| `1` | Model Terms for Marketing, version 2 | `Some("https://m4ow.uk/mtm/2.txt")` | +| any other | One this crate cannot name | `None` | An identifier whose payload ends at the match key carries no terms byte, and -a missing byte is index 0, being `Terms::NotStated`, so absence and zero say -the same thing. An index added to the specification after this release is -`Terms::Unknown` and never `Terms::NotStated`, because terms are stated and -this crate cannot say which, and the index itself stays available so a caller -can say which one it could not read. This crate answers with the address and -never fetches it. +a missing byte is index 0, which answers with no address. An index added to +the specification after this release answers with no address as well, and no +address is ever built from an index this crate cannot name, since that would +name a document nobody wrote. A caller therefore cannot tell an index of +zero from an index this crate cannot name, which is deliberate, because both +lead to the same place. This crate answers with the address and never +fetches it. + +## The payload version + +Bits 4 and 5 of the flags byte say which payload layout the identifier +follows, and this crate reads version 0. A payload naming version 1, 2 or 3 +is refused with `Error::UnsupportedPayloadVersion`, which names the version +it found. + +No field is read under the layout this crate knows once the version says +otherwise. A later version exists precisely because a field moved, so +reading such a payload here would answer with values that are wrong rather +than absent, which is worse than refusing. A version that nothing checks +protects nothing. + +The version is not exposed. Either this crate read the layout, in which case +the accessors are the answer, or it did not, in which case there is no +identifier to read fields from. ## Payload layout @@ -113,9 +136,7 @@ fn read(base64_from_cloud_service: &str, public_pem: &str) -> Result<(), fodid:: // The terms the identifier was created under, and the address of that // document where this crate knows the index. - let terms = fod_id.terms(); // Terms - let terms_index = fod_id.terms_index(); // u8 - let terms_url = fod_id.terms_url(); // Option<&'static str> + let terms = fod_id.terms(); // Option<&'static str> // Inherited OWID level fields and operations, available through Deref. let domain = fod_id.domain(); @@ -126,7 +147,7 @@ fn read(base64_from_cloud_service: &str, public_pem: &str) -> Result<(), fodid:: == SignatureStatus::Valid; let _ = (flags, license_id, match_key, domain, round_trip, genuine); - let _ = (terms, terms_index, terms_url); + let _ = terms; Ok(()) } ``` diff --git a/fodid/src/error.rs b/fodid/src/error.rs index f79f805..4a00e2a 100644 --- a/fodid/src/error.rs +++ b/fodid/src/error.rs @@ -36,8 +36,9 @@ pub type Result = std::result::Result; /// outcome. Each one is a named status a caller can branch on directly, /// without matching on message text, and together they are the 51Did status /// vocabulary, being the OWID one (carried unchanged inside -/// [`Error::Parse`]) plus the two 51Did statuses [`Error::PayloadTooShort`] -/// and [`Error::InvalidTypePayloadLength`]. +/// [`Error::Parse`]) plus the three 51Did statuses +/// [`Error::PayloadTooShort`], [`Error::InvalidTypePayloadLength`] and +/// [`Error::UnsupportedPayloadVersion`]. /// /// A successful read says nothing about the signature. Whether the bytes /// are a 51Did and whether the signature is genuine are two questions with @@ -81,6 +82,18 @@ pub enum Error { /// The number of payload bytes actually present. actual: usize, }, + /// Bits 4 and 5 of the flags byte name a payload layout version this + /// crate does not know, so no field is read. + /// + /// A later version exists precisely because a field moved, so reading + /// the payload under the layout this crate knows would answer with + /// values that are wrong rather than absent, which is worse than + /// refusing. + UnsupportedPayloadVersion { + /// The version the payload named, being 1, 2 or 3, since 0 is the + /// layout this crate reads. + version: u8, + }, /// An OWID operation other than a read failed, for example serialising /// the envelope again or verifying its signature. Wraps the error type of /// the OWID library compiled into this crate, re-exported as @@ -108,6 +121,11 @@ impl fmt::Display for Error { "InvalidTypePayloadLength: a {id_type:?} 51Did needs at least \ {expected} payload bytes and {actual} are present" ), + Error::UnsupportedPayloadVersion { version } => write!( + f, + "UnsupportedPayloadVersion: 51Did payload version {version} \ + is not one this crate can read" + ), Error::Owid(e) => write!(f, "OWID operation failed because {e}"), } } @@ -118,7 +136,9 @@ impl std::error::Error for Error { match self { Error::Parse(e) => Some(e), Error::Owid(e) => Some(e), - Error::PayloadTooShort { .. } | Error::InvalidTypePayloadLength { .. } => None, + Error::PayloadTooShort { .. } + | Error::InvalidTypePayloadLength { .. } + | Error::UnsupportedPayloadVersion { .. } => None, } } } diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index 899248b..03e354e 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -74,6 +74,11 @@ pub const RANDOM_PAYLOAD_LENGTH: usize = HEADER_LENGTH + GUID_LENGTH; /// payloads have a shorter minimum, see [`RANDOM_PAYLOAD_LENGTH`]. pub const PAYLOAD_LENGTH: usize = MATCH_KEY_OFFSET + MATCH_KEY_LENGTH; +/// The payload layout version this crate reads, carried in bits 4 and 5 of +/// the flags byte. Any other version is refused with +/// [`Error::UnsupportedPayloadVersion`] rather than read under this layout. +pub(crate) const SUPPORTED_PAYLOAD_VERSION: u8 = 0; + /// The identifier type carried in bits 6-7 of the 51Did flags byte. /// /// Existing identifiers were issued with those bits zeroed, so they decode as @@ -132,8 +137,14 @@ impl IdType { /// The table is published at /// , /// which is the authority rather than this summary. +/// +/// This enumeration is not public, and neither is the index behind it. The +/// crate turns the index into the address that [`FodId::terms`] answers +/// with, so a caller never handles the byte, and the names here are the ones +/// the specification gives so that every package describes one document the +/// same way. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Terms { +pub(crate) enum Terms { /// Index 0. The terms are not stated in the identifier, which is also /// how an identifier whose payload ends at the match key reads. /// @@ -145,16 +156,14 @@ pub enum Terms { /// was created under, and a receiver needs both. NotStated, /// Index 1. The Model Terms for Marketing, version 2, whose address is - /// answered by [`terms_url`](FodId::terms_url). + /// answered by [`FodId::terms`]. ModelTermsForMarketing2, /// An index added to the specification after this release, which this /// crate cannot name. /// - /// It is not [`Terms::NotStated`], because terms are stated and this - /// crate cannot say which, and a caller treating the two alike would - /// read an identifier created under terms as one created under none. - /// Read the index itself with [`terms_index`](FodId::terms_index), then - /// either update this crate or refuse the identifier. + /// It answers with no address, as [`Terms::NotStated`] does, because + /// no address may be built from an index this crate cannot name, since + /// that would name a document nobody wrote. Unknown, } @@ -204,9 +213,17 @@ impl Terms { /// /// The terms byte follows the match key, so where it sits depends on the /// match key length the type requires. It is read through -/// [`terms`](FodId::terms), [`terms_index`](FodId::terms_index) and -/// [`terms_url`](FodId::terms_url), and a payload that ends at the match key -/// carries no terms byte and reads as [`Terms::NotStated`]. +/// [`terms`](FodId::terms), which answers with the address of the document, +/// and a payload that ends at the match key carries no terms byte and +/// answers with no address. +/// +/// Bits 4 and 5 of the flags byte say which payload layout the identifier +/// follows, and this crate reads version 0. A payload naming any other +/// version is refused with [`Error::UnsupportedPayloadVersion`] rather than +/// read under the layout this crate knows, because a later version exists +/// precisely because a field moved, so reading one here would answer with +/// values that are wrong rather than absent. The version is not exposed, +/// because a caller has nothing to decide with it. /// /// The lengths in the table are minimums. A payload may carry more bytes /// after the fields above, which this reader accepts and leaves in place, @@ -295,6 +312,18 @@ impl FodId { }); } let flags = payload[FLAGS_OFFSET]; + // The version is read before any field, because a later version + // exists precisely because a field moved. Reading a payload of a + // version this crate does not know under the layout it does know + // would answer with values that are wrong rather than absent, which + // is worse than refusing, and a version that nothing checks + // protects nothing. + let payload_version = (flags >> 4) & 0b11; + if payload_version != SUPPORTED_PAYLOAD_VERSION { + return Err(Error::UnsupportedPayloadVersion { + version: payload_version, + }); + } let license_id = u32::from_le_bytes( payload[LICENSE_ID_OFFSET..LICENSE_ID_OFFSET + LICENSE_ID_LENGTH] .try_into() @@ -375,35 +404,28 @@ impl FodId { self.match_key() } - /// The terms document the identifier was created under, read from the - /// byte after the match key. A payload that ends at the match key reads - /// as [`Terms::NotStated`], which says no terms are stated in it. See - /// [`Terms`] for what each value means. - pub fn terms(&self) -> Terms { - Terms::from_index(self.terms_index) - } - - /// The raw index behind [`terms`](FodId::terms), being the byte after - /// the match key, or 0 where the payload ends at the match key. - /// - /// This is the one raw value the surface carries, and it is here - /// because a caller meeting an index added after this release would - /// otherwise hold [`Terms::Unknown`] and no way to find out what it - /// stands for, so it could neither look the document up by hand nor - /// report which index it could not read. - pub fn terms_index(&self) -> u8 { - self.terms_index - } - /// The address of the terms document the identifier was created under, - /// or `None` where there is none to give, being [`Terms::NotStated`] - /// and an index this crate does not know. + /// read from the byte after the match key. + /// + /// The byte is an index into a table in the specification and this + /// crate turns the index into the address, so a caller never handles + /// the byte. The address is answered and never fetched, and it is never + /// an empty string and never built from the index, so `Some` means this + /// crate knows the document and the caller can rely on the address it + /// holds. + /// + /// `None` covers both an index of zero, which says the terms are not + /// stated in the identifier, and an index added to the table after this + /// release, which this crate cannot name. A caller cannot tell those + /// two apart, which is deliberate, because both lead to the same place, + /// being that the identifier does not say which terms it was created + /// under and the answer has to come from somewhere else. /// - /// The address is answered and never fetched, and it is never an empty - /// string and never built from the index, so `Some` means this crate - /// knows the document and the caller can rely on the address it holds. - pub fn terms_url(&self) -> Option<&'static str> { - self.terms().url() + /// No address does not mean the identifier is unrestricted. Where an + /// identifier may go is a separate question [`usage`](FodId::usage) + /// answers. + pub fn terms(&self) -> Option<&'static str> { + Terms::from_index(self.terms_index).url() } /// A reference to the underlying OWID envelope. diff --git a/fodid/src/lib.rs b/fodid/src/lib.rs index 319f961..adb8a3f 100644 --- a/fodid/src/lib.rs +++ b/fodid/src/lib.rs @@ -62,17 +62,29 @@ //! The byte after the match key says which terms document the 51Did was //! created under, so that the terms travel with the identifier rather than //! beside it. It is an index into a table published in the specification and -//! is not a version number, read through [`FodId::terms`] as a named -//! [`Terms`] value, through [`FodId::terms_index`] as the index itself, and -//! through [`FodId::terms_url`] as the address of the document. +//! is not a version number, and [`FodId::terms`] answers with the address of +//! the document, so a caller never handles the byte. //! //! An identifier whose payload ends at the match key carries no terms byte, -//! and a missing byte is index 0, being [`Terms::NotStated`], so absence and -//! zero say the same thing. An index added to the specification after this -//! release is [`Terms::Unknown`] and never [`Terms::NotStated`], because -//! terms are stated and this crate cannot say which, and the index itself -//! stays available so a caller can say which one it could not read. This -//! crate answers with the address and never fetches it. +//! and a missing byte is index 0, which answers with no address. An index +//! added to the specification after this release answers with no address as +//! well, and no address is ever built from an index this crate cannot name, +//! since that would name a document nobody wrote. A caller therefore cannot +//! tell an index of zero from an index this crate cannot name, which is +//! deliberate, because both lead to the same place. This crate answers with +//! the address and never fetches it. +//! +//! ## The payload version +//! +//! Bits 4 and 5 of the flags byte say which payload layout the identifier +//! follows, and this crate reads version 0. A payload naming version 1, 2 or +//! 3 is refused with [`Error::UnsupportedPayloadVersion`], which names the +//! version it found. No field is read under the layout this crate knows once +//! the version says otherwise, because a later version exists precisely +//! because a field moved, so reading such a payload here would answer with +//! values that are wrong rather than absent. The version is not exposed, +//! because either this crate read the layout or there is no identifier to +//! read fields from. //! //! ## Payload layout //! @@ -162,11 +174,9 @@ //! let license_id: u32 = fod_id.license_id(); //! let match_key: &[u8] = fod_id.match_key(); // the match key to compare (32 or 16 bytes) //! -//! // The terms the identifier was created under, and the address of that -//! // document where this crate knows the index. -//! let terms = fod_id.terms(); -//! let terms_index: u8 = fod_id.terms_index(); -//! let terms_url: Option<&str> = fod_id.terms_url(); +//! // The address of the terms document the identifier was created under, +//! // and None where it names none this crate knows. +//! let terms: Option<&str> = fod_id.terms(); //! //! // Inherited OWID level fields and operations, available through Deref. //! let domain = fod_id.domain(); @@ -176,7 +186,7 @@ //! let status = fod_id.verify_status_with_public_key(public_pem, &[]); //! let genuine = status == SignatureStatus::Valid; //! # let _ = (flags, id_type, license_id, match_key, domain, round_trip, genuine); -//! # let _ = (terms, terms_index, terms_url); +//! # let _ = terms; //! # Ok(()) //! # } //! ``` @@ -289,8 +299,8 @@ mod fodid; pub use error::{Error, Result}; pub use fodid::{ - FodId, IdType, Terms, FLAGS_OFFSET, GUID_LENGTH, HEADER_LENGTH, LICENSE_ID_LENGTH, - LICENSE_ID_OFFSET, MATCH_KEY_LENGTH, MATCH_KEY_OFFSET, PAYLOAD_LENGTH, RANDOM_PAYLOAD_LENGTH, + FodId, IdType, FLAGS_OFFSET, GUID_LENGTH, HEADER_LENGTH, LICENSE_ID_LENGTH, LICENSE_ID_OFFSET, + MATCH_KEY_LENGTH, MATCH_KEY_OFFSET, PAYLOAD_LENGTH, RANDOM_PAYLOAD_LENGTH, }; // The obsolete names for the match key constants, re-exported so callers diff --git a/fodid/tests/fodid_tests.rs b/fodid/tests/fodid_tests.rs index 745376c..ce293d5 100644 --- a/fodid/tests/fodid_tests.rs +++ b/fodid/tests/fodid_tests.rs @@ -28,11 +28,13 @@ //! no value came back, and the status names the reason. Reading never //! touches a key, so none of the failure cases here constructs one. -use fodid::{Creator, Crypto, Error, FodId, IdType, Owid, ParseStatus, SignatureStatus, Terms}; +use fodid::{Creator, Crypto, Error, FodId, IdType, Owid, ParseStatus, SignatureStatus}; const TEST_DOMAIN: &str = "51degrees.com"; -const CANONICAL_FLAGS: u8 = 0b1010_0101; +/// The personalized marketing usage in bits 0-2, the payload version 0 in +/// bits 4-5 and the hashed email type in bits 6-7. +const CANONICAL_FLAGS: u8 = 0b1000_0101; const CANONICAL_LICENSE_ID: u32 = 0x1234_5678; /// Flags bytes whose bits 6-7 select each identifier type. The lower usage @@ -52,9 +54,12 @@ fn canonical_hash() -> [u8; fodid::MATCH_KEY_LENGTH] { hash } -/// A canonical 37-byte 51Did payload with flags = 0xA5, -/// licenseId = 0x12345678 (little endian) and the canonical hash. -fn canonical_payload() -> Vec { +/// A canonical 37-byte 51Did payload with flags = 0x85, +/// licenseId = 0x12345678 (little endian) and the canonical hash, cut off at +/// the end of the match key so it carries no terms byte. A reader takes that +/// as a terms index of zero, and this is the fixture for that rule rather +/// than anything an issuer would write. +fn payload_ending_at_match_key() -> Vec { let mut payload = vec![0u8; fodid::PAYLOAD_LENGTH]; payload[fodid::FLAGS_OFFSET] = CANONICAL_FLAGS; payload[fodid::LICENSE_ID_OFFSET..fodid::LICENSE_ID_OFFSET + fodid::LICENSE_ID_LENGTH] @@ -64,6 +69,22 @@ fn canonical_payload() -> Vec { payload } +/// The canonical payload as an issuer writes one, carrying the payload +/// version 0 in its flags byte and the terms byte of the document a +/// personalized marketing identifier is created under. This is the creating +/// side, so it writes every field an issuer writes. +fn canonical_payload() -> Vec { + payload_with_terms(MODEL_TERMS_INDEX) +} + +/// The payload with its version bits set to the given version, leaving every +/// other bit of the flags byte alone. +fn with_payload_version(payload: &[u8], version: u8) -> Vec { + let mut changed = payload.to_vec(); + changed[fodid::FLAGS_OFFSET] = (payload[fodid::FLAGS_OFFSET] & 0b1100_1111) | (version << 4); + changed +} + /// Build a payload of `value_len` value bytes after the header, with the given /// flags byte and the canonical license id. The value bytes run 0x50, 0x51, ... fn typed_payload(flags: u8, value_len: usize) -> Vec { @@ -89,10 +110,10 @@ const MODEL_TERMS_URL: &str = "https://m4ow.uk/mtm/2.txt"; const UNKNOWN_TERMS_INDEX: u8 = 200; /// The canonical payload with a terms index byte after the match key, which -/// is the shape issued once the terms exist. The byte is appended by hand, -/// because the offset it lands at is what these tests are checking. +/// is the shape an issuer writes. The byte is appended by hand, because the +/// offset it lands at is what these tests are checking. fn payload_with_terms(index: u8) -> Vec { - let mut payload = canonical_payload(); + let mut payload = payload_ending_at_match_key(); payload.push(index); payload } @@ -311,14 +332,17 @@ fn flags_zero_value_exposed() { } #[test] -fn flags_all_bits_set_exposed() { +fn every_flags_bit_outside_the_version_exposed() { + // Bits 4 and 5 are the payload version and only version 0 is read, so + // every other bit is set and those two are left clear. A payload with + // them set is refused rather than read, which the version tests cover. let fixture = Fixture::new(); let mut payload = canonical_payload(); - payload[fodid::FLAGS_OFFSET] = 0xFF; + payload[fodid::FLAGS_OFFSET] = 0xCF; let fod_id = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap(); - assert_eq!(0xFF, fod_id.flags()); + assert_eq!(0xCF, fod_id.flags()); } #[test] @@ -860,18 +884,16 @@ fn reserved_type_exposes_remaining_payload_best_effort() { } #[test] -fn a_payload_ending_at_the_match_key_states_no_terms() { +fn a_payload_ending_at_the_match_key_has_no_terms_address() { // There is no byte after the match key to read. A missing byte is index // 0, which says the terms are not stated in the identifier, so such an - // identifier answers index 0 and every other field reads as it does with - // the byte present. + // identifier answers with no address and every other field reads as it + // does with the byte present. let fixture = Fixture::new(); - let result = FodId::from_base64(&fixture.signed_owid_base64(canonical_payload())); + let result = FodId::from_base64(&fixture.signed_owid_base64(payload_ending_at_match_key())); let fod_id = assert_parsed(&result); - assert_eq!(fod_id.terms(), Terms::NotStated); - assert_eq!(fod_id.terms_index(), 0); - assert_eq!(fod_id.terms_url(), None); + assert_eq!(fod_id.terms(), None); // Every other field reads as it does with the byte present. assert_eq!(fod_id.flags(), CANONICAL_FLAGS); @@ -886,13 +908,12 @@ fn an_explicit_zero_reads_the_same_as_a_missing_terms_byte() { // Absence and zero say the same thing, so nothing has to tell them // apart and no presence flag is needed. let fixture = Fixture::new(); - let absent = FodId::from_base64(&fixture.signed_owid_base64(canonical_payload())).unwrap(); + let absent = + FodId::from_base64(&fixture.signed_owid_base64(payload_ending_at_match_key())).unwrap(); let zero = FodId::from_base64(&fixture.signed_owid_base64(payload_with_terms(0))).unwrap(); assert_eq!(absent.terms(), zero.terms()); - assert_eq!(absent.terms_index(), zero.terms_index()); - assert_eq!(absent.terms_url(), zero.terms_url()); - assert_eq!(zero.terms(), Terms::NotStated); + assert_eq!(zero.terms(), None); // The payloads still differ by the byte, which stays in place. assert_eq!(absent.payload().len() + 1, zero.payload().len()); @@ -905,34 +926,30 @@ fn index_one_is_the_model_terms_for_marketing_and_carries_its_address() { let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); let fod_id = assert_parsed(&result); - assert_eq!(fod_id.terms(), Terms::ModelTermsForMarketing2); - assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX); - assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL)); + assert_eq!(fod_id.terms(), Some(MODEL_TERMS_URL)); assert_eq!(fod_id.match_key(), &canonical_hash()); } #[test] -fn an_index_this_crate_does_not_know_is_reported_and_has_no_address() { - // A caller meeting an index added after this release has to be able to - // say which index it could not read, so the raw index is answered - // whether or not the named value is known. +fn an_index_this_crate_does_not_know_has_no_address() { + // No address is ever built from an index this crate cannot name, + // because that would name a document nobody wrote and a receiver would + // record having accepted terms that do not exist. let fixture = Fixture::new(); - let payload = payload_with_terms(UNKNOWN_TERMS_INDEX); - let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); - let fod_id = assert_parsed(&result); + for index in [2u8, 127, UNKNOWN_TERMS_INDEX, 255] { + let payload = payload_with_terms(index); + let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); + let fod_id = assert_parsed(&result); - assert_eq!(fod_id.terms_index(), UNKNOWN_TERMS_INDEX); - assert_eq!(fod_id.terms(), Terms::Unknown); - assert_eq!(fod_id.terms_url(), None); - assert_ne!(fod_id.terms(), Terms::NotStated); + assert_eq!(fod_id.terms(), None, "index {index}"); + } } #[test] -fn no_terms_stated_and_an_unknown_index_are_told_apart() { - // Zero says no terms are stated, whilst an unknown index says terms are - // stated that this crate cannot name. A caller that confused the two - // would read an identifier created under terms as one created under - // none, so the two answer differently in all three members. +fn no_terms_stated_and_an_unknown_index_both_have_no_address() { + // A caller cannot tell the two apart, which is deliberate, since both + // say the identifier does not give the terms and the answer has to come + // from somewhere else. let fixture = Fixture::new(); let stated_none = FodId::from_base64(&fixture.signed_owid_base64(payload_with_terms(0))).unwrap(); @@ -940,15 +957,8 @@ fn no_terms_stated_and_an_unknown_index_are_told_apart() { FodId::from_base64(&fixture.signed_owid_base64(payload_with_terms(UNKNOWN_TERMS_INDEX))) .unwrap(); - assert_ne!(stated_none.terms(), unknown.terms()); - assert_ne!(stated_none.terms_index(), unknown.terms_index()); - assert_eq!(stated_none.terms(), Terms::NotStated); - assert_eq!(unknown.terms(), Terms::Unknown); - - // Neither carries an address, and that shared answer is the reason the - // named value and the index have to be read to tell them apart. - assert_eq!(stated_none.terms_url(), None); - assert_eq!(unknown.terms_url(), None); + assert_eq!(stated_none.terms(), None); + assert_eq!(unknown.terms(), None); } #[test] @@ -958,19 +968,17 @@ fn every_terms_index_decodes_as_the_specification_publishes_it() { // after the last published index and the largest a byte can hold. let fixture = Fixture::new(); let cases = [ - (0u8, Terms::NotStated, None), - (1, Terms::ModelTermsForMarketing2, Some(MODEL_TERMS_URL)), - (2, Terms::Unknown, None), - (UNKNOWN_TERMS_INDEX, Terms::Unknown, None), - (255, Terms::Unknown, None), + (0u8, None), + (1, Some(MODEL_TERMS_URL)), + (2, None), + (UNKNOWN_TERMS_INDEX, None), + (255, None), ]; - for (index, expected_terms, expected_url) in cases { + for (index, expected_url) in cases { let payload = payload_with_terms(index); let fod_id = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap(); - assert_eq!(fod_id.terms(), expected_terms, "index {index}"); - assert_eq!(fod_id.terms_index(), index, "index {index}"); - assert_eq!(fod_id.terms_url(), expected_url, "index {index}"); + assert_eq!(fod_id.terms(), expected_url, "index {index}"); } } @@ -1006,13 +1014,7 @@ fn the_terms_byte_is_read_after_the_match_key_for_both_match_key_lengths() { 0x50 + (value_len as u8 - 1), "{id_type:?}" ); - assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX, "{id_type:?}"); - assert_eq!( - fod_id.terms(), - Terms::ModelTermsForMarketing2, - "{id_type:?}" - ); - assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL), "{id_type:?}"); + assert_eq!(fod_id.terms(), Some(MODEL_TERMS_URL), "{id_type:?}"); } } @@ -1033,13 +1035,7 @@ fn a_creator_context_after_the_terms_leaves_both_the_match_key_and_terms_read() let fod_id = assert_parsed(&result); assert_eq!(fod_id.match_key(), &canonical_hash(), "{context_len}"); - assert_eq!(fod_id.terms_index(), MODEL_TERMS_INDEX, "{context_len}"); - assert_eq!( - fod_id.terms(), - Terms::ModelTermsForMarketing2, - "{context_len}" - ); - assert_eq!(fod_id.terms_url(), Some(MODEL_TERMS_URL), "{context_len}"); + assert_eq!(fod_id.terms(), Some(MODEL_TERMS_URL), "{context_len}"); assert_eq!(fod_id.license_id(), CANONICAL_LICENSE_ID, "{context_len}"); assert_eq!( fod_id.payload(), @@ -1061,9 +1057,7 @@ fn a_reserved_identifier_states_no_terms() { let fod_id = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap(); assert_eq!(fod_id.id_type(), IdType::Reserved); - assert_eq!(fod_id.terms(), Terms::NotStated); - assert_eq!(fod_id.terms_index(), 0); - assert_eq!(fod_id.terms_url(), None); + assert_eq!(fod_id.terms(), None); } #[test] @@ -1075,6 +1069,84 @@ fn the_terms_survive_a_base64_round_trip() { let round_tripped = FodId::from_base64(&fod_id.as_base64().unwrap()).unwrap(); assert_eq!(round_tripped.terms(), fod_id.terms()); - assert_eq!(round_tripped.terms_index(), fod_id.terms_index()); - assert_eq!(round_tripped.terms_url(), fod_id.terms_url()); + assert_eq!(round_tripped.terms(), Some(MODEL_TERMS_URL)); +} + +#[test] +fn payload_version_zero_reads_every_field() { + // Bits 4 and 5 clear is version 0, which is the layout this crate + // reads, so every field reads as it does on the canonical payload. + let fixture = Fixture::new(); + let fod_id = FodId::from_base64(&fixture.signed_owid_base64(canonical_payload())).unwrap(); + + assert_eq!(fod_id.id_type(), IdType::HashedEmail); + assert_eq!(fod_id.flags(), CANONICAL_FLAGS); + assert_eq!(fod_id.license_id(), CANONICAL_LICENSE_ID); + assert_eq!(fod_id.match_key(), &canonical_hash()); + assert_eq!(fod_id.terms(), Some(MODEL_TERMS_URL)); +} + +#[test] +fn an_unassigned_payload_version_is_refused_and_names_the_version() { + // Versions 1, 2 and 3 are not assigned, so a payload naming one is + // refused rather than read under the layout this crate knows, and + // nothing is handed back because there is no identifier to expose + // fields for when the layout was not understood. + let fixture = Fixture::new(); + for version in [1u8, 2, 3] { + let payload = with_payload_version(&canonical_payload(), version); + let result = FodId::from_base64(&fixture.signed_owid_base64(payload)); + + match result { + Err(Error::UnsupportedPayloadVersion { version: found }) => { + assert_eq!(found, version); + } + other => panic!("version {version} was not refused: {other:?}"), + } + } +} + +#[test] +fn a_refused_payload_version_names_itself_in_the_message() { + let fixture = Fixture::new(); + for version in [1u8, 2, 3] { + let payload = with_payload_version(&canonical_payload(), version); + let error = FodId::from_base64(&fixture.signed_owid_base64(payload)).unwrap_err(); + + let message = error.to_string(); + assert!( + message.contains(&format!("version {version}")), + "message did not name the version: {message}" + ); + } +} + +#[test] +fn the_payload_version_is_read_apart_from_the_usage_and_type_bits() { + // A reader masking the wrong bits would refuse a version 0 identifier + // or let a later version through, so every combination of the usage and + // type bits is tried. + let fixture = Fixture::new(); + for usage in [0b000u8, 0b001, 0b011, 0b111] { + for id_type in [0b00u8, 0b10, 0b11] { + let flags = (id_type << 6) | usage; + let mut payload = payload_ending_at_match_key(); + payload[fodid::FLAGS_OFFSET] = flags; + + assert!( + FodId::from_base64(&fixture.signed_owid_base64(payload.clone())).is_ok(), + "flags {flags}" + ); + + for version in [1u8, 2, 3] { + let refused = FodId::from_base64( + &fixture.signed_owid_base64(with_payload_version(&payload, version)), + ); + assert!( + matches!(refused, Err(Error::UnsupportedPayloadVersion { .. })), + "flags {flags} version {version}" + ); + } + } + } } From c6b7779cdaa3c3a3505c4114ff562ef11f1866fe Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 9 Sep 2026 21:34:07 +0100 Subject: [PATCH 05/11] REFACTOR: One terms table, so a new document is one row Adding a terms document meant editing the variant, an arm of from_index and an arm of url, so the index, the name and the address were written in three places that could disagree. TERMS_TABLE now holds one row per document carrying all three together, and both lookups read it, so a new document is one row and one variant. The bare 0 the absent byte fell back to is now NOT_STATED_INDEX, named where it is explained. Four unit tests check the table itself: every row round trips through both lookups and carries an https address, no row claims the not stated index, no index appears twice, and every one of the 256 indexes the table does not carry is Unknown with no address. cargo test -p fodid: 28 unit, 52 integration, 19 doc, 0 failures. Clippy clean over all targets. --- fodid/src/fodid.rs | 107 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index 03e354e..b5055cf 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -79,6 +79,12 @@ pub const PAYLOAD_LENGTH: usize = MATCH_KEY_OFFSET + MATCH_KEY_LENGTH; /// [`Error::UnsupportedPayloadVersion`] rather than read under this layout. pub(crate) const SUPPORTED_PAYLOAD_VERSION: u8 = 0; +/// The Terms index that says the terms are not stated in the identifier. A +/// payload ending at the match key reads as this, so absence and a zero +/// byte mean the same thing and nothing has to tell them apart. It is not a +/// row in [`TERMS_TABLE`] because it names no document. +const NOT_STATED_INDEX: u8 = 0; + /// The identifier type carried in bits 6-7 of the 51Did flags byte. /// /// Existing identifiers were issued with those bits zeroed, so they decode as @@ -167,16 +173,41 @@ pub(crate) enum Terms { Unknown, } +/// The terms table from the specification, which is the whole of the +/// definition of which index is which document. It is published at +/// +/// and this is the only place in the crate that carries it. +/// +/// One row per terms document, holding the index the payload carries, the +/// name for it and the address it stands for. A new terms document is one +/// new row here and one new variant of [`Terms`], and nothing else in the +/// crate changes. That is the point of the byte being an index rather than +/// a version number, so the cost of a new document is a row and not a +/// search for every place a number was written down. +/// +/// [`Terms::NotStated`] and [`Terms::Unknown`] are deliberately absent. +/// Neither names a document, so neither has an address, and a lookup that +/// finds no row is the answer for both. +/// +/// Each address names an exact version rather than a landing page, because +/// a document at an unversioned address can be edited afterwards and a +/// receiver has to know the document that was in force when the identifier +/// was made. +const TERMS_TABLE: &[(u8, Terms, &str)] = + &[(1, Terms::ModelTermsForMarketing2, "https://m4ow.uk/mtm/2.txt")]; + impl Terms { /// Decode the terms from the index byte that follows the match key. An /// index this crate does not know decodes as [`Terms::Unknown`] and /// never as [`Terms::NotStated`]. fn from_index(index: u8) -> Terms { - match index { - 0 => Terms::NotStated, - 1 => Terms::ModelTermsForMarketing2, - _ => Terms::Unknown, + if index == NOT_STATED_INDEX { + return Terms::NotStated; } + TERMS_TABLE + .iter() + .find(|(row_index, _, _)| *row_index == index) + .map_or(Terms::Unknown, |(_, terms, _)| *terms) } /// The address of the terms document, or `None` where there is none to @@ -184,10 +215,10 @@ impl Terms { /// address is answered and never fetched, and the caller decides what /// to do with it. fn url(self) -> Option<&'static str> { - match self { - Terms::NotStated | Terms::Unknown => None, - Terms::ModelTermsForMarketing2 => Some("https://m4ow.uk/mtm/2.txt"), - } + TERMS_TABLE + .iter() + .find(|(_, terms, _)| *terms == self) + .map(|(_, _, url)| *url) } } @@ -358,7 +389,7 @@ impl FodId { let terms_index = payload .get(MATCH_KEY_OFFSET + value_length) .copied() - .unwrap_or(0); + .unwrap_or(NOT_STATED_INDEX); Ok(FodId { owid, flags, @@ -470,3 +501,61 @@ impl FromStr for FodId { FodId::from_base64(s) } } +#[cfg(test)] +mod terms_table_tests { + use super::{Terms, NOT_STATED_INDEX, TERMS_TABLE}; + + /// Every row reads back through the pair of lookups it feeds, so a row + /// whose index and variant were mistyped apart is caught here rather + /// than by a caller reading no address for a document this crate is + /// meant to know. + #[test] + fn every_row_round_trips_through_the_lookups() { + for (index, terms, url) in TERMS_TABLE { + assert_eq!(Terms::from_index(*index), *terms, "index {index}"); + assert_eq!(terms.url(), Some(*url), "{terms:?}"); + assert!(url.starts_with("https://"), "{url} is not https"); + } + } + + /// No row may claim index 0. That index says the terms are not stated, + /// which names no document, so a row there would give an address to an + /// identifier that states none. + #[test] + fn no_row_claims_the_not_stated_index() { + assert!(TERMS_TABLE.iter().all(|(index, _, _)| *index != NOT_STATED_INDEX)); + assert_eq!(Terms::from_index(NOT_STATED_INDEX), Terms::NotStated); + assert_eq!(Terms::NotStated.url(), None); + } + + /// One index may stand for one document only. Two rows sharing an index + /// would make which document an identifier was created under depend on + /// the order the table happens to be written in. + #[test] + fn no_index_appears_twice() { + for (position, (index, _, _)) in TERMS_TABLE.iter().enumerate() { + assert!( + !TERMS_TABLE[position + 1..] + .iter() + .any(|(later, _, _)| later == index), + "index {index} appears more than once" + ); + } + } + + /// An index the table does not carry is Unknown and never NotStated, + /// and it answers with no address rather than one built from the + /// number, since that would name a document nobody wrote. + #[test] + fn an_index_outside_the_table_is_unknown_with_no_address() { + for index in 0..=u8::MAX { + let known = index == NOT_STATED_INDEX + || TERMS_TABLE.iter().any(|(row, _, _)| *row == index); + if known { + continue; + } + assert_eq!(Terms::from_index(index), Terms::Unknown, "index {index}"); + assert_eq!(Terms::from_index(index).url(), None, "index {index}"); + } + } +} From 8804bf4a5a3a279e6b46a78406019b38d183a1ae Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 9 Sep 2026 21:48:21 +0100 Subject: [PATCH 06/11] DOC: Say shipped code, since the tests carry the address on purpose The table comment claimed to be the only place in the package carrying the address. A reader who greps finds it in the tests too, where it is written out deliberately so that a test never compares the reader with itself, so the claim now says shipped code and explains the test. --- fodid/src/fodid.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index b5055cf..16df1ba 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -176,7 +176,9 @@ pub(crate) enum Terms { /// The terms table from the specification, which is the whole of the /// definition of which index is which document. It is published at /// -/// and this is the only place in the crate that carries it. +/// and this is the only place in the shipped code that carries it. The +/// tests write the address out again on purpose, so that a test never +/// compares the reader with itself. /// /// One row per terms document, holding the index the payload carries, the /// name for it and the address it stands for. A new terms document is one From 856ae3106bb31a9755754c5578838eae3f1b2d72 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 13 Sep 2026 10:44:22 +0100 Subject: [PATCH 07/11] STYLE: Format the terms table and its tests the way rustfmt wants The only red job on this branch was `cargo fmt --all -- --check`, which reported three differences in fodid/src/fodid.rs, being the TERMS_TABLE constant and two of its tests. Every other job was green, including the wasm32-wasip1 build and the examples check. This is the output of `cargo fmt --all` and nothing else. No behaviour changes. Verified: `cargo fmt --all -- --check` is now silent, and `cargo test -p fodid` runs 99 tests with 0 failures and 1 ignored. --- fodid/src/fodid.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index 16df1ba..f0822a9 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -195,8 +195,11 @@ pub(crate) enum Terms { /// a document at an unversioned address can be edited afterwards and a /// receiver has to know the document that was in force when the identifier /// was made. -const TERMS_TABLE: &[(u8, Terms, &str)] = - &[(1, Terms::ModelTermsForMarketing2, "https://m4ow.uk/mtm/2.txt")]; +const TERMS_TABLE: &[(u8, Terms, &str)] = &[( + 1, + Terms::ModelTermsForMarketing2, + "https://m4ow.uk/mtm/2.txt", +)]; impl Terms { /// Decode the terms from the index byte that follows the match key. An @@ -525,7 +528,9 @@ mod terms_table_tests { /// identifier that states none. #[test] fn no_row_claims_the_not_stated_index() { - assert!(TERMS_TABLE.iter().all(|(index, _, _)| *index != NOT_STATED_INDEX)); + assert!(TERMS_TABLE + .iter() + .all(|(index, _, _)| *index != NOT_STATED_INDEX)); assert_eq!(Terms::from_index(NOT_STATED_INDEX), Terms::NotStated); assert_eq!(Terms::NotStated.url(), None); } @@ -551,8 +556,8 @@ mod terms_table_tests { #[test] fn an_index_outside_the_table_is_unknown_with_no_address() { for index in 0..=u8::MAX { - let known = index == NOT_STATED_INDEX - || TERMS_TABLE.iter().any(|(row, _, _)| *row == index); + let known = + index == NOT_STATED_INDEX || TERMS_TABLE.iter().any(|(row, _, _)| *row == index); if known { continue; } From c8cbc593e7217a8249b369baa389ca03b2a7fad8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 13 Sep 2026 10:53:35 +0100 Subject: [PATCH 08/11] DOC: Point the terms note at the flags, which is the surface this crate has `cargo doc` failed the build with an unresolved intra-doc link, because the note on `terms` pointed at `FodId::usage` and this crate has no such item. It exposes the raw `flags` byte and documents the usage bits within it, so the link had nowhere to go and `-D warnings` turned that into an error. The sentence now points at `flags` and says what the usage bits do, which is bar a non-marketing identifier from a demand source. That is the wording the .NET package already carries for the same note. Verified: `cargo doc -p fodid --no-deps` with `RUSTDOCFLAGS=-D warnings` is clean, `cargo fmt --all -- --check` is silent, `cargo clippy -p fodid --all-targets -- -D warnings` is clean, and `cargo test -p fodid` runs 99 tests with 0 failures and 1 ignored. --- fodid/src/fodid.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fodid/src/fodid.rs b/fodid/src/fodid.rs index f0822a9..7de1acc 100644 --- a/fodid/src/fodid.rs +++ b/fodid/src/fodid.rs @@ -458,8 +458,9 @@ impl FodId { /// under and the answer has to come from somewhere else. /// /// No address does not mean the identifier is unrestricted. Where an - /// identifier may go is a separate question [`usage`](FodId::usage) - /// answers. + /// identifier may go is a separate question, answered by the usage + /// bits in [`flags`](FodId::flags), which still bar a non-marketing + /// identifier from a demand source. pub fn terms(&self) -> Option<&'static str> { Terms::from_index(self.terms_index).url() } From f5beed2347e35efef4a43bbec1cda71174b3bcf2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 13 Sep 2026 12:09:50 +0100 Subject: [PATCH 09/11] TEST: Check the terms, the flags and the consent bit against the live cloud The live cloud test asked the service for a 51Did at each id.usage and then checked the envelope around it, being the match key length, a minimum payload length, a non-empty domain and a base64 round trip. It never read the terms and never read the flags, so it passed just as happily against a service that wrote neither. Every identifier it reads now asserts the terms address the usage must produce, which is nothing for non-marketing and the Model Terms for Marketing for the two marketing usages, the usage the crate answers with, that the usage was recorded as stated by the caller, and that the type is probabilistic. Read through the accessors rather than by masking, because the usage values are cumulative, being 001, 011 and 111, so a caller masking the byte for the non-marketing bit reads every marketing identifier as non-marketing. The new test covers the half a caller cannot state for itself. A consent management platform sends an IAB TCF consent string and no usage of its own, the service decodes the purposes, decides the usage, and records in the identifier that it decided rather than was told. Two strings are sent, one granting all twelve purposes and one granting the Appendix 1 standard set, and each identifier must read as the matching usage with the consent bit set. No id.usage goes with them, because a stated usage wins over a consent string and sending one would prove the opposite. The address and the consent strings are written out here rather than taken from the crate or shared with the service's own tests, because a test that asked either what it expects would agree with it whatever it said. The local case struct was called Usage and shadowed the crate's enum, which the assertions now need, so it is UsageCase. Rust has no inconclusive result, so where a run reads no marketing identifier the consent test says NOTHING PROVEN rather than leaving a pass to be read as evidence. Verified: cargo fmt --all --check silent, cargo clippy -p fodid --all-targets -D warnings clean, cargo doc clean under RUSTDOCFLAGS=-D warnings, and cargo test -p fodid at 103 passed, 0 failed and 2 ignored, those two being the live tests. The assertions need the live service, so the Cloud tests step on this pull request is what proves them. --- fodid/tests/cloud_51did.rs | 202 ++++++++++++++++++++++++++++++++++--- 1 file changed, 187 insertions(+), 15 deletions(-) diff --git a/fodid/tests/cloud_51did.rs b/fodid/tests/cloud_51did.rs index f500c9c..c83215e 100644 --- a/fodid/tests/cloud_51did.rs +++ b/fodid/tests/cloud_51did.rs @@ -59,7 +59,7 @@ //! when it does not, so this test starts covering them automatically once a //! paid key is expanded for marketing. -use fodid::FodId; +use fodid::{FodId, IdType, Usage}; mod layout; @@ -84,30 +84,63 @@ const USER_AGENT: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) /// reserved for documentation (RFC 5737). const CLIENT_IP: &str = "203.0.113.42"; -/// A cloud `id.usage` level and whether every resource key must return a 51Did -/// for it. -struct Usage { +/// A cloud `id.usage` level, whether every resource key must return a 51Did +/// for it, and what the identifier must then say about itself. +struct UsageCase { /// The `id.usage` request value. name: &'static str, /// Whether a 51Did is required for this usage. Only `non-marketing` is /// required today; the marketing usages are validated when returned. required: bool, + /// The usage the reader must answer with. + expected: Usage, + /// The terms address the identifier must carry, or `None` where it must + /// state none. A non-marketing identifier may not reach a demand source + /// at all, so there is nothing for a receiver to agree to. + terms: Option<&'static str>, } +/// The versioned Model Terms for Marketing document a marketing 51Did is +/// created under. +/// +/// Written out here rather than read from the crate, because a test that +/// asked the crate what it expects would agree with itself whatever the +/// crate said. The literal is what a receiver has to be able to fetch. +const MODEL_TERMS_FOR_MARKETING_2: &str = "https://m4ow.uk/mtm/2.txt"; + +/// IAB TCF v2 consent strings, and the usage the service must derive from +/// each without the caller stating one. +/// +/// The first sets all twelve purposes, which is personalized. The second +/// sets the Appendix 1 standard set, being purposes 1, 2, 7, 8 and 11, which +/// is standard. Both are the strings the cloud's own IabTcfElement tests use, +/// repeated here rather than shared, for the same reason as the address +/// above. +const CONSENT_STRINGS: &[(&str, Usage)] = &[ + ("AAAAAAAAAAAAAAAAAAAAAAAAAP_w", Usage::Personalized), + ("AAAAAAAAAAAAAAAAAAAAAAAAAMMg", Usage::Standard), +]; + /// The usage levels checked for the resource key. Ordered with the required /// `non-marketing` usage first. -const USAGES: &[Usage] = &[ - Usage { +const USAGES: &[UsageCase] = &[ + UsageCase { name: "non-marketing", required: true, + expected: Usage::NonMarketing, + terms: None, }, - Usage { + UsageCase { name: "standard", required: false, + expected: Usage::Standard, + terms: Some(MODEL_TERMS_FOR_MARKETING_2), }, - Usage { + UsageCase { name: "personalized", required: false, + expected: Usage::Personalized, + terms: Some(MODEL_TERMS_FOR_MARKETING_2), }, ]; @@ -125,21 +158,96 @@ fn resource_key() -> Option { /// Calls the cloud JSON endpoint for the given `id.usage` and returns the /// parsed response body. fn request_usage(resource_key: &str, usage: &str) -> serde_json::Value { + request_with(resource_key, "id.usage", usage) +} + +/// Calls the cloud JSON endpoint with one extra query parameter and returns +/// the parsed response body. +fn request_with(resource_key: &str, name: &str, value: &str) -> serde_json::Value { let body = ureq::get(CLOUD_JSON_URL) .query("resource", resource_key) .query("user-agent", USER_AGENT) .query("client-ip", CLIENT_IP) - .query("id.usage", usage) + .query(name, value) .call() - .unwrap_or_else(|e| panic!("cloud request for id.usage={usage} should succeed: {e}")) + .unwrap_or_else(|e| panic!("cloud request for {name}={value} should succeed: {e}")) .into_string() .expect("cloud response should be readable"); serde_json::from_str(&body).expect("cloud response should be JSON") } +/// A consent management platform sends an IAB TCF consent string and no usage +/// of its own. The service decodes the string, decides the usage from the +/// purposes it grants, and records in the identifier that it did so, which is +/// bit 3 of the flags byte. +/// +/// This is the half a caller cannot state for itself. An identifier whose +/// usage was stated in the request and one whose usage was decoded from a +/// consent string are both legitimate, and they are different assertions +/// about how the permission was obtained, so a receiver has to be able to +/// tell them apart. The service signs the answer, and this proves the two +/// ends agree about which bit it is and which way round it reads. +/// +/// Marked `#[ignore]` for the same reason as the test above. +#[test] +#[ignore = "live cloud test: set 51DEGREES_RESOURCE_KEY and run with `--include-ignored` (see module docs)"] +fn consent_string_sets_the_usage_from_consent_bit() { + let Some(resource_key) = resource_key() else { + panic!( + "no resource key found for the live cloud 51Did test. See the message on resource_key_returns_51did_for_supported_usages for how to set one." + ); + }; + + let mut proven = 0; + for (tc_string, expected) in CONSENT_STRINGS { + // No id.usage is sent. A stated usage wins over a consent string, so + // sending one would leave the bit clear and this would prove the + // opposite of what it says. + let response = request_with(&resource_key, "tcstring", tc_string); + + let Some(fodid) = response.get("fodid") else { + eprintln!( + "consent string granting {expected:?}: no 'fodid' element returned, so this key is not entitled to that marketing usage" + ); + continue; + }; + + for name in ["idprobglobal", "idproblic"] { + if let Some(value) = string_field(fodid, name) { + // A consent string granting a marketing usage produces a + // marketing identifier, so the terms travel with it too. + assert_valid_51did( + &format!("consent/{expected:?}/{name}"), + value, + Some(MODEL_TERMS_FOR_MARKETING_2), + *expected, + true, + ); + proven += 1; + } + } + } + + // Rust has no inconclusive result, so this says plainly what the run did + // rather than leaving a pass to be read as proof. + if proven == 0 { + eprintln!( + "NOTHING PROVEN: this resource key returned no identifier for either consent string, so the usage-from-consent bit was never read. Use a key entitled to the standard or personalized usage." + ); + } else { + eprintln!("Usage-from-consent read on {proven} identifier(s)."); + } +} + /// Asserts that `base64` is a real 51Did: a signed OWID envelope whose payload /// carries the three 51Did fields, including the 32-byte probabilistic hash. -fn assert_valid_51did(label: &str, base64: &str) { +fn assert_valid_51did( + label: &str, + base64: &str, + expected_terms: Option<&str>, + expected_usage: Usage, + from_consent: bool, +) { assert!(!base64.is_empty(), "{label} should not be empty"); let fod_id = FodId::from_base64(base64) @@ -173,6 +281,60 @@ fn assert_valid_51did(label: &str, base64: &str) { "{label}: hash should survive a base64 round trip" ); + // The terms travel with the identifier, so a receiver can read what it + // was created under without asking anyone. A payload that stops at the + // match key reads as no terms, which is why this is the assertion that + // fails where the service has not been updated to write the byte. + assert_eq!( + fod_id.terms(), + expected_terms, + "{label}: expected the terms to be {expected_terms:?} and the \ + identifier carries {:?}. Where this reads None for a marketing \ + usage the service that answered is older than the release that \ + writes the Terms byte.", + fod_id.terms() + ); + assert_eq!( + reparsed.terms(), + expected_terms, + "{label}: terms should survive a base64 round trip" + ); + + // The flags byte, read through the accessors rather than by masking. + // The usage values are cumulative, being 001, 011 and 111, so a caller + // masking the byte for the non-marketing bit reads every marketing + // identifier as non-marketing. These assertions are the alignment + // between what the service wrote and what this crate answers. + assert_eq!( + fod_id.usage(), + expected_usage, + "{label}: the service was asked for a {expected_usage:?} identifier \ + and this reads as {:?}", + fod_id.usage() + ); + assert_eq!( + fod_id.usage_from_consent(), + from_consent, + "{label}: expected the usage to be recorded as {}, and it reads as {}", + if from_consent { + "derived from a consent string" + } else { + "stated by the caller" + }, + if fod_id.usage_from_consent() { + "derived from a consent string" + } else { + "stated by the caller" + } + ); + assert_eq!( + fod_id.id_type(), + IdType::Probabilistic, + "{label}: an idprob* value must be a probabilistic identifier and \ + this reads as {:?}", + fod_id.id_type() + ); + let hash_hex: String = fod_id .match_key() .iter() @@ -252,9 +414,13 @@ fn resource_key_returns_51did_for_supported_usages() { // idprobglobal is the global 51Did for this usage. It is required for // non-marketing and validated when a marketing usage returns it. match string_field(fodid, "idprobglobal") { - Some(idprobglobal) => { - assert_valid_51did(&format!("{}/idprobglobal", usage.name), idprobglobal) - } + Some(idprobglobal) => assert_valid_51did( + &format!("{}/idprobglobal", usage.name), + idprobglobal, + usage.terms, + usage.expected, + false, + ), None if usage.required => { panic!( "id.usage={}: no idprobglobal returned. fodid element: {fodid}", @@ -271,7 +437,13 @@ fn resource_key_returns_51did_for_supported_usages() { // idproblic is scoped to the caller's license and is validated whenever // it is returned. if let Some(idproblic) = string_field(fodid, "idproblic") { - assert_valid_51did(&format!("{}/idproblic", usage.name), idproblic); + assert_valid_51did( + &format!("{}/idproblic", usage.name), + idproblic, + usage.terms, + usage.expected, + false, + ); } } } From 4797aab885314c502f02ef25c6f09baf3319919e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 13 Sep 2026 13:05:18 +0100 Subject: [PATCH 10/11] BUILD: Move fodid to 4.5.4, so merging this actually publishes it crates.io already serves fodid 4.5.3, which is the version this branch carried, and ci/publish-crates.sh skips a crate whose version is already published. Merging as it stood would have published nothing and reported no error, so the terms reader would have reached no consumer. Rust 38 merged that way this morning and is in exactly that state. fodid-cloud moves with it, being unpublished and pinned to fodid by version, so the two stay in step as they were at 4.5.3. Verified: cargo build for both crates is clean, cargo fmt --all --check is silent, and cargo test -p fodid runs 103 tests with 0 failures. --- fodid-cloud/Cargo.toml | 6 +++--- fodid/Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fodid-cloud/Cargo.toml b/fodid-cloud/Cargo.toml index a8634cb..93068ff 100644 --- a/fodid-cloud/Cargo.toml +++ b/fodid-cloud/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fiftyone-fodid-cloud" -version = "4.5.3" +version = "4.5.4" description = "Cloud engine that unpacks the 51Degrees identifier (51Did / FODid) block from the cloud JSON response into typed data." edition.workspace = true rust-version.workspace = true @@ -15,7 +15,7 @@ fiftyone-pipeline-core = { version = "4.5.2", path = "../pipeline-core" } # the cloud path never uses, so this crate builds for wasm32-wasip1. fiftyone-pipeline-engines = { version = "4.5.2", path = "../pipeline-engines", default-features = false } fiftyone-cloud-request-engine = { version = "4.5.2", path = "../cloud-request-engine", default-features = false } -fodid = { version = "4.5.3", path = "../fodid" } +fodid = { version = "4.5.4", path = "../fodid" } serde_json.workspace = true once_cell.workspace = true @@ -29,7 +29,7 @@ reqwest-client = ["fiftyone-cloud-request-engine/reqwest-client"] # The tests create a real signed 51Did envelope to stand in for the cloud, # which needs the OWID creator types fodid exposes under its creator # feature. -fodid = { version = "4.5.3", path = "../fodid", features = ["creator"] } +fodid = { version = "4.5.4", path = "../fodid", features = ["creator"] } [package.metadata.docs.rs] # Build the documentation on docs.rs with every feature enabled, so the diff --git a/fodid/Cargo.toml b/fodid/Cargo.toml index 2a33611..6c2bfdc 100644 --- a/fodid/Cargo.toml +++ b/fodid/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fodid" -version = "4.5.3" +version = "4.5.4" description = "Reader for the 51Did (51Degrees Identifier) value returned by the 51Degrees cloud service. Parses the OWID envelope and unpacks the usage flags, License Id and 32-byte probabilistic hash." keywords = ["51degrees", "fodid", "51did", "identifier", "owid"] categories = ["parser-implementations", "cryptography"] From 086798534b6abdb4cddc5bde0428d48906330bea Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 13 Sep 2026 13:31:45 +0100 Subject: [PATCH 11/11] REVERT: Drop the version bump, the release branch already carries it I bumped fodid to 4.5.4 here this morning, not having looked for an existing release pull request. There is one, 31, opened on 2 September, which moves the same line for the same reason and explains the same mechanism, being that ci/publish-crates.sh skips a version already on crates.io so a merge without a bump publishes nothing. This branch now targets that release branch rather than main, so the bump arrives from the base and duplicating it here would only conflict. The fodid-cloud change goes with it. Pull request 31 reasoned that fodid-cloud needs none, because its dependency reads a bare version which Cargo treats as a caret requirement and so accepts 4.5.4, and that is the author's decision to make rather than mine to override in passing. --- fodid-cloud/Cargo.toml | 6 +++--- fodid/Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fodid-cloud/Cargo.toml b/fodid-cloud/Cargo.toml index 93068ff..a8634cb 100644 --- a/fodid-cloud/Cargo.toml +++ b/fodid-cloud/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fiftyone-fodid-cloud" -version = "4.5.4" +version = "4.5.3" description = "Cloud engine that unpacks the 51Degrees identifier (51Did / FODid) block from the cloud JSON response into typed data." edition.workspace = true rust-version.workspace = true @@ -15,7 +15,7 @@ fiftyone-pipeline-core = { version = "4.5.2", path = "../pipeline-core" } # the cloud path never uses, so this crate builds for wasm32-wasip1. fiftyone-pipeline-engines = { version = "4.5.2", path = "../pipeline-engines", default-features = false } fiftyone-cloud-request-engine = { version = "4.5.2", path = "../cloud-request-engine", default-features = false } -fodid = { version = "4.5.4", path = "../fodid" } +fodid = { version = "4.5.3", path = "../fodid" } serde_json.workspace = true once_cell.workspace = true @@ -29,7 +29,7 @@ reqwest-client = ["fiftyone-cloud-request-engine/reqwest-client"] # The tests create a real signed 51Did envelope to stand in for the cloud, # which needs the OWID creator types fodid exposes under its creator # feature. -fodid = { version = "4.5.4", path = "../fodid", features = ["creator"] } +fodid = { version = "4.5.3", path = "../fodid", features = ["creator"] } [package.metadata.docs.rs] # Build the documentation on docs.rs with every feature enabled, so the diff --git a/fodid/Cargo.toml b/fodid/Cargo.toml index 6c2bfdc..2a33611 100644 --- a/fodid/Cargo.toml +++ b/fodid/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fodid" -version = "4.5.4" +version = "4.5.3" description = "Reader for the 51Did (51Degrees Identifier) value returned by the 51Degrees cloud service. Parses the OWID envelope and unpacks the usage flags, License Id and 32-byte probabilistic hash." keywords = ["51degrees", "fodid", "51did", "identifier", "owid"] categories = ["parser-implementations", "cryptography"]