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 fn eager_delivery_enabled() -> bool {
25 static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
26 *ENABLED.get_or_init(|| {
27 std::env::var("FAKECLOUD_S3_EAGER_DELIVERY")
28 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
29 .unwrap_or(false)
30 })
31}
32
33pub struct LoggingConfig {
35 pub target_bucket: String,
36 pub target_prefix: String,
37}
38
39pub(crate) fn parse_logging_config(xml: &str) -> Option<LoggingConfig> {
42 let le_start = xml.find("<LoggingEnabled>")?;
43 let le_end = xml.find("</LoggingEnabled>")?;
44 let le_body = &xml[le_start + 16..le_end];
45
46 let target_bucket = extract_tag(le_body, "TargetBucket")?;
47 let target_prefix = extract_tag(le_body, "TargetPrefix").unwrap_or_default();
48
49 Some(LoggingConfig {
50 target_bucket,
51 target_prefix,
52 })
53}
54
55pub struct AccessLogRequest<'a> {
57 pub operation: &'a str,
58 pub key: Option<&'a str>,
59 pub status: u16,
60 pub request_id: &'a str,
61 pub method: &'a str,
62 pub path: &'a str,
63}
64
65pub fn format_access_log_entry(
69 bucket_owner: &str,
70 bucket: &str,
71 request: &AccessLogRequest<'_>,
72) -> String {
73 let now = Utc::now();
74 let time = now.format("[%d/%b/%Y:%H:%M:%S %z]");
75 let key_str = request.key.unwrap_or("-");
76 let AccessLogRequest {
77 operation,
78 status,
79 request_id,
80 method,
81 path,
82 ..
83 } = request;
84 format!(
86 "{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"
87 )
88}
89
90pub fn maybe_write_access_log(
96 state: &SharedS3State,
97 store: &Arc<dyn S3Store>,
98 source_bucket: &str,
99 request: &AccessLogRequest<'_>,
100) {
101 if !eager_delivery_enabled() {
105 return;
106 }
107
108 let (logging_config_xml, bucket_owner) = {
110 let mas = state.read();
111 let acct = match mas.find_account(|s| s.buckets.contains_key(source_bucket)) {
112 Some(a) => a,
113 None => return,
114 };
115 let st = match mas.get(acct) {
116 Some(s) => s,
117 None => return,
118 };
119 let config_xml = st
120 .buckets
121 .get(source_bucket)
122 .and_then(|b| b.logging_config.clone());
123 let owner = st
124 .buckets
125 .get(source_bucket)
126 .map(|b| b.acl_owner_id.clone())
127 .unwrap_or_else(|| "unknown".to_string());
128 (config_xml, owner)
129 };
130
131 let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
132 Some(c) => c,
133 None => return,
134 };
135
136 let entry = format_access_log_entry(&bucket_owner, source_bucket, request);
137
138 let now = Utc::now();
139 let log_key = format!(
140 "{}{}",
141 config.target_prefix,
142 now.format("%Y-%m-%d-%H-%M-%S-")
143 ) + &Uuid::new_v4().to_string()[..8];
144
145 let data = Bytes::from(entry);
146 let size = data.len() as u64;
147 let etag = format!("{:x}", Md5::digest(&data));
148
149 let log_object = S3Object {
150 key: log_key.clone(),
151 body: crate::state::memory_body(data.clone()),
152 content_type: "text/plain".to_string(),
153 etag,
154 size,
155 last_modified: now,
156 storage_class: "STANDARD".to_string(),
157 ..Default::default()
158 };
159
160 let meta = object_meta_snapshot(&log_object);
161 {
162 let mut mas = state.write();
163 let target_acct = mas
164 .find_account(|s| s.buckets.contains_key(&config.target_bucket))
165 .map(|a| a.to_string());
166 let inserted = if let Some(acct) = target_acct {
167 if let Some(st) = mas.get_mut(&acct) {
168 if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
169 target.objects.insert(log_key.clone(), log_object);
170 true
171 } else {
172 false
173 }
174 } else {
175 false
176 }
177 } else {
178 false
179 };
180 if !inserted {
181 return;
182 }
183 }
184 if let Err(err) = store.put_object(
185 &config.target_bucket,
186 &log_key,
187 None,
188 BodySource::Bytes(data),
189 &meta,
190 ) {
191 tracing::error!(
192 bucket = %config.target_bucket,
193 key = %log_key,
194 error = %err,
195 "failed to persist S3 access log object via store"
196 );
197 }
198}
199
200pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
202 match (method.as_str(), key) {
203 ("GET", None) => "GET.BUCKET",
204 ("GET", Some(_)) => "GET.OBJECT",
205 ("PUT", None) => "PUT.BUCKET",
206 ("PUT", Some(_)) => "PUT.OBJECT",
207 ("DELETE", None) => "DELETE.BUCKET",
208 ("DELETE", Some(_)) => "DELETE.OBJECT",
209 ("HEAD", None) => "HEAD.BUCKET",
210 ("HEAD", Some(_)) => "HEAD.OBJECT",
211 ("POST", _) => "POST",
212 _ => "UNKNOWN",
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn parse_logging_config_enabled() {
222 let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
223 <LoggingEnabled>
224 <TargetBucket>log-bucket</TargetBucket>
225 <TargetPrefix>logs/</TargetPrefix>
226 </LoggingEnabled>
227 </BucketLoggingStatus>"#;
228
229 let config = parse_logging_config(xml).unwrap();
230 assert_eq!(config.target_bucket, "log-bucket");
231 assert_eq!(config.target_prefix, "logs/");
232 }
233
234 #[test]
235 fn parse_logging_config_disabled() {
236 let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
237 </BucketLoggingStatus>"#;
238
239 assert!(parse_logging_config(xml).is_none());
240 }
241
242 #[test]
243 fn format_log_entry_contains_fields() {
244 let request = AccessLogRequest {
245 operation: "GET.OBJECT",
246 key: Some("my-key.txt"),
247 status: 200,
248 request_id: "req-abc",
249 method: "GET",
250 path: "/my-bucket/my-key.txt",
251 };
252 let entry = format_access_log_entry("owner123", "my-bucket", &request);
253 assert!(entry.contains("owner123"));
254 assert!(entry.contains("my-bucket"));
255 assert!(entry.contains("GET.OBJECT"));
256 assert!(entry.contains("my-key.txt"));
257 assert!(entry.contains("200"));
258 assert!(entry.contains("req-abc"));
259 assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
260 }
261
262 #[test]
263 fn parse_logging_config_missing_target_bucket_returns_none() {
264 let xml = r#"<BucketLoggingStatus>
265 <LoggingEnabled><TargetPrefix>logs/</TargetPrefix></LoggingEnabled>
266 </BucketLoggingStatus>"#;
267 assert!(parse_logging_config(xml).is_none());
268 }
269
270 #[test]
271 fn parse_logging_config_empty_prefix_defaults_to_empty_string() {
272 let xml = r#"<BucketLoggingStatus>
273 <LoggingEnabled><TargetBucket>log</TargetBucket></LoggingEnabled>
274 </BucketLoggingStatus>"#;
275 let cfg = parse_logging_config(xml).unwrap();
276 assert_eq!(cfg.target_bucket, "log");
277 assert_eq!(cfg.target_prefix, "");
278 }
279
280 #[test]
281 fn format_log_entry_replaces_missing_key_with_dash() {
282 let request = AccessLogRequest {
283 operation: "GET.BUCKET",
284 key: None,
285 status: 200,
286 request_id: "req-x",
287 method: "GET",
288 path: "/my-bucket",
289 };
290 let entry = format_access_log_entry("owner", "my-bucket", &request);
291 assert!(entry.contains("REST.GET.BUCKET - "));
292 }
293
294 #[test]
295 fn operation_name_maps_all_common_methods() {
296 use http::Method;
297 assert_eq!(operation_name(&Method::GET, None), "GET.BUCKET");
298 assert_eq!(operation_name(&Method::GET, Some("k")), "GET.OBJECT");
299 assert_eq!(operation_name(&Method::PUT, None), "PUT.BUCKET");
300 assert_eq!(operation_name(&Method::PUT, Some("k")), "PUT.OBJECT");
301 assert_eq!(operation_name(&Method::DELETE, None), "DELETE.BUCKET");
302 assert_eq!(operation_name(&Method::DELETE, Some("k")), "DELETE.OBJECT");
303 assert_eq!(operation_name(&Method::HEAD, None), "HEAD.BUCKET");
304 assert_eq!(operation_name(&Method::HEAD, Some("k")), "HEAD.OBJECT");
305 assert_eq!(operation_name(&Method::POST, None), "POST");
306 assert_eq!(operation_name(&Method::POST, Some("k")), "POST");
307 assert_eq!(operation_name(&Method::OPTIONS, None), "UNKNOWN");
308 }
309}