1use std::collections::HashMap;
39use std::sync::RwLock;
40
41use serde::{Deserialize, Serialize};
42
43pub const MAX_TAGS_PER_OBJECT: usize = 10;
45pub const MAX_TAG_KEY_BYTES: usize = 128;
47pub const MAX_TAG_VALUE_BYTES: usize = 256;
49
50#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct TagSet(pub Vec<(String, String)>);
56
57impl TagSet {
58 #[must_use]
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 pub fn from_pairs(pairs: Vec<(String, String)>) -> Result<Self, TagError> {
68 let s = Self(pairs);
69 s.validate()?;
70 Ok(s)
71 }
72
73 #[must_use]
75 pub fn get(&self, key: &str) -> Option<&str> {
76 self.0
77 .iter()
78 .rev()
79 .find(|(k, _)| k == key)
80 .map(|(_, v)| v.as_str())
81 }
82
83 pub fn iter(&self) -> impl Iterator<Item = &(String, String)> {
85 self.0.iter()
86 }
87
88 #[must_use]
89 pub fn len(&self) -> usize {
90 self.0.len()
91 }
92
93 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.0.is_empty()
96 }
97
98 pub fn validate(&self) -> Result<(), TagError> {
103 if self.0.len() > MAX_TAGS_PER_OBJECT {
104 return Err(TagError::TooMany { got: self.0.len() });
105 }
106 for (k, v) in &self.0 {
107 if k.len() > MAX_TAG_KEY_BYTES {
108 return Err(TagError::KeyTooLong { len: k.len() });
109 }
110 if v.len() > MAX_TAG_VALUE_BYTES {
111 return Err(TagError::ValueTooLong { len: v.len() });
112 }
113 }
114 Ok(())
115 }
116}
117
118#[derive(Debug, thiserror::Error)]
120pub enum TagError {
121 #[error("too many tags: {got} (max {})", MAX_TAGS_PER_OBJECT)]
122 TooMany { got: usize },
123 #[error("tag key too long: {len} bytes (max {})", MAX_TAG_KEY_BYTES)]
124 KeyTooLong { len: usize },
125 #[error("tag value too long: {len} bytes (max {})", MAX_TAG_VALUE_BYTES)]
126 ValueTooLong { len: usize },
127 #[error("invalid tag header (URL-encoded): {0}")]
128 InvalidHeader(String),
129}
130
131#[derive(Debug, Default, Serialize, Deserialize)]
134struct TagSnapshot {
135 objects: Vec<((String, String), TagSet)>,
136 buckets: HashMap<String, TagSet>,
137}
138
139#[derive(Debug, Default)]
143pub struct TagManager {
144 objects: RwLock<HashMap<(String, String), TagSet>>,
146 buckets: RwLock<HashMap<String, TagSet>>,
148}
149
150impl TagManager {
151 #[must_use]
153 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub fn put_object_tags(&self, bucket: &str, key: &str, tags: TagSet) {
160 self.objects
161 .write()
162 .expect("tagging objects RwLock poisoned")
163 .insert((bucket.to_owned(), key.to_owned()), tags);
164 }
165
166 #[must_use]
169 pub fn get_object_tags(&self, bucket: &str, key: &str) -> Option<TagSet> {
170 self.objects
171 .read()
172 .expect("tagging objects RwLock poisoned")
173 .get(&(bucket.to_owned(), key.to_owned()))
174 .cloned()
175 }
176
177 pub fn delete_object_tags(&self, bucket: &str, key: &str) {
180 self.objects
181 .write()
182 .expect("tagging objects RwLock poisoned")
183 .remove(&(bucket.to_owned(), key.to_owned()));
184 }
185
186 pub fn put_bucket_tags(&self, bucket: &str, tags: TagSet) {
188 self.buckets
189 .write()
190 .expect("tagging buckets RwLock poisoned")
191 .insert(bucket.to_owned(), tags);
192 }
193
194 #[must_use]
196 pub fn get_bucket_tags(&self, bucket: &str) -> Option<TagSet> {
197 self.buckets
198 .read()
199 .expect("tagging buckets RwLock poisoned")
200 .get(bucket)
201 .cloned()
202 }
203
204 pub fn delete_bucket_tags(&self, bucket: &str) {
206 self.buckets
207 .write()
208 .expect("tagging buckets RwLock poisoned")
209 .remove(bucket);
210 }
211
212 pub fn to_json(&self) -> Result<String, serde_json::Error> {
215 let objects: Vec<((String, String), TagSet)> = self
216 .objects
217 .read()
218 .expect("tagging objects RwLock poisoned")
219 .iter()
220 .map(|(k, v)| (k.clone(), v.clone()))
221 .collect();
222 let buckets = self
223 .buckets
224 .read()
225 .expect("tagging buckets RwLock poisoned")
226 .clone();
227 let snap = TagSnapshot { objects, buckets };
228 serde_json::to_string(&snap)
229 }
230
231 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
233 let snap: TagSnapshot = serde_json::from_str(s)?;
234 let mut objects = HashMap::with_capacity(snap.objects.len());
235 for (k, v) in snap.objects {
236 objects.insert(k, v);
237 }
238 Ok(Self {
239 objects: RwLock::new(objects),
240 buckets: RwLock::new(snap.buckets),
241 })
242 }
243}
244
245pub fn parse_tagging_header(header: &str) -> Result<TagSet, TagError> {
253 let trimmed = header.trim();
254 if trimmed.is_empty() {
255 return Ok(TagSet::new());
256 }
257 let mut pairs = Vec::new();
258 for part in trimmed.split('&') {
259 if part.is_empty() {
260 continue;
261 }
262 let (raw_k, raw_v) = match part.split_once('=') {
263 Some((k, v)) => (k, v),
264 None => (part, ""),
265 };
266 let k = url_decode(raw_k)
267 .map_err(|e| TagError::InvalidHeader(format!("key {raw_k:?}: {e}")))?;
268 let v = url_decode(raw_v)
269 .map_err(|e| TagError::InvalidHeader(format!("value {raw_v:?}: {e}")))?;
270 pairs.push((k, v));
271 }
272 TagSet::from_pairs(pairs)
273}
274
275#[must_use]
279pub fn render_tagging_header(tags: &TagSet) -> String {
280 let mut out = String::new();
281 for (i, (k, v)) in tags.iter().enumerate() {
282 if i > 0 {
283 out.push('&');
284 }
285 url_encode_to(&mut out, k);
286 out.push('=');
287 url_encode_to(&mut out, v);
288 }
289 out
290}
291
292fn url_decode(s: &str) -> Result<String, String> {
298 let bytes = s.as_bytes();
299 let mut out = Vec::with_capacity(bytes.len());
300 let mut i = 0;
301 while i < bytes.len() {
302 match bytes[i] {
303 b'+' => {
304 out.push(b' ');
305 i += 1;
306 }
307 b'%' => {
308 if i + 2 >= bytes.len() {
309 return Err(format!("truncated %-escape at byte {i}"));
310 }
311 let hi = hex_digit(bytes[i + 1])
312 .ok_or_else(|| format!("non-hex byte after % at {}", i + 1))?;
313 let lo = hex_digit(bytes[i + 2])
314 .ok_or_else(|| format!("non-hex byte after % at {}", i + 2))?;
315 out.push((hi << 4) | lo);
316 i += 3;
317 }
318 b => {
319 out.push(b);
320 i += 1;
321 }
322 }
323 }
324 String::from_utf8(out).map_err(|e| format!("invalid UTF-8: {e}"))
325}
326
327fn hex_digit(b: u8) -> Option<u8> {
328 match b {
329 b'0'..=b'9' => Some(b - b'0'),
330 b'a'..=b'f' => Some(10 + b - b'a'),
331 b'A'..=b'F' => Some(10 + b - b'A'),
332 _ => None,
333 }
334}
335
336fn url_encode_to(out: &mut String, s: &str) {
341 for &b in s.as_bytes() {
342 let unreserved = b.is_ascii_alphanumeric()
343 || b == b'-'
344 || b == b'_'
345 || b == b'.'
346 || b == b'~';
347 if unreserved {
348 out.push(b as char);
349 } else {
350 out.push('%');
351 out.push(HEX[((b >> 4) & 0x0F) as usize] as char);
352 out.push(HEX[(b & 0x0F) as usize] as char);
353 }
354 }
355}
356
357const HEX: &[u8; 16] = b"0123456789ABCDEF";
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn from_pairs_too_many_rejected() {
365 let pairs: Vec<(String, String)> = (0..11)
366 .map(|i| (format!("k{i}"), format!("v{i}")))
367 .collect();
368 let err = TagSet::from_pairs(pairs).expect_err("must reject 11 pairs");
369 assert!(matches!(err, TagError::TooMany { got: 11 }));
370 }
371
372 #[test]
373 fn from_pairs_long_key_rejected() {
374 let pairs = vec![("k".repeat(129), "v".into())];
375 let err = TagSet::from_pairs(pairs).expect_err("must reject 129-byte key");
376 assert!(matches!(err, TagError::KeyTooLong { len: 129 }));
377 }
378
379 #[test]
380 fn from_pairs_long_value_rejected() {
381 let pairs = vec![("k".into(), "v".repeat(257))];
382 let err = TagSet::from_pairs(pairs).expect_err("must reject 257-byte value");
383 assert!(matches!(err, TagError::ValueTooLong { len: 257 }));
384 }
385
386 #[test]
387 fn from_pairs_at_limits_accepted() {
388 let pairs: Vec<(String, String)> = (0..10)
391 .map(|i| {
392 let k = format!("k{i}");
393 let v = format!("v{i}");
394 let k = format!("{k:k<128}");
395 let v = format!("{v:v<256}");
396 (k, v)
397 })
398 .collect();
399 for (k, v) in &pairs {
401 assert_eq!(k.len(), 128);
402 assert_eq!(v.len(), 256);
403 }
404 let s = TagSet::from_pairs(pairs).expect("at-limit pairs must pass");
405 assert_eq!(s.len(), 10);
406 }
407
408 #[test]
409 fn parse_tagging_header_basic() {
410 let s = parse_tagging_header("K1=V1&K2=V2").expect("parse");
411 assert_eq!(s.len(), 2);
412 assert_eq!(s.get("K1"), Some("V1"));
413 assert_eq!(s.get("K2"), Some("V2"));
414 }
415
416 #[test]
417 fn parse_tagging_header_url_encoded_values() {
418 let s = parse_tagging_header("Path=foo%2Fbar&Greet=hello%20world&Plus=a+b")
420 .expect("parse");
421 assert_eq!(s.get("Path"), Some("foo/bar"));
422 assert_eq!(s.get("Greet"), Some("hello world"));
423 assert_eq!(s.get("Plus"), Some("a b"));
424 }
425
426 #[test]
427 fn parse_tagging_header_empty_value() {
428 let s = parse_tagging_header("Bare").expect("parse");
429 assert_eq!(s.get("Bare"), Some(""));
430 let s2 = parse_tagging_header("K=").expect("parse");
431 assert_eq!(s2.get("K"), Some(""));
432 }
433
434 #[test]
435 fn parse_tagging_header_empty_returns_empty_set() {
436 let s = parse_tagging_header("").expect("parse");
437 assert!(s.is_empty());
438 let s2 = parse_tagging_header(" ").expect("parse");
439 assert!(s2.is_empty());
440 }
441
442 #[test]
443 fn parse_tagging_header_truncated_escape_rejected() {
444 let err = parse_tagging_header("K=%2").expect_err("truncated");
445 assert!(matches!(err, TagError::InvalidHeader(_)));
446 }
447
448 #[test]
449 fn render_tagging_header_round_trip() {
450 let original = TagSet::from_pairs(vec![
451 ("Project".into(), "Phoenix".into()),
452 ("Env".into(), "prod with space".into()),
453 ("Path".into(), "data/2026".into()),
454 ])
455 .expect("ts");
456 let rendered = render_tagging_header(&original);
457 let parsed = parse_tagging_header(&rendered).expect("parse");
458 assert_eq!(parsed, original);
459 }
460
461 #[test]
462 fn manager_object_put_get_delete() {
463 let m = TagManager::new();
464 let tags =
465 TagSet::from_pairs(vec![("Owner".into(), "alice".into())]).expect("ts");
466 m.put_object_tags("b", "k", tags.clone());
467 assert_eq!(m.get_object_tags("b", "k"), Some(tags));
468 m.delete_object_tags("b", "k");
469 assert!(m.get_object_tags("b", "k").is_none());
470 m.delete_object_tags("b", "k");
472 }
473
474 #[test]
475 fn manager_bucket_put_get_delete() {
476 let m = TagManager::new();
477 let tags =
478 TagSet::from_pairs(vec![("CostCenter".into(), "42".into())]).expect("ts");
479 m.put_bucket_tags("b", tags.clone());
480 assert_eq!(m.get_bucket_tags("b"), Some(tags));
481 m.delete_bucket_tags("b");
482 assert!(m.get_bucket_tags("b").is_none());
483 }
484
485 #[test]
486 fn manager_object_and_bucket_independent() {
487 let m = TagManager::new();
491 m.put_object_tags(
492 "b",
493 "k",
494 TagSet::from_pairs(vec![("o".into(), "1".into())]).unwrap(),
495 );
496 m.put_bucket_tags("b", TagSet::from_pairs(vec![("b".into(), "2".into())]).unwrap());
497 assert_eq!(m.get_object_tags("b", "k").unwrap().get("o"), Some("1"));
498 assert!(m.get_object_tags("b", "k").unwrap().get("b").is_none());
499 assert_eq!(m.get_bucket_tags("b").unwrap().get("b"), Some("2"));
500 assert!(m.get_bucket_tags("b").unwrap().get("o").is_none());
501 }
502
503 #[test]
504 fn manager_json_snapshot_round_trip() {
505 let m = TagManager::new();
506 m.put_object_tags(
507 "b1",
508 "k1",
509 TagSet::from_pairs(vec![("Project".into(), "Phoenix".into())]).unwrap(),
510 );
511 m.put_object_tags(
512 "b2",
513 "k2",
514 TagSet::from_pairs(vec![("Env".into(), "prod".into())]).unwrap(),
515 );
516 m.put_bucket_tags(
517 "b1",
518 TagSet::from_pairs(vec![("CostCenter".into(), "42".into())]).unwrap(),
519 );
520 let json = m.to_json().expect("to_json");
521 let m2 = TagManager::from_json(&json).expect("from_json");
522 assert_eq!(
523 m2.get_object_tags("b1", "k1").unwrap().get("Project"),
524 Some("Phoenix")
525 );
526 assert_eq!(
527 m2.get_object_tags("b2", "k2").unwrap().get("Env"),
528 Some("prod")
529 );
530 assert_eq!(
531 m2.get_bucket_tags("b1").unwrap().get("CostCenter"),
532 Some("42")
533 );
534 }
535
536 #[test]
537 fn tag_set_get_last_wins_on_duplicate_keys() {
538 let s = parse_tagging_header("K=A&K=B").expect("parse");
540 assert_eq!(s.get("K"), Some("B"));
541 }
542}