Skip to main content

netget/client/nfc/
ndef.rs

1//! NDEF (NFC Data Exchange Format) message encoding and decoding.
2//!
3//! `write_ndef` advertises a `records` array of typed records — `{"type": "text", …}`,
4//! `{"type": "uri", …}` — because the root `CLAUDE.md` forbids handing a model raw bytes.
5//! Something has to turn those into NDEF wire bytes, and this is it; before it existed
6//! `write_ndef` accepted the records, dropped them, and reported that it needed a field
7//! nothing declared.
8//!
9//! ## Deliberately flat: no nested messages
10//!
11//! NDEF is a **nesting** format — a Smart Poster record's payload is itself an NDEF message,
12//! and so is a Handover Select's — which is the stack-overflow class the root `CLAUDE.md`
13//! describes: a recursive decoder without a depth counter dies on a `SIGSEGV` against the
14//! guard page, which is not a panic, so `catch_unwind` and `spawn_blocking` cannot contain it
15//! and the whole NetGet process goes down. The bytes here come off a tag anyone can hand us.
16//!
17//! So the decoder **never recurses**: it walks the top-level records in a loop and returns a
18//! nested message's payload as hex rather than descending into it. There is no depth counter
19//! because there is no depth. The encoder mirrors that — it can build a record whose payload
20//! is a caller-supplied blob, but it has no "message inside a message" record type, so a
21//! model cannot ask for one either.
22//!
23//! ## Hostile text on both sides
24//!
25//! An NDEF URI record is a phishing primitive: whatever is in it is what a phone offers to
26//! open. So a URI is required to be the ASCII RFC 3986 allows (no control characters, no
27//! whitespace, no non-ASCII — those must be percent-encoded), and text records are refused if
28//! they carry C0/C1 controls or Unicode bidirectional **override** and **isolate** characters,
29//! which reverse how a string renders without changing what it is.
30//!
31//! Encoding refuses; decoding cannot refuse — a tag is allowed to be hostile and the model
32//! needs to be told what it found — so decoding replaces those characters with U+FFFD and
33//! marks the record `unsafe_characters_removed`, keeping `payload_hex` as the authoritative
34//! form.
35//!
36//! ## References
37//!
38//! - NFC Forum NDEF 1.0 — record layout, TNF values, chunking
39//! - NFC Forum RTD Text 1.0 — `T` record: status byte, language code, text
40//! - NFC Forum RTD URI 1.0 — `U` record: one-byte prefix identifier plus the rest
41
42use anyhow::{anyhow, bail, Result};
43use serde_json::{json, Value};
44
45/// Largest NDEF message this client will write.
46///
47/// A Type 4 tag publishes the message length in the two-byte `NLEN` header, so 65535 is the
48/// hard ceiling of the format, not a policy choice. Range-checked *before* the `as u16` that
49/// writes `NLEN`: a 70 000-byte message silently becoming `NLEN = 4464` would leave the tag
50/// holding a body no reader could interpret.
51pub const MAX_MESSAGE_LEN: usize = 65_535;
52
53/// Most records one message may carry, in either direction.
54///
55/// A decode bound rather than a policy: the smallest possible record is three bytes, so an
56/// unbounded walk over a 64 KiB file could produce ~21 000 JSON objects to put in one event.
57pub const MAX_RECORDS: usize = 64;
58
59/// Longest URI accepted in a `uri` record. Well past any real one; the point is that the
60/// length is bounded before it is encoded.
61const MAX_URI_LEN: usize = 4096;
62
63/// Longest TYPE field a record may carry (NDEF 1.0 §3.2.2: TYPE LENGTH is one octet).
64///
65/// A format limit, not a policy. Every type this encoder produces is a one-byte RTD, a media
66/// type or a `domain:type`, all far shorter — but `mime_type` and `domain_type` come from the
67/// model, so "far shorter" is an assumption and this is the check.
68pub const MAX_TYPE_LEN: usize = u8::MAX as usize;
69
70// Type Name Format values (NDEF 1.0 §3.2.6).
71const TNF_EMPTY: u8 = 0x00;
72const TNF_WELL_KNOWN: u8 = 0x01;
73const TNF_MIME: u8 = 0x02;
74const TNF_ABSOLUTE_URI: u8 = 0x03;
75const TNF_EXTERNAL: u8 = 0x04;
76const TNF_UNKNOWN: u8 = 0x05;
77
78// Record header flag bits (NDEF 1.0 §3.2).
79const FLAG_MB: u8 = 0x80;
80const FLAG_ME: u8 = 0x40;
81const FLAG_CF: u8 = 0x20;
82const FLAG_SR: u8 = 0x10;
83const FLAG_IL: u8 = 0x08;
84const MASK_TNF: u8 = 0x07;
85
86/// The `T` (Text) and `U` (URI) well-known record types.
87const RTD_TEXT: &[u8] = b"T";
88const RTD_URI: &[u8] = b"U";
89
90/// The status byte of a Text record carries the language length in its low six bits, so a
91/// language code longer than this cannot be expressed. Checked before the cast, not after.
92const MAX_LANGUAGE_LEN: usize = 0x3F;
93
94/// Bit 7 of a Text record's status byte: set means the text is UTF-16, clear means UTF-8.
95const TEXT_UTF16_FLAG: u8 = 0x80;
96
97/// URI prefix identifier codes, NFC Forum RTD URI 1.0 Table 3. Index is the code byte.
98const URI_PREFIXES: [&str; 36] = [
99    "",
100    "http://www.",
101    "https://www.",
102    "http://",
103    "https://",
104    "tel:",
105    "mailto:",
106    "ftp://anonymous:anonymous@",
107    "ftp://ftp.",
108    "ftps://",
109    "sftp://",
110    "smb://",
111    "nfs://",
112    "ftp://",
113    "dav://",
114    "news:",
115    "telnet://",
116    "imap:",
117    "rtsp://",
118    "urn:",
119    "pop:",
120    "sip:",
121    "sips:",
122    "tftp:",
123    "btspp://",
124    "btl2cap://",
125    "btgoep://",
126    "tcpobex://",
127    "irdaobex://",
128    "file://",
129    "urn:epc:id:",
130    "urn:epc:tag:",
131    "urn:epc:pat:",
132    "urn:epc:raw:",
133    "urn:epc:",
134    "urn:nfc:",
135];
136
137/// True for a character that must never reach a text record.
138///
139/// C0 controls other than tab/newline/carriage return, DEL and the C1 block, and the Unicode
140/// bidirectional **overrides** and **isolates** (U+202A–U+202E, U+2066–U+2069). The plain
141/// marks U+200E/U+200F are *not* here: they are ordinary in right-to-left text, while an
142/// override exists to make a string render as something other than what it is.
143fn is_unsafe_char(c: char) -> bool {
144    matches!(c,
145        '\u{0000}'..='\u{0008}'
146        | '\u{000B}' | '\u{000C}'
147        | '\u{000E}'..='\u{001F}'
148        | '\u{007F}'..='\u{009F}'
149        | '\u{202A}'..='\u{202E}'
150        | '\u{2066}'..='\u{2069}')
151}
152
153/// Refuse a model-supplied string carrying anything from [`is_unsafe_char`].
154fn check_text(field: &str, value: &str) -> Result<()> {
155    if let Some(c) = value.chars().find(|&c| is_unsafe_char(c)) {
156        bail!(
157            "'{field}' contains U+{:04X}, a control or bidirectional-override character that \
158             must not go into an NDEF record: it changes how the record renders on a phone \
159             without changing what it says",
160            c as u32
161        );
162    }
163    Ok(())
164}
165
166/// Replace anything [`is_unsafe_char`] rejects with U+FFFD.
167///
168/// The decode-side counterpart of [`check_text`]. Returns the cleaned string and whether
169/// anything was removed, because the model must be told rather than quietly shown a shorter
170/// string than the tag held.
171fn scrub_text(value: &str) -> (String, bool) {
172    let mut removed = false;
173    let cleaned = value
174        .chars()
175        .map(|c| {
176            if is_unsafe_char(c) {
177                removed = true;
178                '\u{FFFD}'
179            } else {
180                c
181            }
182        })
183        .collect();
184    (cleaned, removed)
185}
186
187/// Encode a message's worth of typed records into NDEF wire bytes.
188///
189/// Fails rather than truncating, everywhere: an over-long message, an over-long language
190/// code, a URI that is not the ASCII RFC 3986 permits, an unknown record type. A tag written
191/// with a body that does not match its own `NLEN` is worse than a tag not written at all.
192pub fn encode_message(records: &[Value]) -> Result<Vec<u8>> {
193    if records.is_empty() {
194        bail!("an NDEF message needs at least one record");
195    }
196    if records.len() > MAX_RECORDS {
197        bail!(
198            "an NDEF message may carry at most {} records, got {}",
199            MAX_RECORDS,
200            records.len()
201        );
202    }
203
204    let mut out = Vec::new();
205    for (i, record) in records.iter().enumerate() {
206        let (tnf, type_field, payload) = encode_one(record)
207            .map_err(|e| anyhow!("NDEF record {} ({}): {e}", i + 1, describe(record)))?;
208        push_record(
209            &mut out,
210            tnf,
211            &type_field,
212            &payload,
213            i == 0,
214            i + 1 == records.len(),
215        )
216        .map_err(|e| anyhow!("NDEF record {} ({}): {e}", i + 1, describe(record)))?;
217        if out.len() > MAX_MESSAGE_LEN {
218            bail!(
219                "NDEF message exceeds the {} bytes a Type 4 tag's two-byte NLEN can describe",
220                MAX_MESSAGE_LEN
221            );
222        }
223    }
224    Ok(out)
225}
226
227/// A short description of a record for an error message, without dumping its content.
228fn describe(record: &Value) -> String {
229    record
230        .get("type")
231        .and_then(|v| v.as_str())
232        .unwrap_or("no 'type'")
233        .to_string()
234}
235
236/// Build one record's TNF, TYPE field and payload from its JSON form.
237fn encode_one(record: &Value) -> Result<(u8, Vec<u8>, Vec<u8>)> {
238    let kind = record
239        .get("type")
240        .and_then(|v| v.as_str())
241        .ok_or_else(|| anyhow!("a record needs a 'type' ('text', 'uri', 'mime' or 'external')"))?;
242
243    match kind {
244        "text" => {
245            let text = record
246                .get("text")
247                .and_then(|v| v.as_str())
248                .ok_or_else(|| anyhow!("a text record needs 'text'"))?;
249            check_text("text", text)?;
250
251            let language = record
252                .get("language")
253                .and_then(|v| v.as_str())
254                .unwrap_or("en");
255            if language.is_empty() || language.len() > MAX_LANGUAGE_LEN {
256                bail!(
257                    "'language' must be 1..={} bytes (the Text status byte carries its length \
258                     in six bits), got {}",
259                    MAX_LANGUAGE_LEN,
260                    language.len()
261                );
262            }
263            if !language
264                .bytes()
265                .all(|b| b.is_ascii_alphanumeric() || b == b'-')
266            {
267                bail!("'language' must be an IANA language tag such as 'en' or 'en-GB'");
268            }
269
270            // Range-checked above, so the cast cannot lose a bit; the status byte is
271            // `UTF-16 flag | RFU | six bits of length`, and we always write UTF-8.
272            let mut payload = vec![language.len() as u8];
273            payload.extend_from_slice(language.as_bytes());
274            payload.extend_from_slice(text.as_bytes());
275            Ok((TNF_WELL_KNOWN, RTD_TEXT.to_vec(), payload))
276        }
277        "uri" => {
278            let uri = record
279                .get("uri")
280                .and_then(|v| v.as_str())
281                .ok_or_else(|| anyhow!("a uri record needs 'uri'"))?;
282            check_uri(uri)?;
283
284            // Longest matching prefix wins, so "https://www.x" abbreviates to code 2 rather
285            // than code 4 plus a literal "www.".
286            let (code, rest) = URI_PREFIXES
287                .iter()
288                .enumerate()
289                .skip(1)
290                .filter(|(_, prefix)| uri.starts_with(*prefix))
291                .max_by_key(|(_, prefix)| prefix.len())
292                .map(|(code, prefix)| (code as u8, &uri[prefix.len()..]))
293                .unwrap_or((0, uri));
294
295            let mut payload = vec![code];
296            payload.extend_from_slice(rest.as_bytes());
297            Ok((TNF_WELL_KNOWN, RTD_URI.to_vec(), payload))
298        }
299        "mime" => {
300            let mime_type = record
301                .get("mime_type")
302                .and_then(|v| v.as_str())
303                .ok_or_else(|| anyhow!("a mime record needs 'mime_type', e.g. 'text/plain'"))?;
304            check_text("mime_type", mime_type)?;
305            if !mime_type.is_ascii() || mime_type.is_empty() {
306                bail!("'mime_type' must be a non-empty ASCII media type");
307            }
308            Ok((TNF_MIME, mime_type.as_bytes().to_vec(), payload_of(record)?))
309        }
310        "external" => {
311            let domain_type = record
312                .get("domain_type")
313                .and_then(|v| v.as_str())
314                .ok_or_else(|| {
315                    anyhow!("an external record needs 'domain_type', e.g. 'example.com:mytype'")
316                })?;
317            check_text("domain_type", domain_type)?;
318            if !domain_type.contains(':') {
319                bail!("'domain_type' must be 'domain:type', e.g. 'example.com:mytype'");
320            }
321            Ok((
322                TNF_EXTERNAL,
323                domain_type.as_bytes().to_vec(),
324                payload_of(record)?,
325            ))
326        }
327        other => bail!(
328            "unknown NDEF record type '{other}'; this client writes 'text', 'uri', 'mime' and \
329             'external'. Nested messages (smart poster, handover) are deliberately not \
330             supported - see the module docs"
331        ),
332    }
333}
334
335/// The payload of a `mime` or `external` record: text or hex, never both.
336///
337/// The same rule as the server's `respond_to_apdu` and `send_tcp_data`: "48656C6C6F" is
338/// simultaneously valid text and valid hex and only the sender knows which it meant, so the
339/// encoding is declared rather than sniffed.
340fn payload_of(record: &Value) -> Result<Vec<u8>> {
341    let text = record.get("payload_text").and_then(|v| v.as_str());
342    let hex_str = record.get("payload_hex").and_then(|v| v.as_str());
343    match (text, hex_str) {
344        (Some(text), None) => {
345            check_text("payload_text", text)?;
346            Ok(text.as_bytes().to_vec())
347        }
348        (None, Some(hex_str)) => hex::decode(hex_str.trim())
349            .map_err(|e| anyhow!("'payload_hex' is not valid hexadecimal: {e}")),
350        (None, None) => Ok(Vec::new()),
351        (Some(_), Some(_)) => bail!("a record takes 'payload_text' or 'payload_hex', not both"),
352    }
353}
354
355/// Reject a URI that is not what RFC 3986 allows on the wire.
356///
357/// A URI is US-ASCII with no whitespace; everything else is percent-encoded. Requiring that
358/// is what makes a control character, a newline or a bidirectional override impossible here
359/// rather than merely discouraged — none of them is in `0x21..=0x7E`.
360fn check_uri(uri: &str) -> Result<()> {
361    if uri.is_empty() {
362        bail!("'uri' must not be empty");
363    }
364    if uri.len() > MAX_URI_LEN {
365        bail!("'uri' is {} bytes, over the {MAX_URI_LEN} limit", uri.len());
366    }
367    if let Some(b) = uri.bytes().find(|b| !(0x21..=0x7E).contains(b)) {
368        bail!(
369            "'uri' contains byte 0x{b:02X}, which RFC 3986 does not allow unencoded: a URI is \
370             printable US-ASCII with no whitespace, and percent-encoding is how anything else \
371             travels. An NDEF URI record is what a phone offers to open, so this is refused \
372             rather than escaped"
373        );
374    }
375    Ok(())
376}
377
378/// Append one encoded record, choosing the short or long payload-length form.
379///
380/// Refuses a TYPE field past [`MAX_TYPE_LEN`] rather than narrowing the TYPE LENGTH octet to
381/// fit. The narrowing form wrote the whole field under a length of 255, so the message did not
382/// describe itself: a decoder reads 255 type octets and then misattributes every remaining
383/// byte of the record — and of every record after it — to the payload. The rest of this codec
384/// refuses rather than truncating, and this is the one place that did not.
385fn push_record(
386    out: &mut Vec<u8>,
387    tnf: u8,
388    type_field: &[u8],
389    payload: &[u8],
390    first: bool,
391    last: bool,
392) -> Result<()> {
393    if type_field.len() > MAX_TYPE_LEN {
394        bail!(
395            "the record's TYPE field is {} bytes; NDEF's TYPE LENGTH is one octet, so {} is \
396             the most it can describe",
397            type_field.len(),
398            MAX_TYPE_LEN
399        );
400    }
401    let short = payload.len() <= u8::MAX as usize;
402    let mut header = tnf & MASK_TNF;
403    if first {
404        header |= FLAG_MB;
405    }
406    if last {
407        header |= FLAG_ME;
408    }
409    if short {
410        header |= FLAG_SR;
411    }
412    out.push(header);
413    // Range-checked above, so the cast cannot lose a bit.
414    out.push(type_field.len() as u8);
415    if short {
416        out.push(payload.len() as u8);
417    } else {
418        out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
419    }
420    out.extend_from_slice(type_field);
421    out.extend_from_slice(payload);
422    Ok(())
423}
424
425/// Walk an NDEF message into typed JSON records.
426///
427/// **Iterative, and never descends into a payload** — see the module docs. A payload that is
428/// itself an NDEF message comes back as hex, which is the whole reason this cannot be
429/// stack-overflowed by a tag.
430///
431/// Returns what it managed to read: a truncated or malformed tail becomes a final
432/// `{"type": "undecodable", …}` record rather than an error, because a partly-readable tag is
433/// still worth telling the model about.
434pub fn decode_message(bytes: &[u8]) -> Result<Vec<Value>> {
435    if bytes.is_empty() {
436        return Ok(Vec::new());
437    }
438    let mut records = Vec::new();
439    let mut at = 0usize;
440
441    while at < bytes.len() {
442        if records.len() >= MAX_RECORDS {
443            records.push(json!({
444                "type": "undecodable",
445                "reason": format!("stopped after {MAX_RECORDS} records"),
446                "remaining_bytes": bytes.len() - at,
447            }));
448            break;
449        }
450        match decode_one(bytes, at) {
451            Ok((record, next)) => {
452                let last = record
453                    .get("message_end")
454                    .and_then(|v| v.as_bool())
455                    .unwrap_or(false);
456                records.push(record);
457                at = next;
458                if last {
459                    break;
460                }
461            }
462            Err(e) => {
463                records.push(json!({
464                    "type": "undecodable",
465                    "reason": e.to_string(),
466                    "offset": at,
467                    "remaining_hex": hex::encode_upper(&bytes[at..]),
468                }));
469                break;
470            }
471        }
472    }
473
474    Ok(records)
475}
476
477/// Decode the record starting at `at`, returning it and the offset of the next one.
478fn decode_one(bytes: &[u8], at: usize) -> Result<(Value, usize)> {
479    let header = *bytes
480        .get(at)
481        .ok_or_else(|| anyhow!("record header past the end of the message"))?;
482    let tnf = header & MASK_TNF;
483    let short = header & FLAG_SR != 0;
484    let has_id = header & FLAG_IL != 0;
485    let chunked = header & FLAG_CF != 0;
486    let message_end = header & FLAG_ME != 0;
487
488    let mut at = at + 1;
489    let type_len = *take(bytes, at, 1)?.first().unwrap() as usize;
490    at += 1;
491
492    // Widening casts only: a u8 or a u32 read from the wire becomes a usize, which cannot
493    // lose a bit. The bounds check below is what keeps the value honest.
494    let payload_len = if short {
495        at += 1;
496        *take(bytes, at - 1, 1)?.first().unwrap() as usize
497    } else {
498        let raw = take(bytes, at, 4)?;
499        at += 4;
500        u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as usize
501    };
502
503    let id_len = if has_id {
504        at += 1;
505        *take(bytes, at - 1, 1)?.first().unwrap() as usize
506    } else {
507        0
508    };
509
510    let type_field = take(bytes, at, type_len)?.to_vec();
511    at += type_len;
512    let id_field = take(bytes, at, id_len)?.to_vec();
513    at += id_len;
514    let payload = take(bytes, at, payload_len)?.to_vec();
515    at += payload_len;
516
517    let mut record = decode_payload(tnf, &type_field, &payload, chunked);
518    if let Some(map) = record.as_object_mut() {
519        map.insert("message_end".into(), json!(message_end));
520        if chunked {
521            map.insert("chunked".into(), json!(true));
522        }
523        if has_id {
524            map.insert("record_id_hex".into(), json!(hex::encode_upper(&id_field)));
525        }
526    }
527    Ok((record, at))
528}
529
530/// Bounds-checked slice. Every field of every record goes through this, so a length the tag
531/// made up is an `Err` rather than a panic in a connection task.
532fn take(bytes: &[u8], at: usize, len: usize) -> Result<&[u8]> {
533    bytes.get(at..at.saturating_add(len)).ok_or_else(|| {
534        anyhow!(
535            "record claims {len} bytes at offset {at}, past the {} it has",
536            bytes.len()
537        )
538    })
539}
540
541/// Turn one decoded record's TNF, TYPE and payload into JSON.
542///
543/// A **chunked** record is reported rather than reassembled: the payload of the first chunk
544/// alone is not the record's content, so decoding it as text would show the model a fragment
545/// as though it were whole.
546fn decode_payload(tnf: u8, type_field: &[u8], payload: &[u8], chunked: bool) -> Value {
547    let payload_hex = hex::encode_upper(payload);
548
549    if chunked {
550        return json!({
551            "type": "chunked",
552            "note": "this record is a chunk; NetGet does not reassemble chunked records, so \
553                     the payload below is a fragment",
554            "tnf": tnf,
555            "record_type_hex": hex::encode_upper(type_field),
556            "payload_hex": payload_hex,
557        });
558    }
559
560    match (tnf, type_field) {
561        (TNF_WELL_KNOWN, RTD_TEXT) => match decode_text(payload) {
562            Some((language, text, encoding)) => {
563                let (text, removed) = scrub_text(&text);
564                let mut value = json!({
565                    "type": "text",
566                    "language": language,
567                    "text": text,
568                    "text_encoding": encoding,
569                    "payload_hex": payload_hex,
570                });
571                if removed {
572                    value["unsafe_characters_removed"] = json!(true);
573                }
574                value
575            }
576            None => json!({
577                "type": "undecodable",
578                "reason": "a Text record whose status byte does not match its payload length",
579                "payload_hex": payload_hex,
580            }),
581        },
582        (TNF_WELL_KNOWN, RTD_URI) => match decode_uri(payload) {
583            Some(uri) => {
584                let (uri, removed) = scrub_text(&uri);
585                let mut value = json!({
586                    "type": "uri",
587                    "uri": uri,
588                    "payload_hex": payload_hex,
589                });
590                if removed {
591                    value["unsafe_characters_removed"] = json!(true);
592                }
593                value
594            }
595            None => json!({
596                "type": "undecodable",
597                "reason": "an empty URI record",
598                "payload_hex": payload_hex,
599            }),
600        },
601        (TNF_MIME, _) => json!({
602            "type": "mime",
603            "mime_type": String::from_utf8_lossy(type_field),
604            "payload_hex": payload_hex,
605            "payload_text": printable(payload),
606        }),
607        (TNF_ABSOLUTE_URI, _) => {
608            let (uri, removed) = scrub_text(&String::from_utf8_lossy(type_field));
609            json!({
610                "type": "absolute_uri",
611                "uri": uri,
612                "unsafe_characters_removed": removed,
613                "payload_hex": payload_hex,
614            })
615        }
616        (TNF_EXTERNAL, _) => json!({
617            "type": "external",
618            "domain_type": String::from_utf8_lossy(type_field),
619            "payload_hex": payload_hex,
620            "payload_text": printable(payload),
621        }),
622        (TNF_EMPTY, _) => json!({ "type": "empty" }),
623        (TNF_UNKNOWN, _) => json!({
624            "type": "unknown",
625            "payload_hex": payload_hex,
626        }),
627        _ => json!({
628            "type": "other",
629            "tnf": tnf,
630            "record_type_hex": hex::encode_upper(type_field),
631            "payload_hex": payload_hex,
632        }),
633    }
634}
635
636/// Split a Text record's payload into (language, text, encoding name).
637fn decode_text(payload: &[u8]) -> Option<(String, String, &'static str)> {
638    let status = *payload.first()?;
639    let lang_len = (status & 0x3F) as usize;
640    let language = payload.get(1..1 + lang_len)?;
641    let text = payload.get(1 + lang_len..)?;
642
643    if status & TEXT_UTF16_FLAG == 0 {
644        return Some((
645            String::from_utf8_lossy(language).to_string(),
646            String::from_utf8_lossy(text).to_string(),
647            "utf-8",
648        ));
649    }
650
651    // UTF-16, big-endian unless a BOM says otherwise (RTD Text 1.0 §3.2.1). A trailing odd
652    // byte is dropped rather than being an error: a tag is allowed to be wrong, and half a
653    // code unit is nothing the model needs to see.
654    let (rest, little_endian, encoding): (&[u8], bool, &'static str) = match text {
655        [0xFF, 0xFE, rest @ ..] => (rest, true, "utf-16le"),
656        [0xFE, 0xFF, rest @ ..] => (rest, false, "utf-16be"),
657        rest => (rest, false, "utf-16be"),
658    };
659    let units: Vec<u16> = rest
660        .as_chunks::<2>()
661        .0
662        .iter()
663        .map(|c| {
664            if little_endian {
665                u16::from_le_bytes(*c)
666            } else {
667                u16::from_be_bytes(*c)
668            }
669        })
670        .collect();
671    Some((
672        String::from_utf8_lossy(language).to_string(),
673        String::from_utf16_lossy(&units),
674        encoding,
675    ))
676}
677
678/// Expand a URI record's payload: one prefix-identifier byte plus the rest.
679fn decode_uri(payload: &[u8]) -> Option<String> {
680    let code = *payload.first()? as usize;
681    let prefix = URI_PREFIXES.get(code).copied().unwrap_or("");
682    Some(format!(
683        "{prefix}{}",
684        String::from_utf8_lossy(payload.get(1..).unwrap_or_default())
685    ))
686}
687
688/// A text view of an opaque payload, but only when every byte is printable ASCII — the same
689/// rule the NFC server uses for an APDU data field.
690fn printable(payload: &[u8]) -> Value {
691    if !payload.is_empty()
692        && payload
693            .iter()
694            .all(|&b| b.is_ascii_graphic() || b == b' ' || b == b'\t')
695    {
696        json!(String::from_utf8_lossy(payload))
697    } else {
698        Value::Null
699    }
700}