[pbs-devel] [RFC proxmox-backup 08/39] s3 client: add helper for last modified timestamp parsing

Christian Ebner c.ebner at proxmox.com
Mon May 19 13:46:09 CEST 2025


Adds a helper to parse modified timestamps as encountered in s3 list
objects v2 and copy object api calls. Further, allow to convert a
timestamp to a Duration since unix epoch in order for easy comparison
between timestamps during phase 2 of garbage collection.

Signed-off-by: Christian Ebner <c.ebner at proxmox.com>
---
 Cargo.toml               |   1 +
 pbs-s3-client/Cargo.toml |   2 +
 pbs-s3-client/src/lib.rs | 118 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 121 insertions(+)

diff --git a/Cargo.toml b/Cargo.toml
index c2b0029ac..3f51b356c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -144,6 +144,7 @@ regex = "1.5.5"
 rustyline = "9"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
+serde_plain = "1.0"
 siphasher = "0.3"
 syslog = "6"
 tar = "0.4"
diff --git a/pbs-s3-client/Cargo.toml b/pbs-s3-client/Cargo.toml
index 11189ea50..9ee546200 100644
--- a/pbs-s3-client/Cargo.toml
+++ b/pbs-s3-client/Cargo.toml
@@ -11,6 +11,8 @@ anyhow.workspace = true
 hex = { workspace = true, features = [ "serde" ] }
 hyper.workspace = true
 openssl.workspace = true
+serde.workspace = true
+serde_plain.workspace = true
 tracing.workspace = true
 url.workspace = true
 
diff --git a/pbs-s3-client/src/lib.rs b/pbs-s3-client/src/lib.rs
index a4081df15..308db64d8 100644
--- a/pbs-s3-client/src/lib.rs
+++ b/pbs-s3-client/src/lib.rs
@@ -3,3 +3,121 @@ mod client;
 pub use client::{S3Client, S3ClientOptions};
 mod object_key;
 pub use object_key::{S3ObjectKey, S3_CONTENT_PREFIX};
+
+use std::time::Duration;
+
+use anyhow::{bail, Error};
+
+#[derive(Debug)]
+pub struct LastModifiedTimestamp {
+    epoch: i64,
+    milliseconds: u64,
+}
+
+impl LastModifiedTimestamp {
+    pub fn to_duration(&self) -> Result<Duration, Error> {
+        let secs = u64::try_from(self.epoch)?;
+        let mut duration = Duration::from_secs(secs);
+        duration += Duration::from_millis(self.milliseconds);
+        Ok(duration)
+    }
+}
+
+impl std::str::FromStr for LastModifiedTimestamp {
+    type Err = Error;
+
+    fn from_str(timestamp: &str) -> Result<Self, Self::Err> {
+        let input = timestamp.as_bytes();
+
+        let expect = |pos: usize, c: u8| {
+            if input[pos] != c {
+                bail!("unexpected char at pos {pos}");
+            }
+            Ok(())
+        };
+
+        let digit = |pos: usize| -> Result<i32, Error> {
+            let digit = input[pos] as i32;
+            if !(48..=57).contains(&digit) {
+                bail!("unexpected char at pos {pos}");
+            }
+            Ok(digit - 48)
+        };
+
+        fn check_max(i: i32, max: i32) -> Result<i32, Error> {
+            if i > max {
+                bail!("value too large ({i} > {max})");
+            }
+            Ok(i)
+        }
+
+        if input.len() < 20 || input.len() > 25 {
+            bail!("timestamp of unexpected length");
+        }
+
+        if b'.' != input[19] {
+            bail!("unexpected milliseconds separator");
+        }
+        let tz = input[23];
+
+        match tz {
+            b'Z' => {
+                if input.len() != 24 {
+                    bail!("unexpected length in UTC timestamp");
+                }
+            }
+            b'+' | b'-' => {
+                if input.len() != 29 {
+                    bail!("unexpected length in timestamp");
+                }
+            }
+            _ => bail!("unexpected timezone indicator"),
+        }
+
+        let mut tm = proxmox_time::TmEditor::new(true);
+
+        tm.set_year(digit(0)? * 1000 + digit(1)? * 100 + digit(2)? * 10 + digit(3)?)?;
+        expect(4, b'-')?;
+        tm.set_mon(check_max(digit(5)? * 10 + digit(6)?, 12)?)?;
+        expect(7, b'-')?;
+        tm.set_mday(check_max(digit(8)? * 10 + digit(9)?, 31)?)?;
+
+        expect(10, b'T')?;
+
+        tm.set_hour(check_max(digit(11)? * 10 + digit(12)?, 23)?)?;
+        expect(13, b':')?;
+        tm.set_min(check_max(digit(14)? * 10 + digit(15)?, 59)?)?;
+        expect(16, b':')?;
+        tm.set_sec(check_max(digit(17)? * 10 + digit(18)?, 60)?)?;
+        expect(19, b'.')?;
+        let milliseconds: u64 = String::from_utf8(input[20..23].to_vec())?.parse()?;
+
+        let epoch = tm.into_epoch()?;
+
+        if tz == b'Z' {
+            return Ok(Self {
+                epoch,
+                milliseconds,
+            });
+        }
+
+        let hours = check_max(digit(20)? * 10 + digit(21)?, 23)?;
+        expect(22, b':')?;
+        let mins = check_max(digit(23)? * 10 + digit(24)?, 59)?;
+
+        let offset = (hours * 3600 + mins * 60) as i64;
+
+        let epoch = match tz {
+            b'+' => epoch - offset,
+            b'-' => epoch + offset,
+            _ => unreachable!(), // already checked above
+        };
+
+        Ok(Self {
+            epoch,
+            milliseconds,
+        })
+    }
+}
+
+serde_plain::derive_deserialize_from_fromstr!(LastModifiedTimestamp, "last modified timestamp");
-- 
2.39.5





More information about the pbs-devel mailing list