shardline-server 1.0.0

HTTP server boundary, runtime, and operator workflows for Shardline.
Documentation
use std::{
    io::{Error, ErrorKind},
    path::Path,
};

use tokio::fs;

use crate::ServerError;

const MAX_IDENTIFIER_BYTES: usize = 1024;

pub(crate) fn validate_identifier(value: &str) -> Result<(), ServerError> {
    if value.trim().is_empty()
        || value == "."
        || value.len() > MAX_IDENTIFIER_BYTES
        || value.starts_with('/')
        || value.contains("..")
        || value.contains('\\')
        || value.contains('/')
        || value.chars().any(char::is_control)
    {
        return Err(ServerError::InvalidFileId);
    }

    Ok(())
}

pub(crate) fn validate_content_hash(value: &str) -> Result<(), ServerError> {
    shardline_server_core::validate_content_hash_with(value, || ServerError::InvalidContentHash)
}

pub(crate) async fn ensure_directory(path: &Path) -> Result<(), ServerError> {
    let metadata = fs::symlink_metadata(path).await?;
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        return Err(ServerError::Io(Error::new(
            ErrorKind::InvalidData,
            format!("expected directory at {}", path.display()),
        )));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use std::{io::ErrorKind, path::Path};

    use tokio::fs::create_dir_all;

    use super::{MAX_IDENTIFIER_BYTES, validate_content_hash, validate_identifier};
    use crate::ServerError;

    #[test]
    fn identifier_rejects_dot_alias() {
        let result = validate_identifier(".");

        assert!(matches!(result, Err(ServerError::InvalidFileId)));
    }

    #[test]
    fn identifier_rejects_path_separator() {
        let result = validate_identifier("nested/file");

        assert!(matches!(result, Err(ServerError::InvalidFileId)));
    }

    #[test]
    fn identifier_rejects_oversized_values() {
        let oversized = "f".repeat(MAX_IDENTIFIER_BYTES + 1);
        let result = validate_identifier(&oversized);

        assert!(matches!(result, Err(ServerError::InvalidFileId)));
    }

    #[test]
    fn content_hash_accepts_lowercase_hex() {
        let result = validate_content_hash(
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        );

        assert!(result.is_ok());
    }

    #[test]
    fn content_hash_rejects_uppercase_hex() {
        let result = validate_content_hash(
            "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef",
        );

        assert!(matches!(result, Err(ServerError::InvalidContentHash)));
    }

    #[cfg(unix)]
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn ensure_directory_rejects_symlinked_directory() {
        use std::os::unix::fs::symlink;

        let temp = tempfile::tempdir();
        assert!(temp.is_ok());
        let Ok(temp) = temp else {
            return;
        };
        let target = temp.path().join("target");
        let create = create_dir_all(&target).await;
        assert!(create.is_ok());
        let link = temp.path().join("link");
        let linked = symlink(Path::new(&target), Path::new(&link));
        assert!(linked.is_ok());

        let result = super::ensure_directory(&link).await;

        assert!(matches!(
            result,
            Err(ServerError::Io(error)) if error.kind() == ErrorKind::InvalidData
        ));
    }
}