lean_ctx/core/dashboard_pro/
mod.rs1pub mod bridge;
15pub mod collect;
16pub mod effects;
17pub(crate) mod export;
18pub(crate) mod guard;
19pub mod host_usage;
20pub mod ledger;
21pub mod request;
22pub(crate) mod security_fields;
23pub(crate) mod snapshots;
24pub(crate) mod state;
25pub(crate) mod watch;
26pub mod wire;
27
28#[cfg(test)]
29mod tests;
30
31use std::io::{Read, Write};
32use std::path::{Path, PathBuf};
33
34use serde_json::{Value, json};
35
36pub(crate) const GUARD_KINDS: [&str; 7] = [
38 "secret_redacted",
39 "shell_blocked",
40 "path_blocked",
41 "injection_flagged",
42 "pii_redacted",
43 "content_withheld",
44 "coverage_incomplete",
45];
46const STATE_FILE_LIMIT: u64 = 4 << 20;
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ProError {
52 pub status: u16,
53 pub(crate) message: String,
54}
55
56impl ProError {
57 pub(crate) fn new(status: u16, message: impl Into<String>) -> Self {
58 Self {
59 status,
60 message: message.into(),
61 }
62 }
63 pub(crate) fn bad(message: impl Into<String>) -> Self {
64 Self::new(400, message)
65 }
66 pub(crate) fn not_found(message: impl Into<String>) -> Self {
67 Self::new(404, message)
68 }
69 pub(crate) fn conflict(message: impl Into<String>) -> Self {
70 Self::new(409, message)
71 }
72 pub(crate) fn internal(message: impl Into<String>) -> Self {
73 Self::new(500, message)
74 }
75}
76
77impl std::fmt::Display for ProError {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 write!(f, "{} ({})", self.message, self.status)
80 }
81}
82
83pub(crate) type ProResult<T> = Result<T, ProError>;
84
85#[derive(Debug, Clone)]
87pub struct ProjectScope {
88 pub(crate) root: String,
90 pub(crate) canonical: PathBuf,
92 pub(crate) partition: String,
94 pub(crate) label: String,
96 pub(crate) repo_hashes: Vec<String>,
99 pub(crate) projects: Vec<String>,
102 pub(crate) sessions: std::collections::HashSet<String>,
105 pub(crate) data_dir: PathBuf,
106 pub(crate) state_dir: PathBuf,
107 pub(crate) home: Option<PathBuf>,
108}
109
110impl ProjectScope {
111 pub fn device(projects: &[String]) -> ProResult<Self> {
114 let data = crate::core::data_dir::lean_ctx_data_dir().map_err(ProError::internal)?;
115 let state = crate::core::paths::state_dir().map_err(ProError::internal)?;
116 Ok(Self::device_with_dirs(
117 projects,
118 data,
119 state,
120 dirs::home_dir(),
121 ))
122 }
123
124 pub(crate) fn device_with_dirs(
125 projects: &[String],
126 data_dir: PathBuf,
127 state_dir: PathBuf,
128 home: Option<PathBuf>,
129 ) -> Self {
130 let mut roots: Vec<String> = Vec::new();
131 for root in projects.iter().map(|r| r.trim()).filter(|r| !r.is_empty()) {
132 let canonical = Path::new(root)
133 .canonicalize()
134 .map(|p| p.to_string_lossy().into_owned());
135 for candidate in std::iter::once(root.to_string()).chain(canonical) {
136 if !roots.contains(&candidate) {
137 roots.push(candidate);
138 }
139 }
140 }
141 Self {
142 root: String::new(),
143 canonical: PathBuf::new(),
144 partition: repo_hash("lean-ctx:device"),
145 label: "All projects".to_string(),
146 repo_hashes: Vec::new(),
147 projects: roots,
148 sessions: Default::default(),
149 data_dir,
150 state_dir,
151 home,
152 }
153 }
154
155 pub(crate) fn is_device(&self) -> bool {
157 self.root.is_empty()
158 }
159
160 pub fn bind(root: &str) -> ProResult<Self> {
162 let data = crate::core::data_dir::lean_ctx_data_dir().map_err(ProError::internal)?;
163 let state = crate::core::paths::state_dir().map_err(ProError::internal)?;
164 let mut scope = Self::with_dirs(root, data, state, dirs::home_dir())?;
165 scope.adopt_folders(&crate::core::savings_ledger::ProjectScope::for_project(
166 &scope.canonical,
167 ));
168 Ok(scope)
169 }
170
171 pub(crate) fn adopt_folders(&mut self, folders: &crate::core::savings_ledger::ProjectScope) {
174 for hash in folders.repo_hashes() {
175 if !self.repo_hashes.contains(&hash) {
176 self.repo_hashes.push(hash);
177 }
178 }
179 self.sessions.extend(folders.sessions().iter().cloned());
180 }
181
182 pub(crate) fn with_dirs(
183 root: &str,
184 data_dir: PathBuf,
185 state_dir: PathBuf,
186 home: Option<PathBuf>,
187 ) -> ProResult<Self> {
188 let root = root.trim();
189 if root.is_empty() {
190 return Err(ProError::bad(
191 "Choose a project: this view reads one project's data",
192 ));
193 }
194 let path = Path::new(root);
195 if !path.is_absolute() || !path.is_dir() {
196 return Err(ProError::bad("The bound project is not a directory"));
197 }
198 let canonical = path
199 .canonicalize()
200 .map_err(|_| ProError::bad("The bound project is not readable"))?;
201 let partition = crate::core::project_hash::hash_project_root(root);
202 if !is_partition(&partition) {
203 return Err(ProError::internal("Unexpected project hash"));
204 }
205 let mut repo_hashes = vec![repo_hash(root)];
206 let canonical_text = canonical.to_string_lossy().into_owned();
207 if canonical_text != root {
208 repo_hashes.push(repo_hash(&canonical_text));
209 }
210 let label = canonical.file_name().map_or_else(
211 || "Project".to_string(),
212 |n| n.to_string_lossy().into_owned(),
213 );
214 Ok(Self {
215 root: root.to_string(),
216 canonical,
217 partition,
218 label,
219 repo_hashes,
220 projects: Vec::new(),
221 sessions: Default::default(),
222 data_dir,
223 state_dir,
224 home,
225 })
226 }
227
228 pub(crate) fn partition_dir(&self) -> PathBuf {
230 self.data_dir.join("dashboard-pro").join(&self.partition)
231 }
232
233 pub(crate) fn project(&self) -> wire::Project {
235 wire::Project {
236 root_hash: self.repo_hashes.first().cloned().unwrap_or_default(),
237 labels: self.labels(),
238 repo_hashes: self.repo_hashes.clone(),
239 name: Some(self.label.clone()),
240 }
241 }
242
243 pub(crate) fn labels(&self) -> Vec<wire::ProjectLabel> {
244 if self.is_device() {
245 return self
246 .projects
247 .iter()
248 .map(|root| wire::ProjectLabel {
249 hash: repo_hash(root),
250 name: Path::new(root).file_name().map_or_else(
251 || "Project".to_string(),
252 |n| n.to_string_lossy().into_owned(),
253 ),
254 })
255 .collect();
256 }
257 self.repo_hashes
258 .iter()
259 .map(|hash| wire::ProjectLabel {
260 hash: hash.clone(),
261 name: self.label.clone(),
262 })
263 .collect()
264 }
265
266 pub(crate) fn jailed(&self, relative: &str) -> ProResult<PathBuf> {
269 let relative = relative.trim();
270 let candidate = Path::new(relative);
271 if self.is_device()
272 || relative.is_empty()
273 || relative.len() > 4096
274 || relative.chars().any(char::is_control)
275 || candidate.is_absolute()
276 || crate::core::pathutil::is_windows_absolute_path(relative)
277 || candidate
278 .components()
279 .any(|c| !matches!(c, std::path::Component::Normal(_)))
280 {
281 return Err(ProError::conflict("The path is outside this project"));
282 }
283 let joined = self.canonical.join(candidate);
284 if let Ok(resolved) = joined.canonicalize()
285 && !resolved.starts_with(&self.canonical)
286 {
287 return Err(ProError::conflict("The path is outside this project"));
288 }
289 Ok(joined)
290 }
291}
292
293pub(crate) fn repo_hash(path: &str) -> String {
296 use sha2::{Digest, Sha256};
297 let digest = Sha256::digest(path.as_bytes());
298 hex::encode(digest)[..16].to_string()
299}
300
301fn is_partition(value: &str) -> bool {
302 (8..=64).contains(&value.len()) && value.bytes().all(|b| b.is_ascii_hexdigit())
303}
304
305pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> ProResult<()> {
307 let parent = path
308 .parent()
309 .ok_or_else(|| ProError::internal("state path has no parent"))?;
310 create_private_dir(parent)?;
311 let mut temporary = tempfile::NamedTempFile::new_in(parent)
312 .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
313 #[cfg(unix)]
314 {
315 use std::os::unix::fs::PermissionsExt;
316 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o600))
317 .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
318 }
319 temporary
320 .write_all(bytes)
321 .and_then(|()| temporary.as_file().sync_all())
322 .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
323 temporary
324 .persist(path)
325 .map_err(|_| ProError::internal("Cockpit state could not be written"))?;
326 Ok(())
327}
328
329pub(crate) fn write_json(path: &Path, value: &impl serde::Serialize) -> ProResult<()> {
330 let bytes =
331 serde_json::to_vec(value).map_err(|_| ProError::internal("Cockpit state is not JSON"))?;
332 write_private(path, &bytes)
333}
334
335pub(crate) fn create_private_dir(dir: &Path) -> ProResult<()> {
336 if std::fs::symlink_metadata(dir).is_ok_and(|m| m.file_type().is_symlink()) {
337 return Err(ProError::internal("Cockpit state directory is a symlink"));
338 }
339 #[cfg(unix)]
340 {
341 use std::os::unix::fs::DirBuilderExt;
342 std::fs::DirBuilder::new()
343 .recursive(true)
344 .mode(0o700)
345 .create(dir)
346 .map_err(|_| ProError::internal("Cockpit state directory could not be created"))
347 }
348 #[cfg(not(unix))]
349 {
350 std::fs::create_dir_all(dir)
351 .map_err(|_| ProError::internal("Cockpit state directory could not be created"))
352 }
353}
354
355pub(crate) fn read_bounded(path: &Path, limit: u64) -> Option<Vec<u8>> {
358 let meta = std::fs::symlink_metadata(path).ok()?;
359 if !meta.file_type().is_file() || meta.len() > limit {
360 return None;
361 }
362 let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
363 std::fs::File::open(path)
364 .ok()?
365 .take(limit + 1)
366 .read_to_end(&mut bytes)
367 .ok()?;
368 (bytes.len() as u64 <= limit).then_some(bytes)
369}
370
371pub(crate) fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
372 serde_json::from_slice(&read_bounded(path, STATE_FILE_LIMIT)?).ok()
373}
374
375pub(crate) fn plain_text(value: &str, limit: usize) -> bool {
377 value.chars().count() <= limit && !value.chars().any(char::is_control)
378}
379
380pub fn error_body(error: &ProError) -> Value {
381 json!({ "error": error.message })
382}