Skip to main content

tryaudex_core/
resource_parser.rs

1//! Parses wrapped subprocess commands to identify cloud resources being
2//! created, so the audit trail can record what each session left behind.
3//!
4//! This is the foundation for the cleanup flow (Phase 1 of Resource Lifecycle):
5//! audex knows the agent ran `aws s3api create-bucket --bucket foo`, so it
6//! records that `foo` was created under this session. Later, `tryaudex cleanup
7//! <session-id>` enumerates + deletes these resources.
8//!
9//! Coverage is intentionally **command-args only** — we don't parse stdout
10//! yet, so creates where AWS assigns the name (ec2 run-instances, kms
11//! create-key) are a known gap filled in Phase 2.
12
13use serde::{Deserialize, Serialize};
14
15/// A single cloud resource created during a wrapped session.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct CreatedResource {
18    /// Provider: "aws" | "gcp" | "azure"
19    pub provider: String,
20    /// Service: "s3", "dynamodb", "sqs", etc.
21    pub service: String,
22    /// Resource kind within the service: "bucket", "table", "queue", etc.
23    pub resource_type: String,
24    /// User-visible identifier: name, ARN, URL, or key as parsed from args.
25    pub identifier: String,
26}
27
28impl CreatedResource {
29    fn aws(service: &str, resource_type: &str, identifier: &str) -> Self {
30        Self {
31            provider: "aws".to_string(),
32            service: service.to_string(),
33            resource_type: resource_type.to_string(),
34            identifier: identifier.to_string(),
35        }
36    }
37}
38
39/// Parse a subprocess command and return every resource the command appears
40/// to create. Empty vec if none recognized.
41///
42/// The command is the full argv the user passed to `tryaudex run`, e.g.
43/// `["aws", "s3api", "create-bucket", "--bucket", "my-bucket"]`.
44pub fn parse(command: &[String]) -> Vec<CreatedResource> {
45    if command.is_empty() {
46        return Vec::new();
47    }
48    match command[0].as_str() {
49        "aws" => parse_aws(&command[1..]),
50        _ => Vec::new(),
51    }
52}
53
54/// Parse an `aws <service> <verb> [flags]` invocation.
55fn parse_aws(args: &[String]) -> Vec<CreatedResource> {
56    if args.len() < 2 {
57        return Vec::new();
58    }
59    let service = args[0].as_str();
60    let verb = args[1].as_str();
61    let flags = &args[2..];
62
63    match (service, verb) {
64        // S3 high-level CLI
65        ("s3", "mb") => parse_s3_mb(flags),
66        ("s3", "cp") | ("s3", "sync") | ("s3", "mv") => parse_s3_cp(flags),
67        // S3 API
68        ("s3api", "create-bucket") => flag("--bucket", flags)
69            .map(|v| vec![CreatedResource::aws("s3", "bucket", v)])
70            .unwrap_or_default(),
71        ("s3api", "put-object") => {
72            let mut out = Vec::new();
73            if let (Some(bucket), Some(key)) = (flag("--bucket", flags), flag("--key", flags)) {
74                out.push(CreatedResource::aws(
75                    "s3",
76                    "object",
77                    &format!("s3://{bucket}/{key}"),
78                ));
79            }
80            out
81        }
82        // DynamoDB
83        ("dynamodb", "create-table") => flag("--table-name", flags)
84            .map(|v| vec![CreatedResource::aws("dynamodb", "table", v)])
85            .unwrap_or_default(),
86        // SQS
87        ("sqs", "create-queue") => flag("--queue-name", flags)
88            .map(|v| vec![CreatedResource::aws("sqs", "queue", v)])
89            .unwrap_or_default(),
90        // SNS
91        ("sns", "create-topic") => flag("--name", flags)
92            .map(|v| vec![CreatedResource::aws("sns", "topic", v)])
93            .unwrap_or_default(),
94        // SSM
95        ("ssm", "put-parameter") => flag("--name", flags)
96            .map(|v| vec![CreatedResource::aws("ssm", "parameter", v)])
97            .unwrap_or_default(),
98        // Secrets Manager
99        ("secretsmanager", "create-secret") => flag("--name", flags)
100            .map(|v| vec![CreatedResource::aws("secretsmanager", "secret", v)])
101            .unwrap_or_default(),
102        // Lambda
103        ("lambda", "create-function") => flag("--function-name", flags)
104            .map(|v| vec![CreatedResource::aws("lambda", "function", v)])
105            .unwrap_or_default(),
106        // RDS
107        ("rds", "create-db-instance") => flag("--db-instance-identifier", flags)
108            .map(|v| vec![CreatedResource::aws("rds", "db-instance", v)])
109            .unwrap_or_default(),
110        ("rds", "create-db-cluster") => flag("--db-cluster-identifier", flags)
111            .map(|v| vec![CreatedResource::aws("rds", "db-cluster", v)])
112            .unwrap_or_default(),
113        // CloudFormation
114        ("cloudformation", "create-stack") => flag("--stack-name", flags)
115            .map(|v| vec![CreatedResource::aws("cloudformation", "stack", v)])
116            .unwrap_or_default(),
117        // ECR
118        ("ecr", "create-repository") => flag("--repository-name", flags)
119            .map(|v| vec![CreatedResource::aws("ecr", "repository", v)])
120            .unwrap_or_default(),
121        // ECS
122        ("ecs", "create-cluster") => flag("--cluster-name", flags)
123            .map(|v| vec![CreatedResource::aws("ecs", "cluster", v)])
124            .unwrap_or_default(),
125        // IAM (will typically be denied, but track if attempted+granted)
126        ("iam", "create-role") => flag("--role-name", flags)
127            .map(|v| vec![CreatedResource::aws("iam", "role", v)])
128            .unwrap_or_default(),
129        ("iam", "create-user") => flag("--user-name", flags)
130            .map(|v| vec![CreatedResource::aws("iam", "user", v)])
131            .unwrap_or_default(),
132        ("iam", "create-policy") => flag("--policy-name", flags)
133            .map(|v| vec![CreatedResource::aws("iam", "policy", v)])
134            .unwrap_or_default(),
135        // CloudWatch Logs
136        ("logs", "create-log-group") => flag("--log-group-name", flags)
137            .map(|v| vec![CreatedResource::aws("logs", "log-group", v)])
138            .unwrap_or_default(),
139        ("logs", "create-log-stream") => {
140            let mut out = Vec::new();
141            if let (Some(group), Some(stream)) = (
142                flag("--log-group-name", flags),
143                flag("--log-stream-name", flags),
144            ) {
145                out.push(CreatedResource::aws(
146                    "logs",
147                    "log-stream",
148                    &format!("{group}/{stream}"),
149                ));
150            }
151            out
152        }
153        // Route53
154        ("route53", "create-hosted-zone") => flag("--name", flags)
155            .map(|v| vec![CreatedResource::aws("route53", "hosted-zone", v)])
156            .unwrap_or_default(),
157        _ => Vec::new(),
158    }
159}
160
161/// `aws s3 mb s3://bucket-name` → create a bucket.
162fn parse_s3_mb(flags: &[String]) -> Vec<CreatedResource> {
163    flags
164        .iter()
165        .find(|a| a.starts_with("s3://"))
166        .and_then(|uri| uri.strip_prefix("s3://"))
167        .map(|bucket| {
168            // `s3 mb s3://foo` uses just the bucket name (no key)
169            let name = bucket.trim_end_matches('/').split('/').next().unwrap_or("");
170            if name.is_empty() {
171                Vec::new()
172            } else {
173                vec![CreatedResource::aws("s3", "bucket", name)]
174            }
175        })
176        .unwrap_or_default()
177}
178
179/// `aws s3 cp src s3://bucket/key` → create an object (destination is the s3://).
180fn parse_s3_cp(flags: &[String]) -> Vec<CreatedResource> {
181    // Find all s3:// URIs; the last one is conventionally the destination for
182    // cp/sync/mv. `s3 sync src s3://bucket/prefix` → object(s) under prefix.
183    let s3_uris: Vec<&String> = flags.iter().filter(|a| a.starts_with("s3://")).collect();
184    if let Some(dest) = s3_uris.last() {
185        // Don't count source-only ops (s3 cp s3://a s3://b — both are s3 URIs,
186        // but the dest is still the last one, which is correct).
187        if let Some(rest) = dest.strip_prefix("s3://") {
188            if !rest.is_empty() && rest.contains('/') {
189                return vec![CreatedResource::aws("s3", "object", dest)];
190            }
191        }
192    }
193    Vec::new()
194}
195
196/// Extract the value following a CLI flag (`--bucket my-name` → "my-name").
197/// Returns None if the flag isn't present or has no value.
198fn flag<'a>(name: &str, args: &'a [String]) -> Option<&'a str> {
199    let mut iter = args.iter();
200    while let Some(arg) = iter.next() {
201        if arg == name {
202            return iter.next().map(|s| s.as_str());
203        }
204        // Support --flag=value form
205        if let Some(rest) = arg.strip_prefix(&format!("{name}=")) {
206            return Some(rest);
207        }
208    }
209    None
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn cmd(parts: &[&str]) -> Vec<String> {
217        parts.iter().map(|s| s.to_string()).collect()
218    }
219
220    #[test]
221    fn parses_s3api_create_bucket() {
222        let c = cmd(&[
223            "aws",
224            "s3api",
225            "create-bucket",
226            "--bucket",
227            "foo",
228            "--region",
229            "us-east-1",
230        ]);
231        let r = parse(&c);
232        assert_eq!(r.len(), 1);
233        assert_eq!(r[0].service, "s3");
234        assert_eq!(r[0].resource_type, "bucket");
235        assert_eq!(r[0].identifier, "foo");
236    }
237
238    #[test]
239    fn parses_s3_mb() {
240        let c = cmd(&["aws", "s3", "mb", "s3://my-bucket"]);
241        let r = parse(&c);
242        assert_eq!(r.len(), 1);
243        assert_eq!(r[0].identifier, "my-bucket");
244    }
245
246    #[test]
247    fn parses_s3_cp_to_object() {
248        let c = cmd(&["aws", "s3", "cp", "/tmp/file.txt", "s3://my-bucket/key.txt"]);
249        let r = parse(&c);
250        assert_eq!(r.len(), 1);
251        assert_eq!(r[0].service, "s3");
252        assert_eq!(r[0].resource_type, "object");
253        assert_eq!(r[0].identifier, "s3://my-bucket/key.txt");
254    }
255
256    #[test]
257    fn parses_dynamodb_create_table() {
258        let c = cmd(&["aws", "dynamodb", "create-table", "--table-name", "users"]);
259        let r = parse(&c);
260        assert_eq!(r.len(), 1);
261        assert_eq!(r[0].service, "dynamodb");
262        assert_eq!(r[0].identifier, "users");
263    }
264
265    #[test]
266    fn parses_sqs_sns_ssm() {
267        assert_eq!(
268            parse(&cmd(&["aws", "sqs", "create-queue", "--queue-name", "q"]))[0].identifier,
269            "q"
270        );
271        assert_eq!(
272            parse(&cmd(&["aws", "sns", "create-topic", "--name", "t"]))[0].identifier,
273            "t"
274        );
275        assert_eq!(
276            parse(&cmd(&[
277                "aws",
278                "ssm",
279                "put-parameter",
280                "--name",
281                "/p",
282                "--value",
283                "x"
284            ]))[0]
285                .identifier,
286            "/p"
287        );
288    }
289
290    #[test]
291    fn parses_secrets_and_lambda() {
292        assert_eq!(
293            parse(&cmd(&[
294                "aws",
295                "secretsmanager",
296                "create-secret",
297                "--name",
298                "s"
299            ]))[0]
300                .identifier,
301            "s"
302        );
303        assert_eq!(
304            parse(&cmd(&[
305                "aws",
306                "lambda",
307                "create-function",
308                "--function-name",
309                "f"
310            ]))[0]
311                .identifier,
312            "f"
313        );
314    }
315
316    #[test]
317    fn unknown_verb_returns_empty() {
318        assert!(parse(&cmd(&["aws", "s3", "ls"])).is_empty());
319        assert!(parse(&cmd(&["aws", "s3api", "list-buckets"])).is_empty());
320        assert!(parse(&cmd(&["aws", "dynamodb", "scan", "--table-name", "t"])).is_empty());
321    }
322
323    #[test]
324    fn missing_name_flag_returns_empty() {
325        assert!(parse(&cmd(&["aws", "s3api", "create-bucket"])).is_empty());
326        assert!(parse(&cmd(&["aws", "dynamodb", "create-table"])).is_empty());
327    }
328
329    #[test]
330    fn empty_command_returns_empty() {
331        let empty: Vec<String> = Vec::new();
332        assert!(parse(&empty).is_empty());
333        assert!(parse(&cmd(&["aws"])).is_empty());
334        assert!(parse(&cmd(&["aws", "s3"])).is_empty());
335    }
336
337    #[test]
338    fn non_aws_command_returns_empty() {
339        assert!(parse(&cmd(&["gcloud", "storage", "buckets", "create"])).is_empty());
340        assert!(parse(&cmd(&["terraform", "apply"])).is_empty());
341    }
342
343    #[test]
344    fn handles_flag_equals_value_form() {
345        let c = cmd(&["aws", "s3api", "create-bucket", "--bucket=my-bucket"]);
346        let r = parse(&c);
347        assert_eq!(r.len(), 1);
348        assert_eq!(r[0].identifier, "my-bucket");
349    }
350
351    #[test]
352    fn s3_cp_bucket_only_dest_not_counted_as_object() {
353        // "s3 cp file s3://bucket" (no key) — unclear semantics, ignore.
354        let c = cmd(&["aws", "s3", "cp", "/tmp/file", "s3://bucket"]);
355        let r = parse(&c);
356        assert!(r.is_empty());
357    }
358
359    #[test]
360    fn parses_iam_create_role() {
361        let r = parse(&cmd(&[
362            "aws",
363            "iam",
364            "create-role",
365            "--role-name",
366            "MyRole",
367        ]));
368        assert_eq!(r[0].service, "iam");
369        assert_eq!(r[0].resource_type, "role");
370        assert_eq!(r[0].identifier, "MyRole");
371    }
372
373    #[test]
374    fn parses_cloudformation_stack() {
375        let r = parse(&cmd(&[
376            "aws",
377            "cloudformation",
378            "create-stack",
379            "--stack-name",
380            "prod-stack",
381            "--template-body",
382            "file://t.yaml",
383        ]));
384        assert_eq!(r[0].identifier, "prod-stack");
385    }
386
387    #[test]
388    fn parses_logs_create_log_stream_composite() {
389        let r = parse(&cmd(&[
390            "aws",
391            "logs",
392            "create-log-stream",
393            "--log-group-name",
394            "g",
395            "--log-stream-name",
396            "s",
397        ]));
398        assert_eq!(r[0].identifier, "g/s");
399    }
400}