Skip to main content

fakecloud_s3/
logging.rs

1use bytes::Bytes;
2use chrono::Utc;
3use md5::{Digest, Md5};
4use uuid::Uuid;
5
6use crate::state::{S3Object, SharedS3State};
7
8/// Parsed logging configuration extracted from the XML stored on the bucket.
9pub struct LoggingConfig {
10    pub target_bucket: String,
11    pub target_prefix: String,
12}
13
14/// Parse a `<BucketLoggingStatus>` XML body into a `LoggingConfig`, if logging
15/// is enabled (i.e. the `<LoggingEnabled>` element is present).
16pub fn parse_logging_config(xml: &str) -> Option<LoggingConfig> {
17    let le_start = xml.find("<LoggingEnabled>")?;
18    let le_end = xml.find("</LoggingEnabled>")?;
19    let le_body = &xml[le_start + 16..le_end];
20
21    let target_bucket = extract_tag(le_body, "TargetBucket")?;
22    let target_prefix = extract_tag(le_body, "TargetPrefix").unwrap_or_default();
23
24    Some(LoggingConfig {
25        target_bucket,
26        target_prefix,
27    })
28}
29
30/// Generate an S3 access log line in a format similar to AWS.
31///
32/// See <https://docs.aws.amazon.com/AmazonS3/latest/userguide/LogFormat.html>
33#[allow(clippy::too_many_arguments)]
34pub fn format_access_log_entry(
35    bucket_owner: &str,
36    bucket: &str,
37    operation: &str,
38    key: Option<&str>,
39    status: u16,
40    request_id: &str,
41    method: &str,
42    path: &str,
43) -> String {
44    let now = Utc::now();
45    let time = now.format("[%d/%b/%Y:%H:%M:%S %z]");
46    let key_str = key.unwrap_or("-");
47    // Simplified log line matching the AWS format fields
48    format!(
49        "{bucket_owner} {bucket} {time} 127.0.0.1 arn:aws:iam::000000000000:user/testuser {request_id} REST.{operation} {key_str} \"{method} {path} HTTP/1.1\" {status} - - - - - \"-\" \"FakeCloud/1.0\" - - - - -\n"
50    )
51}
52
53/// After a request has been processed, check whether the source bucket has
54/// logging enabled and, if so, write a log entry to the target bucket.
55///
56/// This should be called at the end of the `handle` method so that every S3
57/// operation on a logging-enabled bucket produces a record.
58#[allow(clippy::too_many_arguments)]
59pub fn maybe_write_access_log(
60    state: &SharedS3State,
61    source_bucket: &str,
62    operation: &str,
63    key: Option<&str>,
64    status: u16,
65    request_id: &str,
66    method: &str,
67    path: &str,
68) {
69    // Read logging config from the source bucket
70    let logging_config_xml = {
71        let st = state.read();
72        st.buckets
73            .get(source_bucket)
74            .and_then(|b| b.logging_config.clone())
75    };
76
77    let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
78        Some(c) => c,
79        None => return,
80    };
81
82    let bucket_owner = {
83        let st = state.read();
84        st.buckets
85            .get(source_bucket)
86            .map(|b| b.acl_owner_id.clone())
87            .unwrap_or_else(|| "unknown".to_string())
88    };
89
90    let entry = format_access_log_entry(
91        &bucket_owner,
92        source_bucket,
93        operation,
94        key,
95        status,
96        request_id,
97        method,
98        path,
99    );
100
101    let now = Utc::now();
102    let log_key = format!(
103        "{}{}",
104        config.target_prefix,
105        now.format("%Y-%m-%d-%H-%M-%S-")
106    ) + &Uuid::new_v4().to_string()[..8];
107
108    let data = Bytes::from(entry);
109    let size = data.len() as u64;
110    let etag = format!("{:x}", Md5::digest(&data));
111
112    let log_object = S3Object {
113        key: log_key.clone(),
114        data,
115        content_type: "text/plain".to_string(),
116        etag,
117        size,
118        last_modified: now,
119        metadata: Default::default(),
120        storage_class: "STANDARD".to_string(),
121        tags: Default::default(),
122        acl_grants: vec![],
123        acl_owner_id: None,
124        parts_count: None,
125        part_sizes: None,
126        sse_algorithm: None,
127        sse_kms_key_id: None,
128        bucket_key_enabled: None,
129        version_id: None,
130        is_delete_marker: false,
131        content_encoding: None,
132        website_redirect_location: None,
133        restore_ongoing: None,
134        restore_expiry: None,
135        checksum_algorithm: None,
136        checksum_value: None,
137        lock_mode: None,
138        lock_retain_until: None,
139        lock_legal_hold: None,
140    };
141
142    let mut st = state.write();
143    if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
144        target.objects.insert(log_key, log_object);
145    }
146}
147
148/// Determine the S3 operation name from the HTTP method and key presence.
149pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
150    match (method.as_str(), key) {
151        ("GET", None) => "GET.BUCKET",
152        ("GET", Some(_)) => "GET.OBJECT",
153        ("PUT", None) => "PUT.BUCKET",
154        ("PUT", Some(_)) => "PUT.OBJECT",
155        ("DELETE", None) => "DELETE.BUCKET",
156        ("DELETE", Some(_)) => "DELETE.OBJECT",
157        ("HEAD", None) => "HEAD.BUCKET",
158        ("HEAD", Some(_)) => "HEAD.OBJECT",
159        ("POST", _) => "POST",
160        _ => "UNKNOWN",
161    }
162}
163
164fn extract_tag(body: &str, tag: &str) -> Option<String> {
165    let open = format!("<{tag}>");
166    let close = format!("</{tag}>");
167    let start = body.find(&open)?;
168    let content_start = start + open.len();
169    let end = body[content_start..].find(&close)?;
170    Some(body[content_start..content_start + end].trim().to_string())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parse_logging_config_enabled() {
179        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
180            <LoggingEnabled>
181                <TargetBucket>log-bucket</TargetBucket>
182                <TargetPrefix>logs/</TargetPrefix>
183            </LoggingEnabled>
184        </BucketLoggingStatus>"#;
185
186        let config = parse_logging_config(xml).unwrap();
187        assert_eq!(config.target_bucket, "log-bucket");
188        assert_eq!(config.target_prefix, "logs/");
189    }
190
191    #[test]
192    fn parse_logging_config_disabled() {
193        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
194        </BucketLoggingStatus>"#;
195
196        assert!(parse_logging_config(xml).is_none());
197    }
198
199    #[test]
200    fn format_log_entry_contains_fields() {
201        let entry = format_access_log_entry(
202            "owner123",
203            "my-bucket",
204            "GET.OBJECT",
205            Some("my-key.txt"),
206            200,
207            "req-abc",
208            "GET",
209            "/my-bucket/my-key.txt",
210        );
211        assert!(entry.contains("owner123"));
212        assert!(entry.contains("my-bucket"));
213        assert!(entry.contains("GET.OBJECT"));
214        assert!(entry.contains("my-key.txt"));
215        assert!(entry.contains("200"));
216        assert!(entry.contains("req-abc"));
217        assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
218    }
219}