1use anyhow::{anyhow, bail, Result};
43use serde_json::{json, Value};
44
45pub const MAX_MESSAGE_LEN: usize = 65_535;
52
53pub const MAX_RECORDS: usize = 64;
58
59const MAX_URI_LEN: usize = 4096;
62
63pub const MAX_TYPE_LEN: usize = u8::MAX as usize;
69
70const 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
78const 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
86const RTD_TEXT: &[u8] = b"T";
88const RTD_URI: &[u8] = b"U";
89
90const MAX_LANGUAGE_LEN: usize = 0x3F;
93
94const TEXT_UTF16_FLAG: u8 = 0x80;
96
97const 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
137fn 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
153fn 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
166fn 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
187pub 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
227fn 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
236fn 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 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 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
335fn 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
355fn 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
378fn 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 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
425pub 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
477fn 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 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
530fn 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
541fn 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
636fn 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 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
678fn 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
688fn 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}