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
13pub struct LoggingConfig {
15 pub target_bucket: String,
16 pub target_prefix: String,
17}
18
19pub 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
35pub 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
45pub 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 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
70pub fn maybe_write_access_log(
76 state: &SharedS3State,
77 store: &Arc<dyn S3Store>,
78 source_bucket: &str,
79 request: &AccessLogRequest<'_>,
80) {
81 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
173pub 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}