lfsx-server 1.23.3

A fast, lightweight, secure Git LFS server
Documentation
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::error::Error;
use crate::storage::Store;

const FILE: &str = "dashboard-tokens.json";
const PREFIX: &str = "lfsx_";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Issued {
    pub name: String,
    pub created: u64,
    hash: String,
}

#[derive(Debug, thiserror::Error)]
pub enum Refusal {
    #[error("a token name is 1 to 64 letters, digits, dots, dashes or underscores")]
    Unreadable,
    #[error("a token named {0} already exists")]
    Taken(String),
    #[error("no token is named {0}")]
    Unknown(String),
    #[error(transparent)]
    Store(#[from] Error),
}

fn digest(token: &str) -> String {
    hex::encode(Sha256::digest(token.as_bytes()))
}

fn readable(name: &str) -> bool {
    (1..=64).contains(&name.len())
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}

pub async fn issued(store: &Store) -> Result<Vec<Issued>, Error> {
    match store.read_meta(FILE).await? {
        Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
        None => Ok(Vec::new()),
    }
}

async fn keep(store: &Store, issued: &[Issued]) -> Result<(), Error> {
    store
        .write_meta(FILE, serde_json::to_vec_pretty(issued)?)
        .await
}

pub async fn create(store: &Store, name: &str) -> Result<String, Refusal> {
    if !readable(name) {
        return Err(Refusal::Unreadable);
    }
    let mut issued = issued(store).await?;
    if issued.iter().any(|token| token.name == name) {
        return Err(Refusal::Taken(name.to_owned()));
    }

    let mut secret = [0u8; 32];
    getrandom::fill(&mut secret).expect("the operating system has a random number generator");
    let token = format!("{PREFIX}{}", URL_SAFE_NO_PAD.encode(secret));

    issued.push(Issued {
        name: name.to_owned(),
        created: time::OffsetDateTime::now_utc()
            .unix_timestamp()
            .unsigned_abs(),
        hash: digest(&token),
    });
    keep(store, &issued).await?;

    Ok(token)
}

pub async fn revoke(store: &Store, name: &str) -> Result<(), Refusal> {
    let mut issued = issued(store).await?;
    let before = issued.len();
    issued.retain(|token| token.name != name);
    if issued.len() == before {
        return Err(Refusal::Unknown(name.to_owned()));
    }

    Ok(keep(store, &issued).await?)
}

impl Issued {
    pub fn hash(&self) -> &str {
        &self.hash
    }
}

pub async fn holder(store: &Store, token: &str) -> Result<Option<Issued>, Error> {
    if !token.starts_with(PREFIX) {
        return Ok(None);
    }
    let hash = digest(token);

    Ok(issued(store)
        .await?
        .into_iter()
        .find(|issued| issued.hash == hash))
}

pub async fn still_issued(store: &Store, hash: &str) -> Result<bool, Error> {
    Ok(issued(store)
        .await?
        .iter()
        .any(|issued| issued.hash == hash))
}