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 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
205pub(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
241fn 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 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
284pub(crate) fn ok_json(v: Value) -> AwsResponse {
287 AwsResponse::json_value(StatusCode::OK, v)
288}
289
290pub(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
325pub(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
342pub(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
364pub(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
422pub(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
436pub(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
455pub(crate) fn kind_matches(kind: K, v: &Value) -> bool {
457 match kind {
458 K::Str | K::Blob => v.is_string(),
459 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
469pub(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
485pub(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}