Skip to main content

golem_schema/
http.rs

1// Copyright 2024-2026 Golem Cloud
2// Licensed under the Golem Source License v1.1 (https://license.golem.cloud/LICENSE).
3
4//! Shared HTTP path and file-mapping grammar for metadata producers and consumers.
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub enum FileMapping {
8    Exact {
9        public_path: Vec<String>,
10        file_path: String,
11    },
12    Subtree {
13        public_prefix: Vec<String>,
14        filesystem_root: String,
15    },
16}
17
18/// A public request target with one shared segment boundary for routing and files.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HttpRequestTarget {
21    path: String,
22    query: Option<String>,
23    segments: Vec<String>,
24    trailing_slash: bool,
25}
26
27impl HttpRequestTarget {
28    pub fn parse(target: &str) -> Result<Self, String> {
29        let (path, query) = match target.split_once('?') {
30            Some((path, query)) => (path, Some(query.to_string())),
31            None => (target, None),
32        };
33        let remaining = path.strip_prefix('/').ok_or("unsafe-path")?;
34        let trailing_slash = !remaining.is_empty() && remaining.ends_with('/');
35        let segments = if remaining.is_empty() {
36            Vec::new()
37        } else {
38            let remaining = if trailing_slash {
39                &remaining[..remaining.len() - 1]
40            } else {
41                remaining
42            };
43            remaining
44                .split('/')
45                .map(decode_segment)
46                .collect::<Result<Vec<_>, _>>()?
47        };
48        Ok(Self {
49            path: path.to_string(),
50            query,
51            segments,
52            trailing_slash,
53        })
54    }
55
56    pub fn path(&self) -> &str {
57        &self.path
58    }
59    pub fn query(&self) -> Option<&str> {
60        self.query.as_deref()
61    }
62    pub fn segments(&self) -> &[String] {
63        &self.segments
64    }
65    pub fn trailing_slash(&self) -> bool {
66        self.trailing_slash
67    }
68}
69
70impl FileMapping {
71    /// Compile URI source syntax once; targets are filesystem text, not URIs.
72    pub fn compile(source: &str, target: &str) -> Result<Self, String> {
73        let (source, subtree) = match source.strip_suffix("/*") {
74            Some(prefix) if prefix.ends_with('/') => return Err("source-path".into()),
75            Some(prefix) => (if prefix.is_empty() { "/" } else { prefix }, true),
76            None => (source, false),
77        };
78        if source.contains('*') {
79            return Err("source-wildcard".into());
80        }
81        if source.contains(['$', '?', '#']) {
82            return Err("source-path".into());
83        }
84        let path = HttpRequestTarget::parse(source).map_err(|_| "source-path")?;
85        if path.trailing_slash {
86            return Err("source-path".into());
87        }
88        let (target, allow_root) = if subtree {
89            let root = target.strip_suffix("/$1").ok_or("target-placeholder")?;
90            if root.ends_with('/') {
91                return Err("target-path".into());
92            }
93            (if root.is_empty() { "/" } else { root }, true)
94        } else {
95            (target, false)
96        };
97        validate_file_mapping(&path.segments, target, allow_root)?;
98        Ok(if subtree {
99            Self::Subtree {
100                public_prefix: path.segments,
101                filesystem_root: target.into(),
102            }
103        } else {
104            Self::Exact {
105                public_path: path.segments,
106                file_path: target.into(),
107            }
108        })
109    }
110}
111
112/// Validate already-decoded structural mapping fields without decoding again.
113pub fn validate_file_mapping(
114    segments: &[String],
115    target: &str,
116    allow_root: bool,
117) -> Result<(), String> {
118    if segments
119        .iter()
120        .any(|segment| !valid_decoded_segment(segment))
121    {
122        return Err("source-path".into());
123    }
124    if target.contains('$') {
125        return Err("target-placeholder".into());
126    }
127    if !target.starts_with('/')
128        || target.chars().any(char::is_control)
129        || (!allow_root && target == "/")
130        || (target != "/"
131            && target[1..]
132                .split('/')
133                .any(|segment| !valid_decoded_segment(segment)))
134    {
135        return Err("target-path".into());
136    }
137    Ok(())
138}
139
140pub fn valid_decoded_segment(segment: &str) -> bool {
141    !segment.is_empty()
142        && segment != "."
143        && segment != ".."
144        && !segment
145            .bytes()
146            .any(|byte| byte <= 0x1f || byte == 0x7f || byte == b'/' || byte == b'\\')
147}
148
149fn decode_segment(raw: &str) -> Result<String, String> {
150    let mut decoded = Vec::with_capacity(raw.len());
151    let mut bytes = raw.bytes();
152    while let Some(byte) = bytes.next() {
153        if byte == b'%' {
154            let high = bytes.next().and_then(hex).ok_or("unsafe-path")?;
155            let low = bytes.next().and_then(hex).ok_or("unsafe-path")?;
156            decoded.push(high * 16 + low);
157        } else if byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@".contains(&byte) {
158            decoded.push(byte);
159        } else {
160            return Err("unsafe-path".into());
161        }
162    }
163    let decoded = String::from_utf8(decoded).map_err(|_| "unsafe-path")?;
164    if !valid_decoded_segment(&decoded) {
165        return Err("unsafe-path".into());
166    }
167    Ok(decoded)
168}
169
170fn hex(byte: u8) -> Option<u8> {
171    match byte {
172        b'0'..=b'9' => Some(byte - b'0'),
173        b'a'..=b'f' => Some(byte - b'a' + 10),
174        b'A'..=b'F' => Some(byte - b'A' + 10),
175        _ => None,
176    }
177}