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                // Any action not claimed by `special` is accepted as a
192                // control-plane no-op returning an empty (shape-valid) body.
193                Ok((ok_json(Value::Object(Map::new())), false))
194            }
195        }
196    }
197}
198
199// ===================== routing =====================
200
201/// Match a request `(method, path)` against the generated route table,
202/// returning the operation metadata and captured labels (member name -> value).
203/// Ties (a fixed segment vs a label segment at the same position) are broken in
204/// favour of the route with more fixed segments — the more specific route.
205pub(crate) fn match_route(
206    method: &Method,
207    raw_path: &str,
208) -> Option<(&'static OpMeta, HashMap<String, String>)> {
209    let raw = raw_path.split('?').next().unwrap_or(raw_path);
210    let trimmed = raw.strip_prefix('/').unwrap_or(raw);
211    let segs: Vec<String> = if trimmed.is_empty() {
212        Vec::new()
213    } else {
214        trimmed
215            .split('/')
216            .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
217            .collect()
218    };
219    let method_str = method.as_str();
220
221    let mut best: Option<(&'static OpMeta, HashMap<String, String>, usize)> = None;
222    for meta in OPS {
223        if meta.method != method_str {
224            continue;
225        }
226        if let Some((labels, fixed)) = match_segs(meta.segs, &segs) {
227            if best.as_ref().map(|(_, _, f)| fixed > *f).unwrap_or(true) {
228                best = Some((meta, labels, fixed));
229            }
230        }
231    }
232    best.map(|(m, l, _)| (m, l))
233}
234
235/// Try to match a route's segment pattern against the request segments,
236/// returning captured labels and the fixed-segment count on success.
237fn match_segs(pattern: &[Seg], segs: &[String]) -> Option<(HashMap<String, String>, usize)> {
238    let has_greedy = matches!(pattern.last(), Some(Seg::Greedy(_)));
239    if has_greedy {
240        if segs.len() < pattern.len() {
241            return None;
242        }
243    } else if segs.len() != pattern.len() {
244        return None;
245    }
246
247    let mut labels = HashMap::new();
248    let mut fixed = 0usize;
249    let mut i = 0usize;
250    for (p, seg) in pattern.iter().enumerate() {
251        match seg {
252            Seg::Fixed(f) => {
253                if segs.get(i)?.as_str() != *f {
254                    return None;
255                }
256                fixed += 1;
257                i += 1;
258            }
259            Seg::Label(name) => {
260                labels.insert((*name).to_string(), segs.get(i)?.clone());
261                i += 1;
262            }
263            Seg::Greedy(name) => {
264                // Greedy is always the last segment; capture the rest.
265                debug_assert_eq!(p, pattern.len() - 1);
266                let rest = segs[i..].join("/");
267                labels.insert((*name).to_string(), rest);
268                i = segs.len();
269            }
270        }
271    }
272    if i != segs.len() {
273        return None;
274    }
275    Some((labels, fixed))
276}
277
278// ===================== shared helpers =====================
279
280pub(crate) fn ok_json(v: Value) -> AwsResponse {
281    AwsResponse::json_value(StatusCode::OK, v)
282}
283
284pub(crate) fn parse_query(raw: &str) -> Vec<(String, String)> {
285    raw.split('&')
286        .filter(|p| !p.is_empty())
287        .map(|pair| {
288            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
289            (
290                percent_decode_str(k).decode_utf8_lossy().into_owned(),
291                percent_decode_str(v).decode_utf8_lossy().into_owned(),
292            )
293        })
294        .collect()
295}
296
297pub(crate) fn parse_body(body: &[u8]) -> Map<String, Value> {
298    if body.is_empty() {
299        return Map::new();
300    }
301    match serde_json::from_slice::<Value>(body) {
302        Ok(Value::Object(m)) => m,
303        _ => Map::new(),
304    }
305}
306
307/// Current time as a restJson1 timestamp: Unix epoch **seconds** encoded as a
308/// JSON number, with fractional milliseconds preserved (e.g. `1752324947.041`).
309/// This is the default `timestamp` wire format for the `restJson1` protocol
310/// (which AWS IoT Core uses), and is what the aws-sdk / aws-smithy timestamp
311/// deserializer expects — an RFC3339 string would be rejected.
312pub(crate) fn now_epoch() -> Value {
313    let millis = chrono::Utc::now().timestamp_millis();
314    Value::from(millis as f64 / 1000.0)
315}
316
317pub(crate) fn query_get<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
318    q.iter()
319        .find(|(k, _)| k == key)
320        .map(|(_, v)| v.as_str())
321        .filter(|v| !v.is_empty())
322}
323
324/// Canonical resource type for a named-resource operation: the operation's
325/// fixed URI segments joined by `/`, with a small set of singular/plural
326/// aliases collapsed so a create and its describe/list share one store.
327pub(crate) fn resource_type(meta: &OpMeta) -> String {
328    let joined = meta
329        .segs
330        .iter()
331        .filter_map(|s| match s {
332            Seg::Fixed(f) => Some(*f),
333            _ => None,
334        })
335        .collect::<Vec<_>>()
336        .join("/");
337    match joined.as_str() {
338        "authorizer" => "authorizers".to_string(),
339        "custom-metric" => "custom-metrics".to_string(),
340        "fleet-metric" => "fleet-metrics".to_string(),
341        "cacertificate" => "cacertificates".to_string(),
342        other => other.to_string(),
343    }
344}
345
346/// The composite storage key for a request: its label values in URI order.
347pub(crate) fn storage_key(meta: &OpMeta, labels: &HashMap<String, String>) -> String {
348    let mut parts = Vec::new();
349    for s in meta.segs {
350        match s {
351            Seg::Label(name) | Seg::Greedy(name) => {
352                if let Some(v) = labels.get(*name) {
353                    parts.push(v.clone());
354                }
355            }
356            _ => {}
357        }
358    }
359    parts.join("/")
360}
361
362pub(crate) fn arn_path(rtype: &str) -> &'static str {
363    match rtype {
364        "things" => "thing/",
365        "thing-groups" | "dynamic-thing-groups" => "thinggroup/",
366        "billing-groups" => "billinggroup/",
367        "thing-types" => "thingtype/",
368        "policies" => "policy/",
369        "certificates" => "cert/",
370        "cacertificates" => "cacert/",
371        "jobs" => "job/",
372        "job-templates" => "jobtemplate/",
373        "rules" => "rule/",
374        "authorizers" => "authorizer/",
375        "role-aliases" => "rolealias/",
376        "streams" => "stream/",
377        "packages" => "package/",
378        "packages/versions" => "package/",
379        "security-profiles" => "securityprofile/",
380        "mitigationactions/actions" => "mitigationaction/",
381        "custom-metrics" => "custommetric/",
382        "dimensions" => "dimension/",
383        "audit/scheduledaudits" => "scheduledaudit/",
384        "domainConfigurations" => "domainconfiguration/",
385        "fleet-metrics" => "fleetmetric/",
386        "provisioning-templates" => "provisioningtemplate/",
387        "otaUpdates" => "otaupdate/",
388        "certificate-providers" => "certificateprovider/",
389        "commands" => "command/",
390        _ => "",
391    }
392}
393
394pub(crate) fn mint_arn(ctx: &Ctx, rtype: &str, name: &str) -> String {
395    format!(
396        "arn:aws:iot:{}:{}:{}{}",
397        ctx.region,
398        ctx.account,
399        arn_path(rtype),
400        name
401    )
402}
403
404/// Deterministic UUID-shaped id derived from a seed string.
405pub(crate) fn mint_uuid(seed: &str) -> String {
406    let h = fnv(seed);
407    let h2 = fnv(&format!("{seed}:2"));
408    format!(
409        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
410        (h >> 32) as u32,
411        (h >> 16) as u16,
412        h as u16,
413        (h2 >> 48) as u16,
414        h2 & 0xffff_ffff_ffff
415    )
416}
417
418/// Deterministic 64-hex certificate id derived from a seed string.
419pub(crate) fn mint_hex64(seed: &str) -> String {
420    let mut out = String::with_capacity(64);
421    for i in 0..8 {
422        out.push_str(&format!("{:016x}", fnv(&format!("{seed}:{i}"))));
423    }
424    out.truncate(64);
425    out
426}
427
428fn fnv(s: &str) -> u64 {
429    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
430    for b in s.bytes() {
431        h ^= b as u64;
432        h = h.wrapping_mul(0x0100_0000_01b3);
433    }
434    h
435}
436
437/// Whether a JSON value's type is compatible with a modelled member kind.
438pub(crate) fn kind_matches(kind: K, v: &Value) -> bool {
439    match kind {
440        K::Str | K::Blob => v.is_string(),
441        // restJson1 timestamps wire-encode as epoch-seconds JSON numbers; accept
442        // a string too so any legacy/ISO stored value still projects.
443        K::Ts => v.is_string() || v.is_number(),
444        K::Int | K::Num => v.is_number(),
445        K::Bool => v.is_boolean(),
446        K::List => v.is_array(),
447        K::Map | K::Struct => v.is_object(),
448    }
449}
450
451/// Project a stored record onto an operation's output members: keep only
452/// members present in the record whose JSON type matches the modelled kind.
453pub(crate) fn build_output(meta: &OpMeta, record: &Value) -> Value {
454    let mut out = Map::new();
455    if let Some(obj) = record.as_object() {
456        for (wire, kind) in meta.omembers {
457            if let Some(v) = obj.get(*wire) {
458                if !v.is_null() && kind_matches(*kind, v) {
459                    out.insert((*wire).to_string(), v.clone());
460                }
461            }
462        }
463    }
464    Value::Object(out)
465}
466
467/// Project a record onto a list operation's element members.
468pub(crate) fn build_element(meta: &OpMeta, record: &Value) -> Value {
469    let mut out = Map::new();
470    if let Some(obj) = record.as_object() {
471        for (wire, kind) in meta.list_elems {
472            if let Some(v) = obj.get(*wire) {
473                if !v.is_null() && kind_matches(*kind, v) {
474                    out.insert((*wire).to_string(), v.clone());
475                }
476            }
477        }
478    }
479    Value::Object(out)
480}