Skip to main content

rusticity_core/
finder.rs

1//! Cross-service discovery of supported resource metadata, never resource contents.
2use 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    /// Generic linked target unsupported as an inventory-search kind.
40    /// Related-resource discovery uses this for ARNs that don't map to a
41    /// supported top-level inventory kind (e.g. subnets, volumes, layers).
42    /// Excluded from `ALL` so inventory search stays at 20 kinds.
43    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        // Lambda versions/aliases and nested resources aren't separate finder targets.
205        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    /// Convert an explicit ARN to a finder target. Qualified functions, table
218    /// streams and log streams open their parent view; the original ARN is retained.
219    /// Types without a view remain visible as generic references.
220    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            // Use the existing parser on a normalized parent ARN, then restore the
263            // original reference for display, copying and account-scope checks.
264            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    /// Fast name/ID/ARN matching for the fallback inventory cache.
335    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        // Resource Explorer requires bare terms for prefix matching: `fsx*`,
352        // not `"fsx"*` (the latter changes the query's meaning). Only ordinary
353        // resource-name tokens may be bare; quote syntax-bearing literals so
354        // user input cannot inject filters, negation, or wildcards.
355        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
397/// The caller owns cancellation by aborting this future. All fallback futures
398/// are polled here (not detached tasks), so cancellation drops outstanding reads.
399pub 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;