aboutsummaryrefslogtreecommitdiffstats
path: root/src/store.rs
diff options
context:
space:
mode:
authormurilo ijanc2026-03-25 02:07:37 -0300
committermurilo ijanc2026-03-25 02:07:37 -0300
commit7aff2e1d279a4e442b32f49ca0a0eca065355787 (patch)
treebc987ece7eb78bb8375de1b20123ecd0f90472ba /src/store.rs
downloadtesseras-paste-7aff2e1d279a4e442b32f49ca0a0eca065355787.tar.gz
Initial commit: tesseras-paste decentralized pastebin
DHT-backed encrypted pastebin with two binaries (tp/tpd), XChaCha20-Poly1305 encryption, content-addressed storage, and Unix socket + HTTP interfaces.
Diffstat (limited to 'src/store.rs')
-rw-r--r--src/store.rs262
1 files changed, 262 insertions, 0 deletions
diff --git a/src/store.rs b/src/store.rs
new file mode 100644
index 0000000..18a7641
--- /dev/null
+++ b/src/store.rs
@@ -0,0 +1,262 @@
+//! Filesystem-based paste storage.
+//!
+//! Simple directory layout:
+//! <root>/pastes/<hash>.bin
+//! <root>/pins/<hash>
+//! <root>/blocked/<hash>
+//! <root>/contacts.bin
+
+use std::fs;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+
+use crate::base58;
+use crate::paste::Paste;
+
+/// Persistent paste store backed by the filesystem.
+#[derive(Clone)]
+pub struct PasteStore {
+ root: PathBuf,
+}
+
+impl PasteStore {
+ /// Open or create a store rooted at the given directory.
+ /// Creates `pastes/`, `pins/`, and `blocked/` subdirectories.
+ pub fn open(root: &Path) -> std::io::Result<Self> {
+ fs::create_dir_all(root.join("pastes"))?;
+ fs::create_dir_all(root.join("pins"))?;
+ fs::create_dir_all(root.join("blocked"))?;
+ Ok(PasteStore {
+ root: root.to_path_buf(),
+ })
+ }
+
+ fn paste_path(&self, key: &[u8]) -> PathBuf {
+ self.root.join("pastes").join(base58::encode(key))
+ }
+
+ fn pin_path(&self, key: &[u8]) -> PathBuf {
+ self.root.join("pins").join(base58::encode(key))
+ }
+
+ fn block_path(&self, key: &[u8]) -> PathBuf {
+ self.root.join("blocked").join(base58::encode(key))
+ }
+
+ // ── Paste CRUD ──────────────────────────────────
+
+ /// Write a paste to disk. The key (32 bytes) is prepended
+ /// to the file so [`original_keys`] can reconstruct it.
+ pub fn put_paste(&self, key: &[u8], value: &[u8]) -> std::io::Result<()> {
+ let path = self.paste_path(key);
+ let mut f = fs::File::create(path)?;
+ f.write_all(key)?;
+ f.write_all(value)?;
+ Ok(())
+ }
+
+ /// Read a paste from disk. Returns `None` if the paste
+ /// is blocked, expired (and not pinned), or does not exist.
+ pub fn get_paste(&self, key: &[u8]) -> Option<Vec<u8>> {
+ if self.is_blocked(key) {
+ return None;
+ }
+ let path = self.paste_path(key);
+ let data = fs::read(&path).ok()?;
+ // Strip key prefix (32 bytes)
+ if data.len() < 32 {
+ return None;
+ }
+ let value = data[32..].to_vec();
+
+ // Check expiry (pinned never expire)
+ if let Some(paste) = Paste::from_bytes(&value)
+ && paste.is_expired()
+ && !self.is_pinned(key)
+ {
+ return None;
+ }
+ Some(value)
+ }
+
+ /// Delete a paste file from disk (no-op if absent).
+ pub fn remove_paste(&self, key: &[u8]) {
+ let _ = fs::remove_file(self.paste_path(key));
+ }
+
+ /// List all non-expired, non-blocked paste keys.
+ pub fn original_keys(&self) -> Vec<Vec<u8>> {
+ let dir = self.root.join("pastes");
+ let entries = match fs::read_dir(&dir) {
+ Ok(e) => e,
+ Err(_) => return Vec::new(),
+ };
+
+ let mut keys = Vec::new();
+ for entry in entries.flatten() {
+ let data = match fs::read(entry.path()) {
+ Ok(d) => d,
+ Err(_) => continue,
+ };
+ if data.len() < 32 {
+ continue;
+ }
+ let key = &data[..32];
+ let value = &data[32..];
+
+ if self.is_blocked(key) {
+ continue;
+ }
+ if let Some(paste) = Paste::from_bytes(value)
+ && paste.is_expired()
+ && !self.is_pinned(key)
+ {
+ continue;
+ }
+ keys.push(key.to_vec());
+ }
+ keys
+ }
+
+ // ── Pin / Block ─────────────────────────────────
+
+ /// Mark a paste as pinned (never expires).
+ pub fn pin(&self, key: &[u8]) -> std::io::Result<()> {
+ fs::File::create(self.pin_path(key))?;
+ Ok(())
+ }
+
+ /// Remove the pin from a paste (re-enables expiry).
+ pub fn unpin(&self, key: &[u8]) -> std::io::Result<()> {
+ let _ = fs::remove_file(self.pin_path(key));
+ Ok(())
+ }
+
+ pub fn is_pinned(&self, key: &[u8]) -> bool {
+ self.pin_path(key).exists()
+ }
+
+ pub fn is_blocked(&self, key: &[u8]) -> bool {
+ self.block_path(key).exists()
+ }
+
+ // ── GC ──────────────────────────────────────────
+
+ /// Remove expired pastes from disk. Pinned pastes are kept.
+ pub fn gc(&self) -> std::io::Result<usize> {
+ let dir = self.root.join("pastes");
+ let entries = fs::read_dir(&dir)?;
+ let mut removed = 0;
+
+ for entry in entries.flatten() {
+ let data = match fs::read(entry.path()) {
+ Ok(d) => d,
+ Err(_) => continue,
+ };
+ if data.len() < 32 {
+ continue;
+ }
+ let key = &data[..32];
+ let value = &data[32..];
+
+ if self.is_pinned(key) {
+ continue;
+ }
+ if let Some(paste) = Paste::from_bytes(value)
+ && paste.is_expired()
+ {
+ let _ = fs::remove_file(entry.path());
+ removed += 1;
+ }
+ }
+ Ok(removed)
+ }
+
+ /// Count stored pastes.
+ pub fn paste_count(&self) -> usize {
+ let dir = self.root.join("pastes");
+ fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0)
+ }
+}
+
+// ── tesseras-dht persistence traits ─────────────────
+
+impl tesseras_dht::persist::RoutingPersistence for PasteStore {
+ fn save_contacts(
+ &self,
+ contacts: &[tesseras_dht::persist::ContactRecord],
+ ) -> Result<(), tesseras_dht::Error> {
+ let path = self.root.join("contacts.bin");
+ let mut buf = Vec::new();
+ for c in contacts {
+ let id = c.id.as_bytes();
+ let addr = c.addr.to_string();
+ let addr_bytes = addr.as_bytes();
+ // length-prefixed: addr_len(u16) + id(32) + addr
+ let len = addr_bytes.len() as u16;
+ buf.extend_from_slice(&len.to_be_bytes());
+ buf.extend_from_slice(id);
+ buf.extend_from_slice(addr_bytes);
+ }
+ fs::write(&path, &buf).map_err(tesseras_dht::Error::Io)?;
+ log::info!("store: persisted {} routing contacts", contacts.len());
+ Ok(())
+ }
+
+ fn load_contacts(
+ &self,
+ ) -> Result<Vec<tesseras_dht::persist::ContactRecord>, tesseras_dht::Error>
+ {
+ let path = self.root.join("contacts.bin");
+ let data = match fs::read(&path) {
+ Ok(d) => d,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(Vec::new());
+ }
+ Err(e) => return Err(tesseras_dht::Error::Io(e)),
+ };
+
+ let mut out = Vec::new();
+ let mut pos = 0;
+ while pos + 2 + 32 <= data.len() {
+ let addr_len =
+ u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
+ pos += 2;
+ if pos + 32 + addr_len > data.len() {
+ break;
+ }
+ let mut id_bytes = [0u8; 32];
+ id_bytes.copy_from_slice(&data[pos..pos + 32]);
+ pos += 32;
+ let addr_str =
+ std::str::from_utf8(&data[pos..pos + addr_len]).unwrap_or("");
+ pos += addr_len;
+ if let Ok(addr) = addr_str.parse() {
+ out.push(tesseras_dht::persist::ContactRecord {
+ id: tesseras_dht::NodeId::from_bytes(id_bytes),
+ addr,
+ });
+ }
+ }
+ if !out.is_empty() {
+ log::info!("store: loaded {} routing contacts", out.len());
+ }
+ Ok(out)
+ }
+}
+
+impl tesseras_dht::persist::DataPersistence for PasteStore {
+ fn save(
+ &self,
+ _records: &[tesseras_dht::persist::StoredRecord],
+ ) -> Result<(), tesseras_dht::Error> {
+ Ok(()) // app-level storage handles this
+ }
+
+ fn load(
+ &self,
+ ) -> Result<Vec<tesseras_dht::persist::StoredRecord>, tesseras_dht::Error>
+ {
+ Ok(Vec::new()) // republish timer re-populates DHT
+ }
+}