aboutsummaryrefslogtreecommitdiffstats
path: root/src/paste.rs
blob: 50b32b17e3b00963bb84e866831305096f51d354 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
//! Paste record format.
//!
//! Binary format (no external serialization deps):
//!   version: u8
//!   created_at: u64 (big-endian)
//!   ttl_secs: u64 (big-endian)
//!   content: [u8] (remaining bytes)

use tesseras_dht::sha2::{Digest, Sha256};

/// Maximum paste size: 64 KiB.
pub const MAX_PASTE_SIZE: usize = 64 * 1024;

/// Current format version.
const FORMAT_VERSION: u8 = 1;

/// Header size: version(1) + created_at(8) + ttl(8) = 17.
const HEADER_SIZE: usize = 17;

/// A paste record stored locally and replicated via the DHT.
#[derive(Debug, Clone)]
pub struct Paste {
    pub version: u8,
    pub content: Vec<u8>,
    pub created_at: u64,
    pub ttl_secs: u64,
}

impl Paste {
    /// Create a new paste with the current timestamp.
    pub fn new(content: Vec<u8>, ttl_secs: u64) -> Self {
        let created_at = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        Paste {
            version: FORMAT_VERSION,
            content,
            created_at,
            ttl_secs,
        }
    }

    /// Compute the SHA-256 content-addressed key (32 bytes).
    pub fn content_key(content: &[u8]) -> [u8; 32] {
        let mut h = Sha256::new();
        h.update(content);
        h.finalize().into()
    }

    /// Serialize to bytes.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut buf = Vec::with_capacity(HEADER_SIZE + self.content.len());
        buf.push(self.version);
        buf.extend_from_slice(&self.created_at.to_be_bytes());
        buf.extend_from_slice(&self.ttl_secs.to_be_bytes());
        buf.extend_from_slice(&self.content);
        buf
    }

    /// Deserialize from bytes.
    pub fn from_bytes(data: &[u8]) -> Option<Self> {
        if data.len() < HEADER_SIZE {
            return None;
        }
        let version = data[0];
        let created_at = u64::from_be_bytes(data[1..9].try_into().ok()?);
        let ttl_secs = u64::from_be_bytes(data[9..17].try_into().ok()?);
        let content = data[HEADER_SIZE..].to_vec();
        Some(Paste {
            version,
            content,
            created_at,
            ttl_secs,
        })
    }

    /// Whether this paste has expired.
    pub fn is_expired(&self) -> bool {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        now > self.created_at + self.ttl_secs
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip() {
        let paste = Paste::new(b"hello world".to_vec(), 3600);
        let bytes = paste.to_bytes();
        let decoded = Paste::from_bytes(&bytes).unwrap();
        assert_eq!(decoded.content, b"hello world");
        assert_eq!(decoded.ttl_secs, 3600);
        assert_eq!(decoded.version, FORMAT_VERSION);
    }

    #[test]
    fn content_key_deterministic() {
        let k1 = Paste::content_key(b"test");
        let k2 = Paste::content_key(b"test");
        assert_eq!(k1, k2);
    }

    #[test]
    fn content_key_differs() {
        let k1 = Paste::content_key(b"aaa");
        let k2 = Paste::content_key(b"bbb");
        assert_ne!(k1, k2);
    }

    #[test]
    fn too_short_fails() {
        assert!(Paste::from_bytes(&[0u8; 5]).is_none());
    }

    #[test]
    fn empty_content() {
        let paste = Paste::new(Vec::new(), 60);
        let bytes = paste.to_bytes();
        let decoded = Paste::from_bytes(&bytes).unwrap();
        assert!(decoded.content.is_empty());
    }
}