Skip to main content

fakecloud_iot/
service.rs

1//! AWS IoT Core (`iot`) restJson1 dispatch.
2//!
3//! Requests are routed to one of the 272 modelled operations by HTTP method +
4//! `@http` URI template (the route table in [`crate::generated`]). Path labels
5//! are captured positionally (percent-decoded). Input is validated against the
6//! model-derived constraints ([`crate::validate`]), then handled either by the
7//! generic resource engine ([`engine`], for the create / describe / list /
8//! update / delete verb of every named resource family) or by a resource-
9//! specific handler ([`special`], for certificate minting, endpoints,
10//! singleton configurations, tagging, and relationship operations). State is
11//! account-partitioned and persisted.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use http::{Method, StatusCode};
18use percent_encoding::percent_decode_str;
19use serde_json::{Map, Value};
20use tokio::sync::Mutex as AsyncMutex;
21
22use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
23use fakecloud_persistence::SnapshotStore;
24
25use crate::generated::{OpMeta, Seg, Verb, K, OPS};
26use crate::persistence::save_snapshot;
27use crate::state::SharedIotState;
28use crate::validate::{self, Inputs};
29
30mod engine;
31mod special;
32#[cfg(test)]
33mod tests;
34
35/// Every operation name in the AWS IoT Core Smithy model (272).
36pub use crate::generated::ACTIONS as IOT_ACTIONS;
37
38pub struct IotService {
39    state: SharedIotState,
40    snapshot_store: Option<Arc<dyn SnapshotStore>>,
41    snapshot_lock: Arc<AsyncMutex<()>>,
42}
43
44impl IotService {
45    pub fn new(state: SharedIotState) -> Self {
46        Self {
47            state,
48            snapshot_store: None,
49            snapshot_lock: Arc::new(AsyncMutex::new(())),
50        }
51    }
52
53    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
54        self.snapshot_store = Some(store);
55        self
56    }
57
58    async fn save(&self) {
59        save_snapshot(
60            &self.state,
61            self.snapshot_store.clone(),
62            &self.snapshot_lock,
63        )
64        .await;
65    }
66
67    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
68    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
69        let store = self.snapshot_store.clone()?;
70        let state = self.state.clone();
71        let lock = self.snapshot_lock.clone();
72        Some(Arc::new(move || {
73            let state = state.clone();
74            let store = store.clone();
75            let lock = lock.clone();
76            Box::pin(async move {
77                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
78            })
79        }))
80    }
81}
82
83#[async_trait]
84impl AwsService for IotService {
85    fn service_name(&self) -> &str {
86        "iot"
87    }
88
89    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
90        let Some((meta, labels)) = match_route(&req.method, &req.raw_path) else {
91            return Err(AwsServiceError::aws_error(
92                StatusCode::NOT_FOUND,
93                "ResourceNotFoundException",
94                format!("Unknown operation: {} {}", req.method, req.raw_path),
95            ));
96        };
97        let result = self.dispatch(meta, &labels, &req);
98        match result {
99            Ok((resp, mutated)) => {
100                if mutated && resp.status.is_success() {
101                    self.save().await;
102                }
103                Ok(resp)
104            }
105            Err(e) => Err(e),
106        }
107    }
108
109    fn supported_actions(&self) -> &[&str] {
110        IOT_ACTIONS
111    }
112}
113
114/// Per-request context.
115pub(crate) struct Ctx {
116    pub account: String,
117    pub region: String,
118}
119
120impl IotService {
121    fn dispatch(
122        &self,
123        meta: &'static OpMeta,
124        labels: &HashMap<String, String>,
125        req: &AwsRequest,
126    ) -> Result<(AwsResponse, bool), AwsServiceError> {
127        // A missing required label arrives as the literal `{Name}` placeholder.
128        for v in labels.values() {
129            if v.is_empty() || (v.starts_with('{') && v.ends_with('}')) {
130                return Err(validate::invalid(
131                    meta,
132                    "The request is missing a required path parameter.",
133                ));
134            }
135        }
136        let ctx = Ctx {
137            account: req.account_id.clone(),
138            region: if req.region.is_empty() {
139                "us-east-1".to_string()
140            } else {
141                req.region.clone()
142            },
143        };
144        let query = parse_query(&req.raw_query);
145        let body = parse_body(&req.body);
146        let inputs = Inputs {
147            labels,
148            query: &query,
149            headers: &req.headers,
150            body: &body,
151        };
152        validate::validate(meta, &inputs)?;
153
154        // Resource-specific handlers take precedence over the generic engine.
155        if let Some(res) = special::dispatch(self, meta, &ctx, labels, &query, &req.headers, &body)?
156        {
157            return Ok(res);
158        }
159
160        // Generic resource engine by verb.
161        match meta.verb {
162            Verb::Create => {
163                let mut g = self.state.write();
164                let data = g.get_or_create(&ctx.account);
165                Ok((
166                    engine::create(data, &ctx, meta, labels, &query, &body)?,
167                    true,
168                ))
169            }
170            Verb::Update => {
171                let mut g = self.state.write();
172                let data = g.get_or_create(&ctx.account);
173                Ok((engine::update(data, meta, labels, &body)?, true))
174            }
175            Verb::Delete => {
176                let mut g = self.state.write();
177                let data = g.get_or_create(&ctx.account);
178                Ok((engine::delete(data, meta, labels), true))
179            }
180            Verb::Get => {
181                let g = self.state.read();
182                let data = g.get(&ctx.account);
183                Ok((engine::get(data, meta, labels)?, false))
184            }
185            Verb::List => {
186                let g = self.state.read();
187                let data = g.get(&ctx.account);
188                Ok((engine::list(data, meta, &query), false))
189            }
190            Verb::Action => {
191                // Read-only actions not claimed by `special` return an empty
192                // (shape-valid) body. An unclaimed *mutating* action must fail
193                // loud rather than silently accept-and-discard, so future model
194                // gaps surface instead of appearing to persist.
195                if is_read_only_action(meta) {
196                    Ok((ok_json(Value::Object(Map::new())), false))
197                } else {
198                    Err(AwsServiceError::action_not_implemented("iot", meta.op))
199                }
200            }
201        }
202    }
203}
204
205// ===================== routing =====================
206
207/// Match a request `(method, path)` against the generated route table,
208/// returning the operation metadata and captured labels (member name -> value).
209/// Ties (a fixed segment vs a label segment at the same position) are broken in
210/// favour of the route with more fixed segments — the more specific route.
211pub(crate) fn match_route(
212    method: &Method,
213    raw_path: &str,
214) -> Option<(&'static OpMeta, HashMap<String, String>)> {
215    let raw = raw_path.split('?').next().unwrap_or(raw_path);
216    let trimmed = raw.strip_prefix('/').unwrap_or(raw);
217    let segs: Vec<String> = if trimmed.is_empty() {
218        Vec::new()
219    } else {
220        trimmed
221            .split('/')
222            .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
223            .collect()
224    };
225    let method_str = method.as_str();
226
227    let mut best: Option<(&'static OpMeta, HashMap<String, String>, usize)> = None;
228    for meta in OPS {
229        if meta.method != method_str {
230            continue;
231        }
232        if let Some((labels, fixed)) = match_segs(meta.segs, &segs) {
233            if best.as_ref().map(|(_, _, f)| fixed > *f).unwrap_or(true) {
234                best = Some((meta, labels, fixed));
235            }
236        }
237    }
238    best.map(|(m, l, _)| (m, l))
239}
240
241/// Try to match a route's segment pattern against the request segments,
242/// returning captured labels and the fixed-segment count on success.
243fn match_segs(pattern: &[Seg], segs: &[String]) -> Option<(HashMap<String, String>, usize)> {
244    let has_greedy = matches!(pattern.last(), Some(Seg::Greedy(_)));
245    if has_greedy {
246        if segs.len() < pattern.len() {
247            return None;
248        }
249    } else if segs.len() != pattern.len() {
250        return None;
251    }
252
253    let mut labels = HashMap::new();
254    let mut fixed = 0usize;
255    let mut i = 0usize;
256    for (p, seg) in pattern.iter().enumerate() {
257        match seg {
258            Seg::Fixed(f) => {
259                if segs.get(i)?.as_str() != *f {
260                    return None;
261                }
262                fixed += 1;
263                i += 1;
264            }
265            Seg::Label(name) => {
266                labels.insert((*name).to_string(), segs.get(i)?.clone());
267                i += 1;
268            }
269            Seg::Greedy(name) => {
270                // Greedy is always the last segment; capture the rest.
271                debug_assert_eq!(p, pattern.len() - 1);
272                let rest = segs[i..].join("/");
273                labels.insert((*name).to_string(), rest);
274                i = segs.len();
275            }
276        }
277    }
278    if i != segs.len() {
279        return None;
280    }
281    Some((labels, fixed))
282}
283
284// ===================== shared helpers =====================
285
286pub(crate) fn ok_json(v: Value) -> AwsResponse {
287    AwsResponse::json_value(StatusCode::OK, v)
288}
289
290/// Whether an unclaimed `Verb::Action` operation is safe to accept as an empty
291/// no-op. Read operations (HTTP GET, or a `Get`/`List`/`Describe`/`Test`/
292/// `Validate` name — e.g. the index-analytics and authorizer-test actions) have
293/// no persisted side effect, so an empty body is shape-valid; anything else
294/// mutates and must fail loud instead of silently discarding the request.
295pub(crate) fn is_read_only_action(meta: &OpMeta) -> bool {
296    meta.method == "GET"
297        || ["Get", "List", "Describe", "Search", "Test", "Validate"]
298            .iter()
299            .any(|p| meta.op.starts_with(p))
300}
301
302pub(crate) fn parse_query(raw: &str) -> Vec<(String, String)> {
303    raw.split('&')
304        .filter(|p| !p.is_empty())
305        .map(|pair| {
306            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
307            (
308                percent_decode_str(k).decode_utf8_lossy().into_owned(),
309                percent_decode_str(v).decode_utf8_lossy().into_owned(),
310            )
311        })
312        .collect()
313}
314
315pub(crate) fn parse_body(body: &[u8]) -> Map<String, Value> {
316    if body.is_empty() {
317        return Map::new();
318    }
319    match serde_json::from_slice::<Value>(body) {
320        Ok(Value::Object(m)) => m,
321        _ => Map::new(),
322    }
323}
324
325/// Current time as a restJson1 timestamp: Unix epoch **seconds** encoded as a
326/// JSON number, with fractional milliseconds preserved (e.g. `1752324947.041`).
327/// This is the default `timestamp` wire format for the `restJson1` protocol
328/// (which AWS IoT Core uses), and is what the aws-sdk / aws-smithy timestamp
329/// deserializer expects — an RFC3339 string would be rejected.
330pub(crate) fn now_epoch() -> Value {
331    let millis = chrono::Utc::now().timestamp_millis();
332    Value::from(millis as f64 / 1000.0)
333}
334
335pub(crate) fn query_get<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
336    q.iter()
337        .find(|(k, _)| k == key)
338        .map(|(_, v)| v.as_str())
339        .filter(|v| !v.is_empty())
340}
341
342/// Canonical resource type for a named-resource operation: the operation's
343/// fixed URI segments joined by `/`, with a small set of singular/plural
344/// aliases collapsed so a create and its describe/list share one store.
345pub(crate) fn resource_type(meta: &OpMeta) -> String {
346    let joined = meta
347        .segs
348        .iter()
349        .filter_map(|s| match s {
350            Seg::Fixed(f) => Some(*f),
351            _ => None,
352        })
353        .collect::<Vec<_>>()
354        .join("/");
355    match joined.as_str() {
356        "authorizer" => "authorizers".to_string(),
357        "custom-metric" => "custom-metrics".to_string(),
358        "fleet-metric" => "fleet-metrics".to_string(),
359        "cacertificate" => "cacertificates".to_string(),
360        other => other.to_string(),
361    }
362}
363
364/// The composite storage key for a request: its label values in URI order.
365pub(crate) fn storage_key(meta: &OpMeta, labels: &HashMap<String, String>) -> String {
366    let mut parts = Vec::new();
367    for s in meta.segs {
368        match s {
369            Seg::Label(name) | Seg::Greedy(name) => {
370                if let Some(v) = labels.get(*name) {
371                    parts.push(v.clone());
372                }
373            }
374            _ => {}
375        }
376    }
377    parts.join("/")
378}
379
380pub(crate) fn arn_path(rtype: &str) -> &'static str {
381    match rtype {
382        "things" => "thing/",
383        "thing-groups" | "dynamic-thing-groups" => "thinggroup/",
384        "billing-groups" => "billinggroup/",
385        "thing-types" => "thingtype/",
386        "policies" => "policy/",
387        "certificates" => "cert/",
388        "cacertificates" => "cacert/",
389        "jobs" => "job/",
390        "job-templates" => "jobtemplate/",
391        "rules" => "rule/",
392        "authorizers" => "authorizer/",
393        "role-aliases" => "rolealias/",
394        "streams" => "stream/",
395        "packages" => "package/",
396        "packages/versions" => "package/",
397        "security-profiles" => "securityprofile/",
398        "mitigationactions/actions" => "mitigationaction/",
399        "custom-metrics" => "custommetric/",
400        "dimensions" => "dimension/",
401        "audit/scheduledaudits" => "scheduledaudit/",
402        "domainConfigurations" => "domainconfiguration/",
403        "fleet-metrics" => "fleetmetric/",
404        "provisioning-templates" => "provisioningtemplate/",
405        "otaUpdates" => "otaupdate/",
406        "certificate-providers" => "certificateprovider/",
407        "commands" => "command/",
408        _ => "",
409    }
410}
411
412pub(crate) fn mint_arn(ctx: &Ctx, rtype: &str, name: &str) -> String {
413    format!(
414        "arn:aws:iot:{}:{}:{}{}",
415        ctx.region,
416        ctx.account,
417        arn_path(rtype),
418        name
419    )
420}
421
422/// Deterministic UUID-shaped id derived from a seed string.
423pub(crate) fn mint_uuid(seed: &str) -> String {
424    let h = fnv(seed);
425    let h2 = fnv(&format!("{seed}:2"));
426    format!(
427        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
428        (h >> 32) as u32,
429        (h >> 16) as u16,
430        h as u16,
431        (h2 >> 48) as u16,
432        h2 & 0xffff_ffff_ffff
433    )
434}
435
436/// Deterministic 64-hex certificate id derived from a seed string.
437pub(crate) fn mint_hex64(seed: &str) -> String {
438    let mut out = String::with_capacity(64);
439    for i in 0..8 {
440        out.push_str(&format!("{:016x}", fnv(&format!("{seed}:{i}"))));
441    }
442    out.truncate(64);
443    out
444}
445
446fn fnv(s: &str) -> u64 {
447    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
448    for b in s.bytes() {
449        h ^= b as u64;
450        h = h.wrapping_mul(0x0100_0000_01b3);
451    }
452    h
453}
454
455/// Whether a JSON value's type is compatible with a modelled member kind.
456pub(crate) fn kind_matches(kind: K, v: &Value) -> bool {
457    match kind {
458        K::Str | K::Blob => v.is_string(),
459        // restJson1 timestamps wire-encode as epoch-seconds JSON numbers; accept
460        // a string too so any legacy/ISO stored value still projects.
461        K::Ts => v.is_string() || v.is_number(),
462        K::Int | K::Num => v.is_number(),
463        K::Bool => v.is_boolean(),
464        K::List => v.is_array(),
465        K::Map | K::Struct => v.is_object(),
466    }
467}
468
469/// Project a stored record onto an operation's output members: keep only
470/// members present in the record whose JSON type matches the modelled kind.
471pub(crate) fn build_output(meta: &OpMeta, record: &Value) -> Value {
472    let mut out = Map::new();
473    if let Some(obj) = record.as_object() {
474        for (wire, kind) in meta.omembers {
475            if let Some(v) = obj.get(*wire) {
476                if !v.is_null() && kind_matches(*kind, v) {
477                    out.insert((*wire).to_string(), v.clone());
478                }
479            }
480        }
481    }
482    Value::Object(out)
483}
484
485/// Project a record onto a list operation's element members.
486pub(crate) fn build_element(meta: &OpMeta, record: &Value) -> Value {
487    let mut out = Map::new();
488    if let Some(obj) = record.as_object() {
489        for (wire, kind) in meta.list_elems {
490            if let Some(v) = obj.get(*wire) {
491                if !v.is_null() && kind_matches(*kind, v) {
492                    out.insert((*wire).to_string(), v.clone());
493                }
494            }
495        }
496    }
497    Value::Object(out)
498}