1use crate::config::AwsConfig;
3use anyhow::{bail, Result};
4use futures::{stream, StreamExt};
5use std::{
6 collections::HashMap,
7 sync::Arc,
8 time::{Duration, Instant},
9};
10use tokio::sync::{mpsc, Mutex};
11
12mod inventory;
13
14pub const RESULT_LIMIT: usize = 1000;
15const CACHE_TTL: Duration = Duration::from_secs(60);
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub enum ResourceKind {
19 Api,
20 Stack,
21 Alarm,
22 LogGroup,
23 Instance,
24 Vpc,
25 Table,
26 Repository,
27 PublicRepository,
28 Efs,
29 Fsx,
30 User,
31 Role,
32 Group,
33 Key,
34 Function,
35 Database,
36 Bucket,
37 Queue,
38 Topic,
39 Reference,
44}
45
46impl ResourceKind {
47 pub const ALL: [Self; 20] = [
48 Self::Api,
49 Self::Stack,
50 Self::Alarm,
51 Self::LogGroup,
52 Self::Instance,
53 Self::Vpc,
54 Self::Table,
55 Self::Repository,
56 Self::PublicRepository,
57 Self::Efs,
58 Self::Fsx,
59 Self::User,
60 Self::Role,
61 Self::Group,
62 Self::Key,
63 Self::Function,
64 Self::Database,
65 Self::Bucket,
66 Self::Queue,
67 Self::Topic,
68 ];
69 pub fn service(self) -> &'static str {
70 match self {
71 Self::Api => "apigateway",
72 Self::Stack => "cloudformation",
73 Self::Alarm => "cloudwatch",
74 Self::LogGroup => "logs",
75 Self::Instance => "ec2",
76 Self::Vpc => "vpc",
77 Self::Table => "dynamodb",
78 Self::Repository => "ecr",
79 Self::PublicRepository => "ecr-public",
80 Self::Efs => "efs",
81 Self::Fsx => "fsx",
82 Self::User | Self::Role | Self::Group => "iam",
83 Self::Key => "kms",
84 Self::Function => "lambda",
85 Self::Database => "rds",
86 Self::Bucket => "s3",
87 Self::Queue => "sqs",
88 Self::Topic => "sns",
89 Self::Reference => "",
90 }
91 }
92 pub fn label(self) -> &'static str {
93 match self {
94 Self::Api => "API",
95 Self::Stack => "Stack",
96 Self::Alarm => "Alarm",
97 Self::LogGroup => "Log group",
98 Self::Instance => "Instance",
99 Self::Vpc => "VPC",
100 Self::Table => "Table",
101 Self::Repository | Self::PublicRepository => "Repository",
102 Self::Efs | Self::Fsx => "File system",
103 Self::User => "User",
104 Self::Role => "Role",
105 Self::Group => "Group",
106 Self::Key => "Key",
107 Self::Function => "Function",
108 Self::Database => "Database",
109 Self::Bucket => "Bucket",
110 Self::Queue => "Queue",
111 Self::Topic => "Topic",
112 Self::Reference => "Reference",
113 }
114 }
115 fn resource_types(self) -> &'static str {
116 match self {
117 Self::Api => "apigateway:restapis,apigateway:apis",
118 Self::Stack => "cloudformation:stack",
119 Self::Alarm => "cloudwatch:alarm",
120 Self::LogGroup => "logs:log-group",
121 Self::Instance => "ec2:instance",
122 Self::Vpc => "ec2:vpc",
123 Self::Table => "dynamodb:table",
124 Self::Repository => "ecr:repository",
125 Self::PublicRepository => "ecr-public:repository",
126 Self::Efs => "elasticfilesystem:file-system",
127 Self::Fsx => "fsx:file-system",
128 Self::User => "iam:user",
129 Self::Role => "iam:role",
130 Self::Group => "iam:group",
131 Self::Key => "kms:key",
132 Self::Function => "lambda:function",
133 Self::Database => "rds:db",
134 Self::Bucket => "s3:bucket",
135 Self::Queue => "sqs:queue",
136 Self::Topic => "sns:topic",
137 Self::Reference => "",
138 }
139 }
140 fn accepts_qualifier(self, qualifier: &str) -> bool {
141 if self == Self::Reference {
142 return false;
143 }
144 self.service() == qualifier
145 || match qualifier {
146 "ddb" => self == Self::Table,
147 "ec2" => matches!(self, Self::Instance | Self::Vpc),
148 "cfn" => self == Self::Stack,
149 "cw" | "cloudwatch" => matches!(self, Self::Alarm | Self::LogGroup),
150 "apig" => self == Self::Api,
151 "elasticfilesystem" => self == Self::Efs,
152 "ecr" => matches!(self, Self::Repository | Self::PublicRepository),
153 _ => false,
154 }
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Resource {
160 pub kind: ResourceKind,
161 pub id: String,
162 pub name: String,
163 pub arn: String,
164 pub region: String,
165}
166
167impl Resource {
168 pub(super) fn new(
169 kind: ResourceKind,
170 id: impl Into<String>,
171 name: impl Into<String>,
172 region: &str,
173 ) -> Self {
174 Self {
175 kind,
176 id: id.into(),
177 name: name.into(),
178 arn: String::new(),
179 region: region.into(),
180 }
181 }
182
183 pub fn from_index(resource_type: &str, arn: &str, region: &str) -> Option<Self> {
184 let kind = ResourceKind::ALL
185 .into_iter()
186 .find(|kind| kind.resource_types().split(',').any(|t| t == resource_type))?;
187 let tail = arn.splitn(6, ':').nth(5)?;
188 let id = match kind {
189 ResourceKind::Bucket | ResourceKind::Queue | ResourceKind::Topic => tail,
190 ResourceKind::Database | ResourceKind::Function | ResourceKind::Alarm => {
191 tail.split_once(':')?.1
192 }
193 ResourceKind::LogGroup => tail.strip_prefix("log-group:")?.trim_end_matches(":*"),
194 ResourceKind::Api => tail.trim_start_matches('/').split('/').nth(1)?,
195 ResourceKind::Stack => tail.strip_prefix("stack/")?.split('/').next()?,
196 ResourceKind::User | ResourceKind::Role | ResourceKind::Group => {
197 tail.rsplit('/').next()?
198 }
199 _ => tail.split_once('/')?.1,
200 };
201 if id.is_empty() {
202 return None;
203 }
204 if kind == ResourceKind::Function && id.contains(':') {
206 return None;
207 }
208 Some(Self {
209 kind,
210 id: id.into(),
211 name: id.into(),
212 arn: arn.into(),
213 region: region.into(),
214 })
215 }
216
217 pub fn from_arn(arn: &str) -> Option<Self> {
221 let mut parts = arn.splitn(6, ':');
222 if parts.next()? != "arn" {
223 return None;
224 }
225 let partition = parts.next()?;
226 let service = parts.next()?;
227 let region = parts.next()?;
228 let account = parts.next()?;
229 let tail = parts.next()?;
230 if !matches!(
231 partition,
232 "aws" | "aws-cn" | "aws-us-gov" | "aws-iso" | "aws-iso-b" | "aws-iso-e" | "aws-iso-f"
233 ) || service.is_empty()
234 || tail.is_empty()
235 || [partition, service, region, account]
236 .iter()
237 .any(|part| part.chars().any(char::is_whitespace))
238 {
239 return None;
240 }
241 let kind = match service {
242 "s3" if !tail.contains('/') && !tail.contains(':') => Some(ResourceKind::Bucket),
243 "s3" => None,
244 "sqs" => Some(ResourceKind::Queue),
245 "sns" if !tail.contains('/') && !tail.contains(':') => Some(ResourceKind::Topic),
246 "sns" => None,
247 _ => ResourceKind::ALL.into_iter().find(|kind| {
248 kind.resource_types().split(',').any(|resource_type| {
249 let Some((expected_service, resource_type)) = resource_type.split_once(':')
250 else {
251 return false;
252 };
253 let tail = tail.trim_start_matches('/');
254 expected_service == service
255 && tail.strip_prefix(resource_type).is_some_and(|suffix| {
256 suffix.starts_with('/') || suffix.starts_with(':')
257 })
258 })
259 }),
260 };
261 let mut row = if let Some(kind) = kind {
262 let parent_tail = match kind {
265 ResourceKind::Function => tail
266 .rsplit_once(':')
267 .filter(|_| tail.matches(':').count() > 1)
268 .map_or(tail, |(parent, _)| parent),
269 ResourceKind::Table => tail
270 .split("/stream/")
271 .next()
272 .unwrap_or(tail)
273 .split("/index/")
274 .next()
275 .unwrap_or(tail),
276 ResourceKind::LogGroup => tail.split(":log-stream:").next().unwrap_or(tail),
277 _ => tail,
278 };
279 let normalized = format!("arn:{partition}:{service}:{region}:{account}:{parent_tail}");
280 Self::from_index(
281 kind.resource_types().split(',').find(|resource_type| {
282 kind != ResourceKind::Api
283 || parent_tail.contains(resource_type.split_once(':').unwrap().1)
284 })?,
285 &normalized,
286 region,
287 )?
288 } else {
289 Self::new(ResourceKind::Reference, tail, tail, region)
290 };
291 row.arn = arn.into();
292 Some(row)
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct SearchQuery {
298 pub text: String,
299 pub kinds: Vec<ResourceKind>,
300}
301
302impl SearchQuery {
303 pub fn parse(input: &str) -> Result<Self> {
304 let mut qualifiers = Vec::new();
305 let mut words = Vec::new();
306 for word in input.split_whitespace() {
307 if let Some(service) = word.strip_prefix('@') {
308 let service = service.to_ascii_lowercase();
309 if !ResourceKind::ALL
310 .iter()
311 .any(|k| k.accepts_qualifier(&service))
312 {
313 bail!(
314 "Unknown service @{service}; try @s3, @dynamodb, @ec2, @rds, @iam, @lambda"
315 );
316 }
317 qualifiers.push(service);
318 } else {
319 words.push(word);
320 }
321 }
322 let text = words.join(" ").to_lowercase();
323 let kinds = ResourceKind::ALL
324 .into_iter()
325 .filter(|kind| {
326 qualifiers.is_empty() || qualifiers.iter().any(|q| kind.accepts_qualifier(q))
327 })
328 .collect();
329 Ok(Self { text, kinds })
330 }
331 pub fn ready(&self) -> bool {
332 self.text.chars().filter(|c| !c.is_whitespace()).count() >= 3
333 }
334 pub fn matches(&self, resource: &Resource) -> bool {
336 if resource.kind == ResourceKind::Reference {
337 let service = resource.arn.split(':').nth(2).unwrap_or_default();
338 let service_matches = self.kinds.len() == ResourceKind::ALL.len()
339 || self.kinds.iter().any(|kind| kind.service() == service);
340 return service_matches
341 && [&resource.id, &resource.name, &resource.arn]
342 .iter()
343 .any(|value| value.to_lowercase().contains(&self.text));
344 }
345 self.kinds.contains(&resource.kind)
346 && [&resource.id, &resource.name, &resource.arn]
347 .iter()
348 .any(|value| value.to_lowercase().contains(&self.text))
349 }
350 fn index_query(&self, region: &str) -> String {
351 let terms = self
356 .text
357 .split_whitespace()
358 .map(|term| {
359 if !term.starts_with('-')
360 && term
361 .chars()
362 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
363 {
364 format!("{term}*")
365 } else {
366 let escaped = term.replace('\\', "\\\\").replace('"', "\\\"");
367 format!("\"{escaped}\"")
368 }
369 })
370 .collect::<Vec<_>>()
371 .join(" ");
372 let types = self
373 .kinds
374 .iter()
375 .map(|k| k.resource_types())
376 .collect::<Vec<_>>()
377 .join(",");
378 format!("{terms} resourcetype:{types} region:{region},global")
379 }
380}
381
382#[derive(Debug)]
383pub enum SearchUpdate {
384 Provider(&'static str),
385 Results(Vec<Resource>),
386 Warning(String),
387 Finished { limited: bool },
388}
389
390#[derive(Default)]
391pub struct SearchCache {
392 inventory: HashMap<ResourceKind, (Instant, Vec<Resource>, bool)>,
393 index_unavailable: Option<Instant>,
394}
395pub type SharedSearchCache = Arc<Mutex<SearchCache>>;
396
397pub async fn search(
400 config: AwsConfig,
401 query: SearchQuery,
402 cache: SharedSearchCache,
403 tx: mpsc::Sender<SearchUpdate>,
404) {
405 if !query.ready() {
406 return;
407 }
408 let try_index = cache
409 .lock()
410 .await
411 .index_unavailable
412 .is_none_or(|at| at.elapsed() >= CACHE_TTL);
413 if try_index {
414 let _ = tx
415 .send(SearchUpdate::Provider("Indexed prefix search"))
416 .await;
417 match tokio::time::timeout(Duration::from_secs(12), search_index(&config, &query, &tx))
418 .await
419 {
420 Ok(Ok(limited)) => {
421 let _ = tx.send(SearchUpdate::Finished { limited }).await;
422 return;
423 }
424 _ => {
425 cache.lock().await.index_unavailable = Some(Instant::now());
426 let _ = tx
427 .send(SearchUpdate::Warning(
428 "Indexed search unavailable; using supported-service inventories".into(),
429 ))
430 .await;
431 }
432 }
433 }
434 let _ = tx
435 .send(SearchUpdate::Provider(
436 "Parallel inventory search (60s cache)",
437 ))
438 .await;
439 let mut work = stream::iter(query.kinds.clone())
440 .map(|kind| {
441 let config = config.clone();
442 let cache = cache.clone();
443 async move {
444 if let Some((at, rows, limited)) = cache.lock().await.inventory.get(&kind) {
445 if at.elapsed() < CACHE_TTL {
446 return (kind, Ok((rows.clone(), *limited)));
447 }
448 }
449 let result = match tokio::time::timeout(
450 Duration::from_secs(20),
451 inventory::fetch(&config, kind),
452 )
453 .await
454 {
455 Ok(result) => result,
456 Err(_) => Err(anyhow::anyhow!("request timed out")),
457 };
458 if let Ok((rows, limited)) = &result {
459 cache
460 .lock()
461 .await
462 .inventory
463 .insert(kind, (Instant::now(), rows.clone(), *limited));
464 }
465 (kind, result)
466 }
467 })
468 .buffer_unordered(4);
469 let mut limited = false;
470 let mut total = 0;
471 while let Some((kind, result)) = work.next().await {
472 match result {
473 Ok((rows, capped)) => {
474 limited |= capped;
475 let matches: Vec<_> = rows.into_iter().filter(|r| query.matches(r)).collect();
476 let remaining = RESULT_LIMIT.saturating_sub(total);
477 limited |= matches.len() > remaining;
478 let matches: Vec<_> = matches.into_iter().take(remaining).collect();
479 total += matches.len();
480 if tx.send(SearchUpdate::Results(matches)).await.is_err() {
481 return;
482 }
483 }
484 Err(error) => {
485 if tx
486 .send(SearchUpdate::Warning(format!(
487 "{} {}: {error}",
488 kind.service(),
489 kind.label()
490 )))
491 .await
492 .is_err()
493 {
494 return;
495 }
496 }
497 }
498 }
499 let _ = tx.send(SearchUpdate::Finished { limited }).await;
500}
501
502async fn search_index(
503 config: &AwsConfig,
504 query: &SearchQuery,
505 tx: &mpsc::Sender<SearchUpdate>,
506) -> Result<bool> {
507 let client = config.resource_explorer_client();
508 let mut token = None;
509 let mut count = 0;
510 let mut limited = false;
511 loop {
512 let page = client
513 .search()
514 .query_string(query.index_query(&config.region))
515 .max_results(100)
516 .set_next_token(token.clone())
517 .send()
518 .await?;
519 if page.count().is_some_and(|c| c.complete() != Some(true)) {
520 limited = true;
521 }
522 let rows: Vec<_> = page
523 .resources()
524 .iter()
525 .filter(|r| r.owning_account_id() == Some(config.account_id.as_str()))
526 .filter_map(|r| Resource::from_index(r.resource_type()?, r.arn()?, r.region()?))
527 .filter(|r| query.kinds.contains(&r.kind))
528 .collect();
529 count += rows.len();
530 tx.send(SearchUpdate::Results(rows))
531 .await
532 .map_err(|_| anyhow::anyhow!("search cancelled"))?;
533 let next = page.next_token().map(str::to_owned);
534 if next.is_none() {
535 break;
536 }
537 if next == token || count >= RESULT_LIMIT {
538 limited = true;
539 break;
540 }
541 token = next;
542 }
543 Ok(limited)
544}
545
546#[cfg(test)]
547mod tests;