Skip to main content

hardless_core/
nfc.rs

1//! NFC wire types (PRD section 8.3): NDEF records, card readings and what an NFC reader
2//! reports in [`DeviceEvent::Nfc`](crate::DeviceEvent::Nfc).
3//!
4//! Records follow Web NFC's `NDEFRecordInit` field names, so the shim can hand them to the
5//! app as `NDEFRecord`s without translation. Text payloads travel as `data`, binary ones as
6//! `dataBase64`.
7
8use bytes::Bytes;
9use serde::{Deserialize, Serialize};
10
11/// Most records in one NDEF message. Real tags hold a handful; this only stops runaway input.
12pub const MAX_NDEF_RECORDS: usize = 64;
13
14/// Most payload bytes in one NDEF message. The largest common tags (NTAG216) hold 888 bytes;
15/// this only stops runaway input.
16pub const MAX_NDEF_BYTES: usize = 32 * 1024;
17
18/// One NDEF record, as Web NFC's `NDEFRecordInit`.
19///
20/// ```
21/// use hardless_core::NdefRecord;
22/// let text: NdefRecord =
23///     serde_json::from_str(r#"{ "recordType": "text", "data": "Hello", "lang": "en" }"#).unwrap();
24/// assert_eq!(text.payload(), b"Hello");
25/// let png: NdefRecord = serde_json::from_str(
26///     r#"{ "recordType": "mime", "mediaType": "image/png", "dataBase64": "iVBORw==" }"#,
27/// ).unwrap();
28/// assert_eq!(png.payload(), b"\x89PNG");
29/// assert!(text.validate().is_ok() && png.validate().is_ok());
30/// ```
31#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
32#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct NdefRecord {
35    /// `empty`, `text`, `url`, `absolute-url`, `mime`, `unknown`, or an external type such as
36    /// `example.com:loyalty`.
37    pub record_type: String,
38    /// The MIME type of a `mime` record, e.g. `application/json`.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    #[cfg_attr(feature = "ts", ts(optional))]
41    pub media_type: Option<String>,
42    /// The record identifier.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[cfg_attr(feature = "ts", ts(optional))]
45    pub id: Option<String>,
46    /// The text encoding of a `text` record: `utf-8` (default), `utf-16`, `utf-16be` or
47    /// `utf-16le`.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    #[cfg_attr(feature = "ts", ts(optional))]
50    pub encoding: Option<String>,
51    /// The language of a `text` record, e.g. `en`.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    #[cfg_attr(feature = "ts", ts(optional))]
54    pub lang: Option<String>,
55    /// A text payload: the text of a `text` record, the URL of a `url` record, or any payload
56    /// that is UTF-8 text.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    #[cfg_attr(feature = "ts", ts(optional))]
59    pub data: Option<String>,
60    /// A binary payload, base64. At most one of `data` and `dataBase64` is set.
61    #[serde(
62        default,
63        rename = "dataBase64",
64        skip_serializing_if = "Option::is_none",
65        with = "opt_base64"
66    )]
67    #[cfg_attr(feature = "ts", ts(rename = "dataBase64", optional, type = "string"))]
68    #[schemars(with = "Option<String>")]
69    pub data_base64: Option<Bytes>,
70}
71
72impl NdefRecord {
73    /// A `text` record.
74    ///
75    /// ```
76    /// assert_eq!(hardless_core::NdefRecord::text("Hi").record_type, "text");
77    /// ```
78    pub fn text(text: impl Into<String>) -> Self {
79        Self {
80            record_type: "text".into(),
81            data: Some(text.into()),
82            ..Self::default()
83        }
84    }
85
86    /// A `url` record.
87    pub fn url(url: impl Into<String>) -> Self {
88        Self {
89            record_type: "url".into(),
90            data: Some(url.into()),
91            ..Self::default()
92        }
93    }
94
95    /// The payload bytes: `data` as UTF-8, or the decoded `dataBase64`.
96    pub fn payload(&self) -> Vec<u8> {
97        match (&self.data, &self.data_base64) {
98            (Some(text), _) => text.as_bytes().to_vec(),
99            (None, Some(bytes)) => bytes.to_vec(),
100            (None, None) => Vec::new(),
101        }
102    }
103
104    /// Checks the record the way Web NFC checks an `NDEFRecordInit` before writing it.
105    pub fn validate(&self) -> Result<(), String> {
106        let kind = self.record_type.as_str();
107        if kind.is_empty() || kind.len() > 255 {
108            return Err("recordType must be 1 to 255 characters".into());
109        }
110        if self.data.is_some() && self.data_base64.is_some() {
111            return Err("set either data or dataBase64, not both".into());
112        }
113        let has_data = self.data.is_some() || self.data_base64.is_some();
114        match kind {
115            "empty" if has_data || self.media_type.is_some() => {
116                Err("an empty record has no data or mediaType".into())
117            }
118            "empty" | "mime" | "unknown" => Ok(()),
119            "text" | "url" | "absolute-url" if !has_data => {
120                Err(format!("a {kind} record needs data"))
121            }
122            "text" => match self.encoding.as_deref() {
123                None | Some("utf-8" | "utf-16" | "utf-16be" | "utf-16le") => Ok(()),
124                Some(other) => Err(format!("unsupported text encoding `{other}`")),
125            },
126            "url" | "absolute-url" => Ok(()),
127            "smart-poster" => {
128                Err("smart-poster records (nested messages) are not supported".into())
129            }
130            local if local.starts_with(':') => {
131                Err("local record types are only allowed inside nested messages".into())
132            }
133            external if external.contains(':') => Ok(()),
134            other => Err(format!("unknown recordType `{other}`")),
135        }
136    }
137}
138
139/// Checks a whole NDEF message: every record, the record count and the total size.
140///
141/// ```
142/// use hardless_core::{validate_ndef_message, NdefRecord};
143/// assert!(validate_ndef_message(&[NdefRecord::text("ok")]).is_ok());
144/// assert!(validate_ndef_message(&[NdefRecord::default()]).is_err());
145/// ```
146pub fn validate_ndef_message(records: &[NdefRecord]) -> Result<(), String> {
147    if records.len() > MAX_NDEF_RECORDS {
148        return Err(format!("at most {MAX_NDEF_RECORDS} records"));
149    }
150    let mut total = 0usize;
151    for (i, record) in records.iter().enumerate() {
152        record.validate().map_err(|e| format!("record {i}: {e}"))?;
153        total += record.payload().len();
154    }
155    if total > MAX_NDEF_BYTES {
156        return Err(format!("records hold more than {MAX_NDEF_BYTES} bytes"));
157    }
158    Ok(())
159}
160
161/// Parses an NFC card UID given as hex, with or without `:`, `-` or space separators, and
162/// returns it in Web NFC's `serialNumber` form: lowercase, colon-separated bytes.
163///
164/// Real UIDs are 4, 7 or 10 bytes (ISO 14443) or 8 bytes (ISO 15693); 1 to 10 are accepted.
165///
166/// ```
167/// use hardless_core::serial_number;
168/// assert_eq!(serial_number("04A2B3C4").unwrap(), "04:a2:b3:c4");
169/// assert_eq!(serial_number("04:a2:b3:c4").unwrap(), "04:a2:b3:c4");
170/// assert!(serial_number("4a2").is_err());
171/// ```
172pub fn serial_number(uid: &str) -> Result<String, String> {
173    let hex: String = uid
174        .chars()
175        .filter(|c| !matches!(c, ':' | '-' | ' '))
176        .collect();
177    if hex.is_empty() || !hex.len().is_multiple_of(2) || !hex.chars().all(|c| c.is_ascii_hexdigit())
178    {
179        return Err(format!("uid `{uid}` is not hex bytes, e.g. 04a2b3c4d5e6f7"));
180    }
181    if hex.len() > 20 {
182        return Err(format!("uid `{uid}` is longer than 10 bytes"));
183    }
184    let lower = hex.to_ascii_lowercase();
185    let bytes: Vec<&str> = (0..lower.len())
186        .step_by(2)
187        .filter_map(|i| lower.get(i..i + 2))
188        .collect();
189    Ok(bytes.join(":"))
190}
191
192/// A card read: the `reading` event of `NDEFReader`.
193///
194/// ```
195/// use hardless_core::{NdefRecord, NfcReading};
196/// let reading = NfcReading { serial_number: "04:a2".into(), records: vec![NdefRecord::text("Hi")] };
197/// assert_eq!(
198///     serde_json::to_value(&reading).unwrap(),
199///     serde_json::json!({ "serialNumber": "04:a2", "records": [{ "recordType": "text", "data": "Hi" }] })
200/// );
201/// ```
202#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
204#[serde(rename_all = "camelCase")]
205pub struct NfcReading {
206    /// The card UID, lowercase colon-separated hex (see [`serial_number`]).
207    pub serial_number: String,
208    /// The card's NDEF records.
209    pub records: Vec<NdefRecord>,
210}
211
212/// Why an NFC reader refused a write or a lock.
213#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
215#[serde(rename_all = "kebab-case")]
216pub enum NfcRejection {
217    /// No card is on the reader; the shim waits for the next tap and tries again.
218    NoCard,
219    /// The card is read-only (`NotAllowedError`).
220    ReadOnly,
221    /// The card has data and the write asked not to overwrite it (`NotAllowedError`).
222    NotEmpty,
223}
224
225/// What an NFC reader reports, in [`DeviceEvent::Nfc`](crate::DeviceEvent::Nfc).
226///
227/// ```
228/// use hardless_core::NfcEvent;
229/// let event = NfcEvent::ReadError { serial_number: "04:a2".into() };
230/// assert_eq!(
231///     serde_json::to_value(&event).unwrap(),
232///     serde_json::json!({ "type": "read-error", "serialNumber": "04:a2" })
233/// );
234/// ```
235#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
237#[serde(tag = "type", rename_all = "kebab-case")]
238pub enum NfcEvent {
239    /// A card was tapped and read; the bridge's `nfc-reading` message.
240    Reading(NfcReading),
241    /// A card was tapped but could not be read; the bridge's `nfc-error` message.
242    #[serde(rename_all = "camelCase")]
243    ReadError {
244        /// The card UID.
245        serial_number: String,
246    },
247    /// The app wrote records to the card on the reader.
248    #[serde(rename_all = "camelCase")]
249    Written {
250        /// The card UID.
251        serial_number: String,
252        /// What was written.
253        records: Vec<NdefRecord>,
254    },
255    /// The app made the card on the reader read-only.
256    #[serde(rename_all = "camelCase")]
257    MadeReadOnly {
258        /// The card UID.
259        serial_number: String,
260    },
261    /// A write or lock was refused.
262    Rejected {
263        /// Why.
264        reason: NfcRejection,
265        /// A human-readable message.
266        message: String,
267    },
268}
269
270/// `#[serde(with = "opt_base64")]` for `Option<Bytes>` fields.
271mod opt_base64 {
272    use bytes::Bytes;
273    use serde::{de, Deserialize, Deserializer, Serializer};
274
275    pub(crate) fn serialize<S: Serializer>(data: &Option<Bytes>, s: S) -> Result<S::Ok, S::Error> {
276        match data {
277            Some(bytes) => s.serialize_str(&crate::base64::encode(bytes)),
278            None => s.serialize_none(),
279        }
280    }
281
282    pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Bytes>, D::Error> {
283        let text = Option::<std::borrow::Cow<'de, str>>::deserialize(d)?;
284        text.map(|t| {
285            crate::base64::decode(&t)
286                .map(Bytes::from)
287                .map_err(de::Error::custom)
288        })
289        .transpose()
290    }
291}