use bytes::Bytes;
use chrono::Utc;
use md5::{Digest, Md5};
use crate::state::{S3Object, SharedS3State};
use crate::xml_util::extract_tag;
struct InventoryDestination {
bucket_arn: String,
prefix: Option<String>,
}
fn parse_inventory_destination(xml: &str) -> Option<InventoryDestination> {
let dest_start = xml.find("<Destination>")?;
let dest_end = xml.find("</Destination>")?;
let dest_body = &xml[dest_start + 13..dest_end];
let s3_start = dest_body.find("<S3BucketDestination>")?;
let s3_end = dest_body.find("</S3BucketDestination>")?;
let s3_body = &dest_body[s3_start + 21..s3_end];
let bucket_arn = extract_tag(s3_body, "Bucket")?;
let prefix = extract_tag(s3_body, "Prefix");
Some(InventoryDestination { bucket_arn, prefix })
}
fn bucket_name_from_arn(arn: &str) -> Option<&str> {
arn.strip_prefix("arn:aws:s3:::")
}
pub fn generate_inventory_report(state: &SharedS3State, source_bucket: &str, config_id: &str) {
let (config_xml, source_account_id) = {
let mas = state.read();
let mut found = None;
for (acct_id, st) in mas.iter() {
if let Some(cfg) = st
.buckets
.get(source_bucket)
.and_then(|b| b.inventory_configs.get(config_id).cloned())
{
found = Some((cfg, acct_id.to_string()));
break;
}
}
match found {
Some((cfg, acct)) => (Some(cfg), acct),
None => (None, String::new()),
}
};
let config_xml = match config_xml {
Some(c) => c,
None => return,
};
let destination = match parse_inventory_destination(&config_xml) {
Some(d) => d,
None => return,
};
let dest_bucket_name = match bucket_name_from_arn(&destination.bucket_arn) {
Some(name) => name.to_string(),
None => return,
};
let rows: Vec<String> = {
let mas = state.read();
let st = match mas.get(&source_account_id) {
Some(s) => s,
None => return,
};
let bucket = match st.buckets.get(source_bucket) {
Some(b) => b,
None => return,
};
let mut csv_rows = vec![
"\"Bucket\",\"Key\",\"Size\",\"LastModifiedDate\",\"ETag\",\"StorageClass\""
.to_string(),
];
for (key, obj) in &bucket.objects {
if obj.is_delete_marker {
continue;
}
csv_rows.push(format!(
"{},{},{},{},{},{}",
csv_escape(source_bucket),
csv_escape(key),
obj.size,
csv_escape(
&obj.last_modified
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string()
),
csv_escape(&obj.etag),
csv_escape(&obj.storage_class),
));
}
csv_rows
};
let csv_content = rows.join("\n") + "\n";
let data = Bytes::from(csv_content);
let size = data.len() as u64;
let etag = format!("{:x}", Md5::digest(&data));
let now = Utc::now();
let report_key = format!(
"{}{}/{}/data/{}.csv",
destination.prefix.as_deref().unwrap_or(""),
source_bucket,
config_id,
now.format("%Y-%m-%dT%H-%M-%SZ"),
);
let report_object = S3Object {
key: report_key.clone(),
body: crate::state::memory_body(data),
content_type: "text/csv".to_string(),
etag,
size,
last_modified: now,
storage_class: "STANDARD".to_string(),
..Default::default()
};
let mut mas = state.write();
let dest_acct = mas
.find_account(|s| s.buckets.contains_key(&dest_bucket_name))
.map(|a| a.to_string());
if let Some(acct) = dest_acct {
if let Some(st) = mas.get_mut(&acct) {
if let Some(target) = st.buckets.get_mut(&dest_bucket_name) {
target.objects.insert(report_key, report_object);
}
}
}
}
fn csv_escape(value: &str) -> String {
if value.contains(',') || value.contains('"') || value.contains('\n') {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
format!("\"{value}\"")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_destination_from_inventory_config() {
let xml = r#"<InventoryConfiguration>
<Id>my-inv</Id>
<Destination>
<S3BucketDestination>
<Bucket>arn:aws:s3:::dest-bucket</Bucket>
<Format>CSV</Format>
<Prefix>inventory/</Prefix>
</S3BucketDestination>
</Destination>
<IsEnabled>true</IsEnabled>
<Schedule><Frequency>Daily</Frequency></Schedule>
<IncludedObjectVersions>Current</IncludedObjectVersions>
</InventoryConfiguration>"#;
let dest = parse_inventory_destination(xml).unwrap();
assert_eq!(dest.bucket_arn, "arn:aws:s3:::dest-bucket");
assert_eq!(dest.prefix.as_deref(), Some("inventory/"));
}
#[test]
fn bucket_name_from_arn_works() {
assert_eq!(
bucket_name_from_arn("arn:aws:s3:::my-bucket"),
Some("my-bucket")
);
assert_eq!(bucket_name_from_arn("not-an-arn"), None);
}
#[test]
fn csv_escape_plain_value() {
assert_eq!(csv_escape("hello"), "\"hello\"");
}
#[test]
fn csv_escape_value_with_comma() {
assert_eq!(csv_escape("a,b"), "\"a,b\"");
}
#[test]
fn csv_escape_value_with_quotes() {
assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn csv_escape_value_with_comma_and_quotes() {
assert_eq!(csv_escape("a,\"b\""), "\"a,\"\"b\"\"\"");
}
#[test]
fn parse_inventory_missing_destination_block_is_none() {
let xml = "<InventoryConfiguration></InventoryConfiguration>";
assert!(parse_inventory_destination(xml).is_none());
}
#[test]
fn parse_inventory_missing_bucket_returns_none() {
let xml = "<InventoryConfiguration>
<Destination>
<S3BucketDestination>
<Format>CSV</Format>
</S3BucketDestination>
</Destination>
</InventoryConfiguration>";
assert!(parse_inventory_destination(xml).is_none());
}
#[test]
fn parse_inventory_without_prefix_field_is_ok() {
let xml = "<InventoryConfiguration>
<Destination>
<S3BucketDestination>
<Bucket>arn:aws:s3:::dest</Bucket>
</S3BucketDestination>
</Destination>
</InventoryConfiguration>";
let dest = parse_inventory_destination(xml).unwrap();
assert_eq!(dest.bucket_arn, "arn:aws:s3:::dest");
assert!(dest.prefix.is_none());
}
#[test]
fn generate_inventory_writes_csv_report() {
use crate::state::{memory_body, S3Bucket, S3Object, S3State};
use fakecloud_core::multi_account::MultiAccountState;
use parking_lot::RwLock;
use std::sync::Arc;
let mut s = S3State::new("123456789012", "us-east-1");
let mut src = S3Bucket::new("src", "us-east-1", "owner");
src.objects.insert(
"file.txt".to_string(),
S3Object {
key: "file.txt".to_string(),
body: memory_body(Bytes::from_static(b"abc")),
content_type: "text/plain".to_string(),
etag: "abc".to_string(),
size: 3,
last_modified: Utc::now(),
storage_class: "STANDARD".to_string(),
..Default::default()
},
);
src.inventory_configs.insert(
"cfg".to_string(),
r#"<InventoryConfiguration>
<Destination>
<S3BucketDestination>
<Bucket>arn:aws:s3:::dest</Bucket>
<Prefix>inv/</Prefix>
</S3BucketDestination>
</Destination>
</InventoryConfiguration>"#
.to_string(),
);
s.buckets.insert("src".to_string(), src);
s.buckets.insert(
"dest".to_string(),
S3Bucket::new("dest", "us-east-1", "owner"),
);
let mut multi: MultiAccountState<S3State> =
MultiAccountState::new("123456789012", "us-east-1", "http://x");
*multi.default_mut() = s;
let shared: SharedS3State = Arc::new(RwLock::new(multi));
generate_inventory_report(&shared, "src", "cfg");
let guard = shared.read();
let dest = guard.default_ref().buckets.get("dest").unwrap();
assert_eq!(dest.objects.len(), 1);
let (key, obj) = dest.objects.iter().next().unwrap();
assert!(key.starts_with("inv/src/cfg/data/"));
assert_eq!(obj.content_type, "text/csv");
}
#[test]
fn generate_inventory_missing_config_is_noop() {
use crate::state::{S3Bucket, S3State};
use fakecloud_core::multi_account::MultiAccountState;
use parking_lot::RwLock;
use std::sync::Arc;
let mut s = S3State::new("123456789012", "us-east-1");
s.buckets.insert(
"src".to_string(),
S3Bucket::new("src", "us-east-1", "owner"),
);
let mut multi: MultiAccountState<S3State> =
MultiAccountState::new("123456789012", "us-east-1", "http://x");
*multi.default_mut() = s;
let shared: SharedS3State = Arc::new(RwLock::new(multi));
generate_inventory_report(&shared, "src", "missing");
}
}