Skip to main content

stakpak_shared/models/
context.rs

1use serde::{Deserialize, Serialize};
2
3/// Shared API limits for caller-provided context payloads.
4pub const MAX_CALLER_CONTEXT_ITEMS: usize = 32;
5pub const MAX_CALLER_CONTEXT_NAME_CHARS: usize = 256;
6pub const MAX_CALLER_CONTEXT_CONTENT_CHARS: usize = 50_000;
7pub const MAX_CALLER_CONTEXT_TOTAL_CHARS: usize = 500_000;
8
9/// Structured caller-provided context injected into server session runs.
10///
11/// Used by HTTP clients (gateway/watch) and server request parsing.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct CallerContextInput {
14    pub name: String,
15    pub content: String,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub priority: Option<String>,
18}
19
20/// Validate a batch of caller context inputs against shared limits.
21///
22/// Used by both the server routes and the gateway client to enforce
23/// consistent constraints at the API boundary.
24pub fn validate_caller_context(inputs: &[CallerContextInput]) -> Result<(), String> {
25    if inputs.len() > MAX_CALLER_CONTEXT_ITEMS {
26        return Err(format!(
27            "context can include at most {} entries",
28            MAX_CALLER_CONTEXT_ITEMS
29        ));
30    }
31
32    let mut total_content_chars: usize = 0;
33
34    for input in inputs {
35        let raw_name_len = input.name.chars().count();
36        if raw_name_len > MAX_CALLER_CONTEXT_NAME_CHARS {
37            return Err(format!(
38                "context.name exceeds {} characters",
39                MAX_CALLER_CONTEXT_NAME_CHARS
40            ));
41        }
42
43        let raw_content_len = input.content.chars().count();
44        if raw_content_len > MAX_CALLER_CONTEXT_CONTENT_CHARS {
45            return Err(format!(
46                "context.content exceeds {} characters",
47                MAX_CALLER_CONTEXT_CONTENT_CHARS
48            ));
49        }
50
51        total_content_chars = total_content_chars.saturating_add(raw_content_len);
52        if total_content_chars > MAX_CALLER_CONTEXT_TOTAL_CHARS {
53            return Err(format!(
54                "total context exceeds {} characters",
55                MAX_CALLER_CONTEXT_TOTAL_CHARS
56            ));
57        }
58
59        let trimmed_name = input.name.trim();
60        let trimmed_content = input.content.trim();
61        if trimmed_name.is_empty() || trimmed_content.is_empty() {
62            continue; // empty values are silently dropped downstream
63        }
64    }
65
66    Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn validate_accepts_within_limits() {
75        let input = CallerContextInput {
76            name: "n".repeat(MAX_CALLER_CONTEXT_NAME_CHARS),
77            content: "x".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS),
78            priority: Some("high".to_string()),
79        };
80        assert!(validate_caller_context(&[input]).is_ok());
81    }
82
83    #[test]
84    fn validate_rejects_too_many_items() {
85        let inputs: Vec<_> = (0..MAX_CALLER_CONTEXT_ITEMS + 1)
86            .map(|i| CallerContextInput {
87                name: format!("ctx-{i}"),
88                content: "value".to_string(),
89                priority: None,
90            })
91            .collect();
92        assert!(validate_caller_context(&inputs).is_err());
93    }
94
95    #[test]
96    fn validate_rejects_oversized_name() {
97        let input = CallerContextInput {
98            name: "n".repeat(MAX_CALLER_CONTEXT_NAME_CHARS + 1),
99            content: "value".to_string(),
100            priority: None,
101        };
102        assert!(validate_caller_context(&[input]).is_err());
103    }
104
105    #[test]
106    fn validate_rejects_oversized_content() {
107        let input = CallerContextInput {
108            name: "ctx".to_string(),
109            content: "x".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS + 1),
110            priority: None,
111        };
112        assert!(validate_caller_context(&[input]).is_err());
113    }
114
115    #[test]
116    fn validate_rejects_total_content_over_limit() {
117        let inputs: Vec<_> = (0..11)
118            .map(|i| CallerContextInput {
119                name: format!("ctx-{i}"),
120                content: "x".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS),
121                priority: None,
122            })
123            .collect();
124
125        assert!(validate_caller_context(&inputs).is_err());
126    }
127
128    #[test]
129    fn validate_rejects_oversized_whitespace_only_name() {
130        let input = CallerContextInput {
131            name: " ".repeat(MAX_CALLER_CONTEXT_NAME_CHARS + 1),
132            content: "value".to_string(),
133            priority: None,
134        };
135        assert!(
136            validate_caller_context(&[input]).is_err(),
137            "raw name length must be enforced even if trimmed name is empty"
138        );
139    }
140
141    #[test]
142    fn validate_rejects_oversized_whitespace_only_content() {
143        let input = CallerContextInput {
144            name: "ctx".to_string(),
145            content: " ".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS + 1),
146            priority: None,
147        };
148        assert!(
149            validate_caller_context(&[input]).is_err(),
150            "raw content length must be enforced even if trimmed content is empty"
151        );
152    }
153
154    #[test]
155    fn validate_skips_small_whitespace_only_content() {
156        let input = CallerContextInput {
157            name: "ctx".to_string(),
158            content: "   ".to_string(),
159            priority: None,
160        };
161        assert!(
162            validate_caller_context(&[input]).is_ok(),
163            "small whitespace-only content is skipped downstream"
164        );
165    }
166}