From deb836b3c34c3d6ee9e2620f1a798b3d0b7ede8b Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Sun, 19 Feb 2023 15:05:50 -0800 Subject: create 'adenosine' crate with library code --- adenosine/Cargo.toml | 31 ++++ adenosine/README.md | 10 ++ adenosine/src/identifiers.rs | 418 +++++++++++++++++++++++++++++++++++++++++++ adenosine/src/lib.rs | 248 +++++++++++++++++++++++++ 4 files changed, 707 insertions(+) create mode 100644 adenosine/Cargo.toml create mode 100644 adenosine/README.md create mode 100644 adenosine/src/identifiers.rs create mode 100644 adenosine/src/lib.rs diff --git a/adenosine/Cargo.toml b/adenosine/Cargo.toml new file mode 100644 index 0000000..a05aa78 --- /dev/null +++ b/adenosine/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "adenosine" +description = "Common libraries for AT protocol and bsky.app" +keywords = ["atproto"] +categories = [] + +# common across workspace +version = "0.2.0" +edition = "2021" +rust-version = "1.61" +authors = ["Bryan Newbold "] +license = "AGPL-3.0-or-later" +readme = "README.md" +repository = "https://gitlab.com/bnewbold/adenosine" + +[dependencies] +# NOTE: could try 'rustls-tls' feature instead of default native TLS? +reqwest = { version = "0.11", features = ["blocking", "json"] } +serde = { version = "1", features = ["serde_derive"] } +serde_json = "1" +base64 = "0.13" +regex = "1" +lazy_static = "1" +data-encoding = "2" +rand = "0.8" +time = { version = "0.3", features = ["formatting"] } + +# uncertain about these... +anyhow = "1" +log = "0.4" +env_logger = "0.7" diff --git a/adenosine/README.md b/adenosine/README.md new file mode 100644 index 0000000..88f630d --- /dev/null +++ b/adenosine/README.md @@ -0,0 +1,10 @@ + +`adenosine`: library code for AT protocol (atproto.com) +================================================================== + +This is a simple, enthusiast-grade library for the work-in-progress AT Protocol +([atproto.com](https://atproto.com)). It is used by both `adenosine-cli` and +`adenosine-pds`. + +NOTE: this library code is currently AGPL, but will probably switch to +something permission (eg, MIT), with the applications staying AGPL. diff --git a/adenosine/src/identifiers.rs b/adenosine/src/identifiers.rs new file mode 100644 index 0000000..7e93d71 --- /dev/null +++ b/adenosine/src/identifiers.rs @@ -0,0 +1,418 @@ +use anyhow::{anyhow, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::fmt; +use std::ops::Deref; +use std::str::FromStr; +use std::time::SystemTime; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum DidOrHost { + Did(String, String), + Host(String), +} + +impl FromStr for DidOrHost { + type Err = anyhow::Error; + + /// DID syntax is specified in: + /// + /// Lazy partial hostname regex, isn't very correct. + fn from_str(s: &str) -> Result { + lazy_static! { + static ref DID_RE: Regex = + Regex::new(r"^did:([a-z]{1,64}):([a-zA-Z0-9\-.]{1,1024})$").unwrap(); + } + lazy_static! { + static ref HOSTNAME_RE: Regex = + Regex::new(r"^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$").unwrap(); + } + if let Some(caps) = DID_RE.captures(s) { + Ok(Self::Did(caps[1].to_string(), caps[2].to_string())) + } else if HOSTNAME_RE.is_match(s) { + Ok(Self::Host(s.to_string())) + } else { + Err(anyhow!("does not match as a DID or hostname: {}", s)) + } + } +} + +impl fmt::Display for DidOrHost { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Host(v) => write!(f, "{}", v), + Self::Did(m, v) => write!(f, "did:{}:{}", m, v), + } + } +} + +#[test] +fn test_didorhost() { + assert_eq!( + DidOrHost::from_str("hyde.test").unwrap(), + DidOrHost::Host("hyde.test".to_string()) + ); + assert_eq!( + DidOrHost::from_str("did:method:blah").unwrap(), + DidOrHost::Did("method".to_string(), "blah".to_string()) + ); + + assert!(DidOrHost::from_str("barestring").is_err()); + assert!(DidOrHost::from_str("did:partial:").is_err()); + assert!(DidOrHost::from_str("").is_err()); + assert!(DidOrHost::from_str(" ").is_err()); + assert!(DidOrHost::from_str("1234").is_err()); + + assert!(DidOrHost::from_str("multi.part.domain").is_ok()); + assert!(DidOrHost::from_str("did:is:weird").is_ok()); + assert!(DidOrHost::from_str("did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok()); +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct AtUri { + pub repository: DidOrHost, + pub collection: Option, + pub record: Option, + pub fragment: Option, +} + +impl FromStr for AtUri { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + lazy_static! { + static ref ATURI_RE: Regex = Regex::new(r"^at://([a-zA-Z0-9:_\.-]+)(/(([a-zA-Z0-9\.]+))?)?(/(([a-zA-Z0-9\.-]+))?)?(#([a-zA-Z0-9/-]+))?$").unwrap(); + } + if let Some(caps) = ATURI_RE.captures(s) { + let uri = AtUri { + repository: DidOrHost::from_str(&caps[1])?, + collection: caps.get(4).map(|v| v.as_str().to_string()), + record: caps.get(7).map(|v| v.as_str().to_string()), + fragment: caps.get(9).map(|v| v.as_str().to_string()), + }; + Ok(uri) + } else { + Err(anyhow!("couldn't parse as an at:// URI: {}", s)) + } + } +} + +impl fmt::Display for AtUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "at://{}", self.repository)?; + if let Some(ref c) = self.collection { + write!(f, "/{}", c)?; + }; + if let Some(ref r) = self.record { + write!(f, "/{}", r)?; + }; + if let Some(ref v) = self.fragment { + write!(f, "#{}", v)?; + }; + Ok(()) + } +} + +#[test] +fn test_aturi() { + assert!(AtUri::from_str("at://bob.com").is_ok()); + assert!(AtUri::from_str("at://bob.com/").is_ok()); + assert!(AtUri::from_str("at://did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok()); + assert!(AtUri::from_str("at://bob.com/io.example.song").is_ok()); + assert!(AtUri::from_str("at://bob.com/io.example.song/").is_ok()); + assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a").is_ok()); + assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").is_ok()); + assert!( + AtUri::from_str("at://did:plc:ltk4reuh7rkoy2frnueetpb5/app.bsky.follow/3jg23pbmlhc2a") + .is_ok() + ); + + let uri = AtUri { + repository: DidOrHost::Did("some".to_string(), "thing".to_string()), + collection: Some("com.atproto.record".to_string()), + record: Some("asdf-123".to_string()), + fragment: Some("/path".to_string()), + }; + assert_eq!( + "at://did:some:thing/com.atproto.record/asdf-123#/path", + uri.to_string() + ); + println!("{:?}", AtUri::from_str(&uri.to_string())); + assert!(AtUri::from_str(&uri.to_string()).is_ok()); + + let uri = AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").unwrap(); + assert_eq!(uri.repository, DidOrHost::Host("bob.com".to_string())); + assert_eq!(uri.collection, Some("io.example.song".to_string())); + assert_eq!(uri.record, Some("3yI5-c1z-cc2p-1a".to_string())); + assert_eq!(uri.fragment, Some("/title".to_string())); + + let uri = AtUri::from_str("at://bob.com/io.example.song/").unwrap(); + assert_eq!(uri.repository, DidOrHost::Host("bob.com".to_string())); + assert_eq!(uri.collection, Some("io.example.song".to_string())); +} + +/// A String (newtype) representing an NSID +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +pub struct Nsid(String); + +impl FromStr for Nsid { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + lazy_static! { + static ref NSID_RE: Regex = Regex::new(r"^([a-z][a-z0-9-]+\.)+[a-zA-Z0-9-]+$").unwrap(); + } + if NSID_RE.is_match(s) { + Ok(Self(s.to_string())) + } else { + Err(anyhow!("does not match as an NSID: {}", s)) + } + } +} + +impl Deref for Nsid { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Nsid { + pub fn domain(&self) -> String { + self.rsplit('.').skip(1).collect::>().join(".") + } + + pub fn name(&self) -> String { + self.split('.') + .last() + .expect("multiple segments in NSID") + .to_string() + } +} + +impl fmt::Display for Nsid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[test] +fn test_nsid() { + assert!(Nsid::from_str("com.atproto.recordType").is_ok()); + + let nsid = Nsid::from_str("com.atproto.recordType").unwrap(); + assert_eq!(nsid.domain(), "atproto.com".to_string()); +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +pub struct Did(String); + +impl FromStr for Did { + type Err = anyhow::Error; + + /// DID syntax is specified in: + /// + /// This regex does not follow that definition exactly. + fn from_str(s: &str) -> Result { + lazy_static! { + static ref DID_RE: Regex = + Regex::new(r"^did:([a-z]{1,32}):([a-zA-Z0-9\-.]{1,256})$").unwrap(); + } + if DID_RE.is_match(s) { + Ok(Self(s.to_string())) + } else { + Err(anyhow!("does not match as a DID: {}", s)) + } + } +} + +impl Deref for Did { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Did { + pub fn did_type(&self) -> String { + self.split(':').nth(1).unwrap().to_string() + } +} + +impl fmt::Display for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[test] +fn test_did() { + assert!(Did::from_str("did:web:asdf.org").is_ok()); + assert!(Did::from_str("did:plc:asdf").is_ok()); + + assert!(Did::from_str("bob.com").is_err()); + assert!(Did::from_str("").is_err()); + assert!(Did::from_str("did:").is_err()); + assert!(Did::from_str("did:plc:").is_err()); + assert!(Did::from_str("plc:asdf").is_err()); + assert!(Did::from_str("DID:thing:thang").is_err()); + + assert_eq!( + Did::from_str("did:web:asdf.org").unwrap().did_type(), + "web".to_string() + ); +} + +lazy_static! { + /// Sortable base32 encoding, as bluesky implements/defines + static ref BASE32SORT: data_encoding::Encoding = { + let mut spec = data_encoding::Specification::new(); + spec.symbols.push_str("234567abcdefghijklmnopqrstuvwxyz"); + spec.padding = None; + spec.encoding().unwrap() + }; +} + +/// A string identifier for individual records, based on UNIX timestamp in microseconds. +/// +/// See also: https://github.com/bluesky-social/atproto/issues/334 +/// +/// Pretty permissive about what can be parsed/accepted, because there were some old TIDs floating +/// around with weird format. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Tid(String); + +impl FromStr for Tid { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + lazy_static! { + static ref TID_RE: Regex = Regex::new(r"^[0-9a-zA-Z-]{13,20}$").unwrap(); + } + if TID_RE.is_match(s) { + Ok(Self(s.to_string())) + } else { + Err(anyhow!("does not match as a TID: {}", s)) + } + } +} + +impl Deref for Tid { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for Tid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Tid { + pub fn new(micros: u64, clock_id: u16) -> Self { + // 53 bits of millis + let micros = micros & 0x001FFFFFFFFFFFFF; + // 10 bits of clock ID + let clock_id = clock_id & 0x03FF; + let val: u64 = (micros << 10) | (clock_id as u64); + // big-endian encoding + let enc = BASE32SORT.encode(&val.to_be_bytes()); + Tid(format!( + "{}-{}-{}-{}", + &enc[0..4], + &enc[4..7], + &enc[7..11], + &enc[11..13] + )) + } +} + +#[test] +fn test_tid() { + Tid::from_str("3yI5-c1z-cc2p-1a").unwrap(); + assert!(Tid::from_str("3jg6anbimrc2a").is_ok()); + assert!(Tid::from_str("3yI5-c1z-cc2p-1a").is_ok()); + + Tid::from_str("asdf234as4asdf234").unwrap(); + assert!(Tid::from_str("asdf234as4asdf234").is_ok()); + + assert!(Tid::from_str("").is_err()); + assert!(Tid::from_str("com").is_err()); + assert!(Tid::from_str("com.blah.Thing").is_err()); + assert!(Tid::from_str("did:stuff:blah").is_err()); + + let t1 = Tid::new(0, 0); + assert_eq!(t1.to_string(), "2222-222-2222-22".to_string()); +} + +/// TID Generator +/// +/// This version uses 53-bit microsecond counter (since UNIX epoch), and a random 10-bit clock id. +/// +/// If the current timestamp is not greater than the last timestamp (either because clock did not +/// advance monotonically, or multiple TIDs were generated in the same microsecond (very unlikely), +/// the timestamp is simply incremented. +pub struct Ticker { + last_timestamp: u64, + clock_id: u16, +} + +impl Ticker { + pub fn new() -> Self { + let mut ticker = Self { + last_timestamp: 0, + // mask to 10 bits + clock_id: rand::random::() & 0x03FF, + }; + // prime the pump + ticker.next_tid(); + ticker + } + + pub fn next_tid(&mut self) -> Tid { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("timestamp in micros since UNIX epoch") + .as_micros() as u64; + // mask to 53 bits + let now = now & 0x001FFFFFFFFFFFFF; + if now > self.last_timestamp { + self.last_timestamp = now; + } else { + self.last_timestamp += 1; + } + Tid::new(self.last_timestamp, self.clock_id) + } +} + +impl Default for Ticker { + fn default() -> Self { + Self::new() + } +} + +#[test] +fn test_ticker() { + let mut ticker = Ticker::new(); + let mut prev = ticker.next_tid(); + let mut next = ticker.next_tid(); + for _ in [0..100] { + println!("{} >? {}", next, prev); + assert!(next > prev); + prev = next; + next = ticker.next_tid(); + } + println!("{}", prev); + assert_eq!(prev, Tid::from_str(&prev.to_string()).unwrap()); + assert_eq!(next[13..16], prev[13..16]); + + let mut other_ticker = Ticker::new(); + let other = other_ticker.next_tid(); + assert!(other > next); + assert!(next[13..16] != other[13..16]); +} diff --git a/adenosine/src/lib.rs b/adenosine/src/lib.rs new file mode 100644 index 0000000..461930f --- /dev/null +++ b/adenosine/src/lib.rs @@ -0,0 +1,248 @@ +use anyhow::anyhow; +pub use anyhow::Result; +use reqwest::header; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; +use std::time::Duration; + +pub mod identifiers; +use identifiers::Nsid; + +static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum XrpcMethod { + Get, + Post, +} + +impl FromStr for XrpcMethod { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "get" => Ok(XrpcMethod::Get), + "post" => Ok(XrpcMethod::Post), + _ => Err(anyhow!("unknown method: {}", s)), + } + } +} + +pub struct XrpcClient { + http_client: reqwest::blocking::Client, + host: String, +} + +impl XrpcClient { + pub fn new(host: String, auth_token: Option) -> Result { + let mut headers = header::HeaderMap::new(); + if let Some(token) = &auth_token { + let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?; + auth_value.set_sensitive(true); + headers.insert(header::AUTHORIZATION, auth_value); + }; + + let http_client = reqwest::blocking::Client::builder() + .default_headers(headers) + .user_agent(APP_USER_AGENT) + .timeout(Duration::from_secs(30)) + //.danger_accept_invalid_certs(true) + .build() + .expect("ERROR :: Could not build reqwest client"); + + Ok(XrpcClient { http_client, host }) + } + + pub fn get( + &self, + nsid: &Nsid, + params: Option>, + ) -> Result> { + log::debug!("XRPC GET endpoint={} params={:?}", nsid, params); + let params: HashMap = params.unwrap_or_default(); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + // TODO: refactor this error handling stuff into single method + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + pub fn get_to_writer( + &self, + nsid: &Nsid, + params: Option>, + output: &mut W, + ) -> Result { + let params: HashMap = params.unwrap_or_default(); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let mut res = res.error_for_status()?; + Ok(res.copy_to(output)?) + } + + pub fn post( + &self, + nsid: &Nsid, + params: Option>, + body: Option, + ) -> Result> { + let params: HashMap = params.unwrap_or_default(); + log::debug!( + "XRPC POST endpoint={} params={:?} body={:?}", + nsid, + params, + body + ); + let mut req = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms); + req = if let Some(b) = body { + req.json(&b) + } else { + req + }; + let res = req.send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + if res.content_length() == Some(0) { + Ok(None) + } else { + Ok(res.json()?) + } + } + + pub fn post_cbor_from_reader( + &self, + nsid: &Nsid, + params: Option>, + input: &mut R, + ) -> Result> { + let params: HashMap = params.unwrap_or_default(); + let mut buf: Vec = Vec::new(); + input.read_to_end(&mut buf)?; + let res = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .header(reqwest::header::CONTENT_TYPE, "application/cbor") + .body(buf) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request: {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + // reqwest::blocking::Body +} + +/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token) +pub fn parse_did_from_jwt(jwt: &str) -> Result { + let second_b64 = jwt.split('.').nth(1).ok_or(anyhow!("couldn't parse JWT"))?; + let second_json: Vec = base64::decode_config(second_b64, base64::URL_SAFE)?; + let obj: Value = serde_json::from_slice(&second_json)?; + // trying to also support pulling "aud" as DID; not sure this is actually correct use of + // UCAN/JWT semantics? + let did = obj["sub"] + .as_str() + .or(obj["aud"].as_str()) + .ok_or(anyhow!("couldn't find DID subject in JWT"))? + .to_string(); + if !did.starts_with("did:") { + return Err(anyhow!("couldn't find DID subject in JWT")); + } + Ok(did) +} + +#[test] +fn test_parse_jwt() { + assert!(parse_did_from_jwt(".").is_err()); + // JWT from atproto ("sub") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(), + "did:plc:ex3si527cd2aogbvidkooozc", + ); + // UCAN from adenosine-pds ("aud") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(), + "did:plc:s7onbyja7c3yrsgvholkls5b", + ); + assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err()); +} + +/// Helper to generate the current timestamp as right now, UTC, formatted as an RFC 3339 string. +/// +/// Currently, bluesky PDS expects millisecond precision, so we use that. +/// +/// Returns something like "2022-11-22T09:21:15.640Z" +pub fn created_at_now() -> String { + let now = time::OffsetDateTime::now_utc(); + // remove microsecond precision, but retain millisecond precision + let ms = now.millisecond(); + let now = now.replace_microsecond(0).unwrap(); + let now = now.replace_millisecond(ms).unwrap(); + now.format(&time::format_description::well_known::Rfc3339) + .unwrap() +} + +#[test] +fn test_created_at_now() { + // eg: 2022-11-22T09:20:44.123Z + let ts = created_at_now(); + println!("{}", ts); + assert_eq!(&ts[4..5], "-"); + assert_eq!(&ts[7..8], "-"); + assert_eq!(&ts[10..11], "T"); + assert_eq!(&ts[23..24], "Z"); +} -- cgit v1.2.3