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 = {
83        let st = state.read();
84        st.buckets
85            .get(source_bucket)
86            .and_then(|b| b.logging_config.clone())
87    };
88
89    let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
90        Some(c) => c,
91        None => return,
92    };
93
94    let bucket_owner = {
95        let st = state.read();
96        st.buckets
97            .get(source_bucket)
98            .map(|b| b.acl_owner_id.clone())
99            .unwrap_or_else(|| "unknown".to_string())
100    };
101
102    let entry = format_access_log_entry(&bucket_owner, source_bucket, request);
103
104    let now = Utc::now();
105    let log_key = format!(
106        "{}{}",
107        config.target_prefix,
108        now.format("%Y-%m-%d-%H-%M-%S-")
109    ) + &Uuid::new_v4().to_string()[..8];
110
111    let data = Bytes::from(entry);
112    let size = data.len() as u64;
113    let etag = format!("{:x}", Md5::digest(&data));
114
115    let log_object = S3Object {
116        key: log_key.clone(),
117        body: crate::state::memory_body(data.clone()),
118        content_type: "text/plain".to_string(),
119        etag,
120        size,
121        last_modified: now,
122        storage_class: "STANDARD".to_string(),
123        ..Default::default()
124    };
125
126    let meta = object_meta_snapshot(&log_object);
127    {
128        let mut st = state.write();
129        if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
130            target.objects.insert(log_key.clone(), log_object);
131        } else {
132            return;
133        }
134    }
135    if let Err(err) = store.put_object(
136        &config.target_bucket,
137        &log_key,
138        None,
139        BodySource::Bytes(data),
140        &meta,
141    ) {
142        tracing::error!(
143            bucket = %config.target_bucket,
144            key = %log_key,
145            error = %err,
146            "failed to persist S3 access log object via store"
147        );
148    }
149}
150
151/// Determine the S3 operation name from the HTTP method and key presence.
152pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
153    match (method.as_str(), key) {
154        ("GET", None) => "GET.BUCKET",
155        ("GET", Some(_)) => "GET.OBJECT",
156        ("PUT", None) => "PUT.BUCKET",
157        ("PUT", Some(_)) => "PUT.OBJECT",
158        ("DELETE", None) => "DELETE.BUCKET",
159        ("DELETE", Some(_)) => "DELETE.OBJECT",
160        ("HEAD", None) => "HEAD.BUCKET",
161        ("HEAD", Some(_)) => "HEAD.OBJECT",
162        ("POST", _) => "POST",
163        _ => "UNKNOWN",
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn parse_logging_config_enabled() {
173        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
174            <LoggingEnabled>
175                <TargetBucket>log-bucket</TargetBucket>
176                <TargetPrefix>logs/</TargetPrefix>
177            </LoggingEnabled>
178        </BucketLoggingStatus>"#;
179
180        let config = parse_logging_config(xml).unwrap();
181        assert_eq!(config.target_bucket, "log-bucket");
182        assert_eq!(config.target_prefix, "logs/");
183    }
184
185    #[test]
186    fn parse_logging_config_disabled() {
187        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
188        </BucketLoggingStatus>"#;
189
190        assert!(parse_logging_config(xml).is_none());
191    }
192
193    #[test]
194    fn format_log_entry_contains_fields() {
195        let request = AccessLogRequest {
196            operation: "GET.OBJECT",
197            key: Some("my-key.txt"),
198            status: 200,
199            request_id: "req-abc",
200            method: "GET",
201            path: "/my-bucket/my-key.txt",
202        };
203        let entry = format_access_log_entry("owner123", "my-bucket", &request);
204        assert!(entry.contains("owner123"));
205        assert!(entry.contains("my-bucket"));
206        assert!(entry.contains("GET.OBJECT"));
207        assert!(entry.contains("my-key.txt"));
208        assert!(entry.contains("200"));
209        assert!(entry.contains("req-abc"));
210        assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
211    }
212}