fn0 0.2.48

FaaS platform powered by wasmtime
//! Shared path and telemetry rules for lazy static page caching.

use opentelemetry::{KeyValue, global};
use sha2::{Digest, Sha256};

pub const PAGE_DIRECTORY: &str = "__forte/pages";

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaticPageError {
    EmptyPath,
    MissingLeadingSlash,
    QueryOrFragment,
    InvalidPercentEncoding,
    DotSegment,
    Backslash,
}

impl std::fmt::Display for StaticPageError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::EmptyPath => write!(formatter, "path is empty"),
            Self::MissingLeadingSlash => write!(formatter, "path must start with '/'"),
            Self::QueryOrFragment => {
                write!(formatter, "query strings and fragments are not allowed")
            }
            Self::InvalidPercentEncoding => {
                write!(formatter, "path contains invalid percent encoding")
            }
            Self::DotSegment => write!(formatter, "dot path segments are not allowed"),
            Self::Backslash => write!(formatter, "backslashes are not allowed"),
        }
    }
}

impl std::error::Error for StaticPageError {}

pub fn normalize_path(path: &str) -> Result<String, StaticPageError> {
    if path.is_empty() {
        return Err(StaticPageError::EmptyPath);
    }
    if !path.starts_with('/') {
        return Err(StaticPageError::MissingLeadingSlash);
    }
    if path.contains(['?', '#']) {
        return Err(StaticPageError::QueryOrFragment);
    }
    if path.contains('\\') {
        return Err(StaticPageError::Backslash);
    }

    let bytes = path.as_bytes();
    let mut normalized_bytes = Vec::with_capacity(path.len());
    let mut byte_offset = 0;
    while byte_offset < bytes.len() {
        if bytes[byte_offset] != b'%' {
            normalized_bytes.push(bytes[byte_offset]);
            byte_offset += 1;
            continue;
        }
        if byte_offset + 2 >= bytes.len() {
            return Err(StaticPageError::InvalidPercentEncoding);
        }
        let high =
            hex_value(bytes[byte_offset + 1]).ok_or(StaticPageError::InvalidPercentEncoding)?;
        let low =
            hex_value(bytes[byte_offset + 2]).ok_or(StaticPageError::InvalidPercentEncoding)?;
        let decoded = high * 16 + low;
        if decoded == b'\\' {
            return Err(StaticPageError::Backslash);
        }
        normalized_bytes.push(b'%');
        normalized_bytes.push(upper_hex_byte(high));
        normalized_bytes.push(upper_hex_byte(low));
        byte_offset += 3;
    }
    let normalized = String::from_utf8(normalized_bytes).expect("normalizing preserves UTF-8");

    for segment in normalized.split('/') {
        if is_dot_segment(segment) {
            return Err(StaticPageError::DotSegment);
        }
    }

    Ok(normalized)
}

pub fn object_path_for(normalized_path: &str) -> String {
    let digest = Sha256::digest(normalized_path.as_bytes());
    let mut encoded = String::with_capacity(digest.len() * 2);
    for byte in digest {
        encoded.push(upper_hex(byte >> 4));
        encoded.push(upper_hex(byte & 0x0f));
    }
    format!("{PAGE_DIRECTORY}/{encoded}.html")
}

pub fn record_result(project_id: &str, code_version: u64, object_path: &str, result: &'static str) {
    global::meter("fn0")
        .u64_counter("fn0.static_page_requests")
        .build()
        .add(
            1,
            &[
                KeyValue::new("project_id", project_id.to_string()),
                KeyValue::new("code_version", code_version.to_string()),
                KeyValue::new("path_identifier", object_path.to_string()),
                KeyValue::new("result", result),
            ],
        );
}

pub fn record_generation_duration(
    project_id: &str,
    code_version: u64,
    object_path: &str,
    duration: std::time::Duration,
) {
    global::meter("fn0")
        .f64_histogram("fn0.static_page_generation_duration_seconds")
        .build()
        .record(
            duration.as_secs_f64(),
            &[
                KeyValue::new("project_id", project_id.to_string()),
                KeyValue::new("code_version", code_version.to_string()),
                KeyValue::new("path_identifier", object_path.to_string()),
            ],
        );
}

fn is_dot_segment(segment: &str) -> bool {
    let mut decoded = Vec::with_capacity(segment.len());
    let bytes = segment.as_bytes();
    let mut byte_offset = 0;
    while byte_offset < bytes.len() {
        if bytes[byte_offset] == b'%' {
            let high = hex_value(bytes[byte_offset + 1]).unwrap();
            let low = hex_value(bytes[byte_offset + 2]).unwrap();
            decoded.push(high * 16 + low);
            byte_offset += 3;
        } else {
            decoded.push(bytes[byte_offset]);
            byte_offset += 1;
        }
    }
    decoded == b"." || decoded == b".."
}

fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

fn upper_hex(value: u8) -> char {
    char::from(upper_hex_byte(value))
}

fn upper_hex_byte(value: u8) -> u8 {
    if value < 10 {
        b'0' + value
    } else {
        b'A' + value - 10
    }
}

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

    #[test]
    fn normalizes_percent_encoding_without_merging_distinct_paths() {
        assert_eq!(normalize_path("/docs/%7euser").unwrap(), "/docs/%7Euser");
        assert_eq!(normalize_path("/docs/").unwrap(), "/docs/");
        assert_eq!(normalize_path("/docs").unwrap(), "/docs");
    }

    #[test]
    fn rejects_unsafe_or_request_specific_paths() {
        for path in [
            "",
            "about",
            "/about?preview=1",
            "/about#main",
            "/a/../b",
            "/a/%2e%2E/b",
            "/a\\b",
            "/a/%5cb",
            "/a/%",
        ] {
            assert!(normalize_path(path).is_err(), "{path}");
        }
    }

    #[test]
    fn creates_deterministic_opaque_object_paths() {
        assert_eq!(
            object_path_for("/"),
            "__forte/pages/8A5EDAB282632443219E051E4ADE2D1D5BBC671C781051BF1437897CBDFEA0F1.html"
        );
        assert_ne!(object_path_for("/docs"), object_path_for("/docs/"));
    }
}