Skip to main content

fakecloud_s3/
logging.rs

1use std::sync::Arc;
2
3use bytes::Bytes;
4use chrono::Utc;
5use fakecloud_persistence::{BodySource, S3Store};
6use md5::{Digest, Md5};
7use uuid::Uuid;
8
9use crate::persistence::object_meta_snapshot;
10use crate::state::{S3Object, SharedS3State};
11use crate::xml_util::extract_tag;
12
13/// Parsed logging configuration extracted from the XML stored on the bucket.
14pub struct LoggingConfig {
15    pub target_bucket: String,
16    pub target_prefix: String,
17}
18
19/// Parse a `<BucketLoggingStatus>` XML body into a `LoggingConfig`, if logging
20/// is enabled (i.e. the `<LoggingEnabled>` element is present).
21pub fn parse_logging_config(xml: &str) -> Option<LoggingConfig> {
22    let le_start = xml.find("<LoggingEnabled>")?;
23    let le_end = xml.find("</LoggingEnabled>")?;
24    let le_body = &xml[le_start + 16..le_end];
25
26    let target_bucket = extract_tag(le_body, "TargetBucket")?;
27    let target_prefix = extract_tag(le_body, "TargetPrefix").unwrap_or_default();
28
29    Some(LoggingConfig {
30        target_bucket,
31        target_prefix,
32    })
33}
34
35/// Everything needed to describe a single S3 request for access logging.
36pub struct AccessLogRequest<'a> {
37    pub operation: &'a str,
38    pub key: Option<&'a str>,
39    pub status: u16,
40    pub request_id: &'a str,
41    pub method: &'a str,
42    pub path: &'a str,
43}
44
45/// Generate an S3 access log line in a format similar to AWS.
46///
47/// See <https://docs.aws.amazon.com/AmazonS3/latest/userguide/LogFormat.html>
48pub fn format_access_log_entry(
49    bucket_owner: &str,
50    bucket: &str,
51    request: &AccessLogRequest<'_>,
52) -> String {
53    let now = Utc::now();
54    let time = now.format("[%d/%b/%Y:%H:%M:%S %z]");
55    let key_str = request.key.unwrap_or("-");
56    let AccessLogRequest {
57        operation,
58        status,
59        request_id,
60        method,
61        path,
62        ..
63    } = request;
64    // Simplified log line matching the AWS format fields
65    format!(
66        "{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"
67    )
68}
69
70/// After a request has been processed, check whether the source bucket has
71/// logging enabled and, if so, write a log entry to the target bucket.
72///
73/// This should be called at the end of the `handle` method so that every S3
74/// operation on a logging-enabled bucket produces a record.
75pub fn maybe_write_access_log(
76    state: &SharedS3State,
77    store: &Arc<dyn S3Store>,
78    source_bucket: &str,
79    request: &AccessLogRequest<'_>,
80) {
81    // Read logging config from the source bucket
82    let (logging_config_xml, bucket_owner) = {
83        let mas = state.read();
84        let acct = match mas.find_account(|s| s.buckets.contains_key(source_bucket)) {
85            Some(a) => a,
86            None => return,
87        };
88        let st = match mas.get(acct) {
89            Some(s) => s,
90            None => return,
91        };
92        let config_xml = st
93            .buckets
94            .get(source_bucket)
95            .and_then(|b| b.logging_config.clone());
96        let owner = st
97            .buckets
98            .get(source_bucket)
99            .map(|b| b.acl_owner_id.clone())
100            .unwrap_or_else(|| "unknown".to_string());
101        (config_xml, owner)
102    };
103
104    let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
105        Some(c) => c,
106        None => return,
107    };
108
109    let entry = format_access_log_entry(&bucket_owner, source_bucket, request);
110
111    let now = Utc::now();
112    let log_key = format!(
113        "{}{}",
114        config.target_prefix,
115        now.format("%Y-%m-%d-%H-%M-%S-")
116    ) + &Uuid::new_v4().to_string()[..8];
117
118    let data = Bytes::from(entry);
119    let size = data.len() as u64;
120    let etag = format!("{:x}", Md5::digest(&data));
121
122    let log_object = S3Object {
123        key: log_key.clone(),
124        body: crate::state::memory_body(data.clone()),
125        content_type: "text/plain".to_string(),
126        etag,
127        size,
128        last_modified: now,
129        storage_class: "STANDARD".to_string(),
130        ..Default::default()
131    };
132
133    let meta = object_meta_snapshot(&log_object);
134    {
135        let mut mas = state.write();
136        let target_acct = mas
137            .find_account(|s| s.buckets.contains_key(&config.target_bucket))
138            .map(|a| a.to_string());
139        let inserted = if let Some(acct) = target_acct {
140            if let Some(st) = mas.get_mut(&acct) {
141                if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
142                    target.objects.insert(log_key.clone(), log_object);
143                    true
144                } else {
145                    false
146                }
147            } else {
148                false
149            }
150        } else {
151            false
152        };
153        if !inserted {
154            return;
155        }
156    }
157    if let Err(err) = store.put_object(
158        &config.target_bucket,
159        &log_key,
160        None,
161        BodySource::Bytes(data),
162        &meta,
163    ) {
164        tracing::error!(
165            bucket = %config.target_bucket,
166            key = %log_key,
167            error = %err,
168            "failed to persist S3 access log object via store"
169        );
170    }
171}
172
173/// Determine the S3 operation name from the HTTP method and key presence.
174pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
175    match (method.as_str(), key) {
176        ("GET", None) => "GET.BUCKET",
177        ("GET", Some(_)) => "GET.OBJECT",
178        ("PUT", None) => "PUT.BUCKET",
179        ("PUT", Some(_)) => "PUT.OBJECT",
180        ("DELETE", None) => "DELETE.BUCKET",
181        ("DELETE", Some(_)) => "DELETE.OBJECT",
182        ("HEAD", None) => "HEAD.BUCKET",
183        ("HEAD", Some(_)) => "HEAD.OBJECT",
184        ("POST", _) => "POST",
185        _ => "UNKNOWN",
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn parse_logging_config_enabled() {
195        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
196            <LoggingEnabled>
197                <TargetBucket>log-bucket</TargetBucket>
198                <TargetPrefix>logs/</TargetPrefix>
199            </LoggingEnabled>
200        </BucketLoggingStatus>"#;
201
202        let config = parse_logging_config(xml).unwrap();
203        assert_eq!(config.target_bucket, "log-bucket");
204        assert_eq!(config.target_prefix, "logs/");
205    }
206
207    #[test]
208    fn parse_logging_config_disabled() {
209        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
210        </BucketLoggingStatus>"#;
211
212        assert!(parse_logging_config(xml).is_none());
213    }
214
215    #[test]
216    fn format_log_entry_contains_fields() {
217        let request = AccessLogRequest {
218            operation: "GET.OBJECT",
219            key: Some("my-key.txt"),
220            status: 200,
221            request_id: "req-abc",
222            method: "GET",
223            path: "/my-bucket/my-key.txt",
224        };
225        let entry = format_access_log_entry("owner123", "my-bucket", &request);
226        assert!(entry.contains("owner123"));
227        assert!(entry.contains("my-bucket"));
228        assert!(entry.contains("GET.OBJECT"));
229        assert!(entry.contains("my-key.txt"));
230        assert!(entry.contains("200"));
231        assert!(entry.contains("req-abc"));
232        assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
233    }
234
235    #[test]
236    fn parse_logging_config_missing_target_bucket_returns_none() {
237        let xml = r#"<BucketLoggingStatus>
238            <LoggingEnabled><TargetPrefix>logs/</TargetPrefix></LoggingEnabled>
239        </BucketLoggingStatus>"#;
240        assert!(parse_logging_config(xml).is_none());
241    }
242
243    #[test]
244    fn parse_logging_config_empty_prefix_defaults_to_empty_string() {
245        let xml = r#"<BucketLoggingStatus>
246            <LoggingEnabled><TargetBucket>log</TargetBucket></LoggingEnabled>
247        </BucketLoggingStatus>"#;
248        let cfg = parse_logging_config(xml).unwrap();
249        assert_eq!(cfg.target_bucket, "log");
250        assert_eq!(cfg.target_prefix, "");
251    }
252
253    #[test]
254    fn format_log_entry_replaces_missing_key_with_dash() {
255        let request = AccessLogRequest {
256            operation: "GET.BUCKET",
257            key: None,
258            status: 200,
259            request_id: "req-x",
260            method: "GET",
261            path: "/my-bucket",
262        };
263        let entry = format_access_log_entry("owner", "my-bucket", &request);
264        assert!(entry.contains("REST.GET.BUCKET - "));
265    }
266
267    #[test]
268    fn operation_name_maps_all_common_methods() {
269        use http::Method;
270        assert_eq!(operation_name(&Method::GET, None), "GET.BUCKET");
271        assert_eq!(operation_name(&Method::GET, Some("k")), "GET.OBJECT");
272        assert_eq!(operation_name(&Method::PUT, None), "PUT.BUCKET");
273        assert_eq!(operation_name(&Method::PUT, Some("k")), "PUT.OBJECT");
274        assert_eq!(operation_name(&Method::DELETE, None), "DELETE.BUCKET");
275        assert_eq!(operation_name(&Method::DELETE, Some("k")), "DELETE.OBJECT");
276        assert_eq!(operation_name(&Method::HEAD, None), "HEAD.BUCKET");
277        assert_eq!(operation_name(&Method::HEAD, Some("k")), "HEAD.OBJECT");
278        assert_eq!(operation_name(&Method::POST, None), "POST");
279        assert_eq!(operation_name(&Method::POST, Some("k")), "POST");
280        assert_eq!(operation_name(&Method::OPTIONS, None), "UNKNOWN");
281    }
282}