tesseras-paste 0.1.3

Decentralized pastebin built on tesseras-dht
//! 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 atomically (write-to-temp + rename).
    /// 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);
        atomic_write(&path, &[key, value])
    }

    /// 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()
    }

    /// Mark a paste as blocked (prevents re-import from DHT).
    pub fn block(&self, key: &[u8]) {
        let _ = fs::File::create(self.block_path(key));
    }

    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)
    }
}

/// Write data to `path` atomically: write to a temporary file in
/// the same directory, then rename over the target. This prevents
/// corruption if the process is killed mid-write.
fn atomic_write(path: &Path, chunks: &[&[u8]]) -> std::io::Result<()> {
    let parent = path.parent().unwrap_or(Path::new("."));
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("tmp");
    let tmp = parent.join(format!(".tmp.{}.{}", std::process::id(), name));
    let mut f = fs::File::create(&tmp)?;
    for chunk in chunks {
        f.write_all(chunk)?;
    }
    f.sync_all()?;
    fs::rename(&tmp, path)
}

// ── 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);
        }
        atomic_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
    }
}