1use 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
35pub 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 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
114pub(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 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 if let Some(res) = special::dispatch(self, meta, &ctx, labels, &query, &req.headers, &body)?
156 {
157 return Ok(res);
158 }
159
160 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 Ok((ok_json(Value::Object(Map::new())), false))
194 }
195 }
196 }
197}
198
199pub(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
235fn 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 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
278pub(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
307pub(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
324pub(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
346pub(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
404pub(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
418pub(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
437pub(crate) fn kind_matches(kind: K, v: &Value) -> bool {
439 match kind {
440 K::Str | K::Blob => v.is_string(),
441 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
451pub(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
467pub(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}