1use super::session::SessionStore;
9#[cfg_attr(not(test), allow(unused_imports))]
13use super::types::{FileEntry, FileEntryResult, FileSource};
14use zeroclaw_api::jsonrpc::JsonRpcError;
15use zeroclaw_api::jsonrpc::error_codes::*;
16
17pub const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
19
20pub const MAX_REQUEST_BYTES: u64 = 20 * 1024 * 1024;
22
23fn rpc_err(code: i32, msg: impl Into<String>) -> JsonRpcError {
24 JsonRpcError {
25 code,
26 message: msg.into(),
27 data: None,
28 }
29}
30
31pub async fn process_file_entry(
39 entry: &FileEntry,
40 session_id: &str,
41 upload_root: &str,
42 is_wss: bool,
43 sessions: &SessionStore,
44) -> Result<FileEntryResult, JsonRpcError> {
45 use base64::{Engine, engine::general_purpose::STANDARD};
46 use sha2::{Digest, Sha256};
47
48 let (bytes, filename, mime_type, original_path) = if let Some(ref b64) = entry.data_b64 {
50 let decoded = STANDARD
51 .decode(b64)
52 .map_err(|e| rpc_err(INVALID_PARAMS, format!("Invalid base64: {e}")))?;
53 if decoded.len() as u64 > MAX_FILE_BYTES {
54 return Err(rpc_err(
55 INVALID_PARAMS,
56 format!(
57 "File exceeds {} MB limit ({} bytes)",
58 MAX_FILE_BYTES / (1024 * 1024),
59 decoded.len()
60 ),
61 ));
62 }
63 let fname = entry.filename.as_deref().unwrap_or("upload").to_string();
64 let mime = entry
65 .mime_type
66 .clone()
67 .unwrap_or_else(|| mime_from_filename(&fname));
68 (decoded, fname, mime, None)
69 } else if let Some(ref path) = entry.path {
70 if is_wss {
71 return Err(rpc_err(
72 INVALID_PARAMS,
73 "Path mode is not available over WSS; send data_b64 instead",
74 ));
75 }
76 let p = std::path::Path::new(path);
77 if !p.is_absolute() {
78 return Err(rpc_err(INVALID_PARAMS, "Path must be absolute"));
79 }
80 let bytes = tokio::fs::read(p)
81 .await
82 .map_err(|e| rpc_err(INVALID_PARAMS, format!("Cannot read file: {e}")))?;
83 if bytes.len() as u64 > MAX_FILE_BYTES {
84 return Err(rpc_err(
85 INVALID_PARAMS,
86 format!(
87 "File exceeds {} MB limit ({} bytes)",
88 MAX_FILE_BYTES / (1024 * 1024),
89 bytes.len()
90 ),
91 ));
92 }
93 let fname = p
94 .file_name()
95 .map(|n| n.to_string_lossy().to_string())
96 .unwrap_or_else(|| "upload".to_string());
97 let mime = entry
98 .mime_type
99 .clone()
100 .unwrap_or_else(|| mime_from_filename(&fname));
101 (bytes, fname, mime, Some(path.clone()))
102 } else {
103 return Err(rpc_err(
104 INVALID_PARAMS,
105 "Each file entry must have either `data_b64` or `path`",
106 ));
107 };
108
109 let hash = Sha256::digest(&bytes);
111 let hex = format!("{hash:x}");
112 let ref_id = format!("sha256:{hex}");
113
114 if let Some(existing) = sessions.get_upload(session_id, &ref_id).await {
116 return Ok(FileEntryResult {
117 ref_id: existing.ref_id,
118 marker: existing.marker,
119 workspace_path: existing.workspace_path,
120 size_bytes: existing.size_bytes,
121 deduplicated: true,
122 });
123 }
124
125 let sanitized = sanitize_filename(&filename);
127
128 let ext = std::path::Path::new(&sanitized)
130 .extension()
131 .map(|e| e.to_string_lossy().to_string())
132 .unwrap_or_default();
133 let storage_name = if ext.is_empty() {
134 hex[..16].to_string()
135 } else {
136 format!("{}.{ext}", &hex[..16])
137 };
138 let upload_dir = std::path::Path::new(upload_root).join("uploads");
139 tokio::fs::create_dir_all(&upload_dir)
140 .await
141 .map_err(|e| rpc_err(INTERNAL_ERROR, format!("Cannot create upload dir: {e}")))?;
142 let dest = upload_dir.join(&storage_name);
143 tokio::fs::write(&dest, &bytes)
144 .await
145 .map_err(|e| rpc_err(INTERNAL_ERROR, format!("Cannot write upload: {e}")))?;
146
147 let canonical = tokio::fs::canonicalize(&dest)
150 .await
151 .unwrap_or_else(|_| dest.clone());
152 let workspace_path = canonical.to_string_lossy().to_string();
153
154 let kind = attachment_kind(&mime_type);
178 let is_clipboard = matches!(entry.source, FileSource::Clipboard);
179 let display_path = if is_clipboard {
180 &workspace_path
181 } else {
182 original_path.as_deref().unwrap_or(&workspace_path)
183 };
184 let marker = if kind == "IMAGE" {
185 format!("[IMAGE:{display_path}]")
186 } else {
187 format!("[Document: {filename}] {workspace_path}")
190 };
191
192 let size_bytes = bytes.len() as u64;
193
194 sessions
196 .insert_upload(
197 session_id,
198 super::session::UploadEntry {
199 ref_id: ref_id.clone(),
200 marker: marker.clone(),
201 workspace_path: workspace_path.clone(),
202 size_bytes,
203 },
204 )
205 .await;
206
207 Ok(FileEntryResult {
208 ref_id,
209 marker,
210 workspace_path,
211 size_bytes,
212 deduplicated: false,
213 })
214}
215
216fn sanitize_filename(name: &str) -> String {
218 name.replace(['/', '\\', '\0'], "_")
219}
220
221fn mime_from_filename(name: &str) -> String {
224 mime_guess::from_path(name)
225 .first_or_octet_stream()
226 .to_string()
227}
228
229fn attachment_kind(mime: &str) -> &'static str {
231 if mime.starts_with("image/") {
232 "IMAGE"
233 } else if mime == "application/pdf" {
234 "DOCUMENT"
235 } else {
236 "FILE"
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use serde_json::json;
244
245 #[test]
246 fn mime_from_filename_common_types() {
247 assert_eq!(mime_from_filename("photo.png"), "image/png");
248 assert_eq!(mime_from_filename("photo.jpg"), "image/jpeg");
249 assert_eq!(mime_from_filename("doc.pdf"), "application/pdf");
250 assert_eq!(mime_from_filename("data.csv"), "text/csv");
251 assert_eq!(
252 mime_from_filename("unknown.zzzzz"),
253 "application/octet-stream"
254 );
255 assert_eq!(mime_from_filename("noext"), "application/octet-stream");
256 }
257
258 #[test]
259 fn attachment_kind_maps_correctly() {
260 assert_eq!(attachment_kind("image/png"), "IMAGE");
261 assert_eq!(attachment_kind("image/jpeg"), "IMAGE");
262 assert_eq!(attachment_kind("image/svg+xml"), "IMAGE");
263 assert_eq!(attachment_kind("application/pdf"), "DOCUMENT");
264 assert_eq!(attachment_kind("application/zip"), "FILE");
265 assert_eq!(attachment_kind("text/plain"), "FILE");
266 }
267
268 #[test]
269 fn sanitize_filename_strips_separators() {
270 assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
271 assert_eq!(sanitize_filename("path/to/file.txt"), "path_to_file.txt");
272 assert_eq!(sanitize_filename("back\\slash.txt"), "back_slash.txt");
273 assert_eq!(sanitize_filename("null\0byte.txt"), "null_byte.txt");
274 }
275
276 #[test]
277 fn file_source_default_is_file() {
278 let source: FileSource = Default::default();
279 assert!(matches!(source, FileSource::File));
280 }
281
282 #[test]
283 fn file_entry_deserialize_data_mode() {
284 let v = json!({
285 "filename": "screenshot.png",
286 "mime_type": "image/png",
287 "data_b64": "aGVsbG8="
288 });
289 let entry: FileEntry = serde_json::from_value(v).unwrap();
290 assert_eq!(entry.filename.as_deref(), Some("screenshot.png"));
291 assert_eq!(entry.data_b64.as_deref(), Some("aGVsbG8="));
292 assert!(entry.path.is_none());
293 assert!(matches!(entry.source, FileSource::File));
294 }
295
296 #[test]
297 fn file_entry_deserialize_path_mode() {
298 let v = json!({
299 "path": "/home/user/doc.pdf",
300 "source": "file"
301 });
302 let entry: FileEntry = serde_json::from_value(v).unwrap();
303 assert_eq!(entry.path.as_deref(), Some("/home/user/doc.pdf"));
304 assert!(entry.data_b64.is_none());
305 }
306
307 #[test]
308 fn file_entry_deserialize_clipboard_source() {
309 let v = json!({
310 "filename": "paste.png",
311 "mime_type": "image/png",
312 "data_b64": "aGVsbG8=",
313 "source": "clipboard"
314 });
315 let entry: FileEntry = serde_json::from_value(v).unwrap();
316 assert!(matches!(entry.source, FileSource::Clipboard));
317 }
318
319 fn make_session_store(max: usize) -> SessionStore {
322 SessionStore::new(
323 max,
324 std::sync::Arc::new(zeroclaw_infra::session_queue::SessionActorQueue::new(
325 4, 10, 60,
326 )),
327 )
328 }
329
330 fn make_test_agent() -> crate::agent::agent::Agent {
331 use crate::agent::dispatcher::NativeToolDispatcher;
332
333 let mem_cfg = zeroclaw_config::schema::MemoryConfig {
334 backend: "none".into(),
335 ..zeroclaw_config::schema::MemoryConfig::default()
336 };
337 let mem = std::sync::Arc::from(
338 zeroclaw_memory::create_memory(&mem_cfg, &std::env::temp_dir(), None).unwrap(),
339 );
340
341 crate::agent::agent::Agent::builder()
342 .model_provider(Box::new(StubProvider))
343 .tools(vec![])
344 .memory(mem)
345 .observer(std::sync::Arc::new(crate::observability::NoopObserver {})
346 as std::sync::Arc<dyn crate::observability::Observer>)
347 .tool_dispatcher(Box::new(NativeToolDispatcher))
348 .workspace_dir(std::env::temp_dir())
349 .build()
350 .unwrap()
351 }
352
353 struct StubProvider;
354
355 #[async_trait::async_trait]
356 impl zeroclaw_providers::ModelProvider for StubProvider {
357 async fn chat_with_system(
358 &self,
359 _: Option<&str>,
360 _: &str,
361 _: &str,
362 _: Option<f64>,
363 ) -> anyhow::Result<String> {
364 Ok(String::new())
365 }
366 async fn chat(
367 &self,
368 _: zeroclaw_providers::ChatRequest<'_>,
369 _: &str,
370 _: Option<f64>,
371 ) -> anyhow::Result<zeroclaw_providers::ChatResponse> {
372 Ok(zeroclaw_providers::ChatResponse {
373 text: Some("stub".into()),
374 tool_calls: vec![],
375 usage: None,
376 reasoning_content: None,
377 })
378 }
379 }
380 impl zeroclaw_api::attribution::Attributable for StubProvider {
381 fn role(&self) -> zeroclaw_api::attribution::Role {
382 zeroclaw_api::attribution::Role::Provider(
383 zeroclaw_api::attribution::ProviderKind::Model(
384 zeroclaw_api::attribution::ModelProviderKind::Custom,
385 ),
386 )
387 }
388 fn alias(&self) -> &str {
389 "stub"
390 }
391 }
392
393 async fn setup_store(workspace: &str) -> SessionStore {
394 let store = make_session_store(4);
395 store
396 .insert(
397 "s1".into(),
398 super::super::session::RpcSession::new(
399 make_test_agent(),
400 "a",
401 workspace,
402 crate::rpc::types::ChatMode::Chat,
403 ),
404 )
405 .await
406 .unwrap();
407 store
408 }
409
410 #[tokio::test]
411 async fn clipboard_image() {
412 use base64::{Engine, engine::general_purpose::STANDARD};
413
414 let tmp = tempfile::tempdir().unwrap();
415 let ws = tmp.path().to_string_lossy().to_string();
416 let store = setup_store(&ws).await;
417
418 let png_bytes = b"fake-png-data";
419 let entry = FileEntry {
420 path: None,
421 data_b64: Some(STANDARD.encode(png_bytes)),
422 filename: Some("screenshot.png".into()),
423 mime_type: Some("image/png".into()),
424 source: FileSource::Clipboard,
425 };
426
427 let r = process_file_entry(&entry, "s1", &ws, false, &store)
428 .await
429 .unwrap();
430
431 assert!(r.ref_id.starts_with("sha256:"));
432 assert!(
437 r.marker.starts_with("[IMAGE:") && r.marker.ends_with(']'),
438 "marker = {}",
439 r.marker
440 );
441 assert!(
442 r.marker.contains("/uploads/"),
443 "clipboard image marker should reference workspace uploads path: {}",
444 r.marker
445 );
446 assert!(!r.deduplicated);
447 assert_eq!(r.size_bytes, png_bytes.len() as u64);
448 assert!(std::path::Path::new(&r.workspace_path).exists());
449 }
450
451 #[tokio::test]
452 async fn file_pdf() {
453 use base64::{Engine, engine::general_purpose::STANDARD};
454
455 let tmp = tempfile::tempdir().unwrap();
456 let ws = tmp.path().to_string_lossy().to_string();
457 let store = setup_store(&ws).await;
458
459 let entry = FileEntry {
460 path: None,
461 data_b64: Some(STANDARD.encode(b"%PDF-1.4 fake")),
462 filename: Some("report.pdf".into()),
463 mime_type: Some("application/pdf".into()),
464 source: FileSource::File,
465 };
466
467 let r = process_file_entry(&entry, "s1", &ws, false, &store)
468 .await
469 .unwrap();
470
471 assert!(
473 r.marker.starts_with("[Document: report.pdf]"),
474 "marker = {}",
475 r.marker
476 );
477 assert!(
478 r.marker.contains("/uploads/"),
479 "marker should include workspace uploads path: {}",
480 r.marker
481 );
482 assert!(!r.deduplicated);
483 }
484
485 #[tokio::test]
486 async fn deduplication() {
487 use base64::{Engine, engine::general_purpose::STANDARD};
488
489 let tmp = tempfile::tempdir().unwrap();
490 let ws = tmp.path().to_string_lossy().to_string();
491 let store = setup_store(&ws).await;
492
493 let b64 = STANDARD.encode(b"identical-bytes");
494
495 let entry = FileEntry {
496 path: None,
497 data_b64: Some(b64.clone()),
498 filename: Some("img.png".into()),
499 mime_type: Some("image/png".into()),
500 source: FileSource::Clipboard,
501 };
502
503 let r1 = process_file_entry(&entry, "s1", &ws, false, &store)
504 .await
505 .unwrap();
506 assert!(!r1.deduplicated);
507
508 let entry2 = FileEntry {
509 path: None,
510 data_b64: Some(b64),
511 filename: Some("img2.png".into()),
512 mime_type: Some("image/png".into()),
513 source: FileSource::Clipboard,
514 };
515
516 let r2 = process_file_entry(&entry2, "s1", &ws, false, &store)
517 .await
518 .unwrap();
519 assert!(r2.deduplicated);
520 assert_eq!(r1.ref_id, r2.ref_id);
521 }
522
523 #[tokio::test]
524 async fn malformed_base64() {
525 let tmp = tempfile::tempdir().unwrap();
526 let ws = tmp.path().to_string_lossy().to_string();
527 let store = setup_store(&ws).await;
528
529 let entry = FileEntry {
530 path: None,
531 data_b64: Some("not-valid-base64!!!".into()),
532 filename: Some("bad.png".into()),
533 mime_type: Some("image/png".into()),
534 source: FileSource::File,
535 };
536
537 let err = process_file_entry(&entry, "s1", &ws, false, &store)
538 .await
539 .unwrap_err();
540 assert_eq!(err.code, INVALID_PARAMS);
541 assert!(err.message.contains("base64"));
542 }
543
544 #[tokio::test]
545 async fn rejects_path_over_wss() {
546 let tmp = tempfile::tempdir().unwrap();
547 let ws = tmp.path().to_string_lossy().to_string();
548 let store = setup_store(&ws).await;
549
550 let entry = FileEntry {
551 path: Some("/home/user/file.txt".into()),
552 data_b64: None,
553 filename: None,
554 mime_type: None,
555 source: FileSource::File,
556 };
557
558 let err = process_file_entry(&entry, "s1", &ws, true, &store)
559 .await
560 .unwrap_err();
561 assert_eq!(err.code, INVALID_PARAMS);
562 assert!(err.message.contains("WSS"));
563 }
564
565 #[tokio::test]
566 async fn rejects_no_data_and_no_path() {
567 let tmp = tempfile::tempdir().unwrap();
568 let ws = tmp.path().to_string_lossy().to_string();
569 let store = setup_store(&ws).await;
570
571 let entry = FileEntry {
572 path: None,
573 data_b64: None,
574 filename: Some("orphan.txt".into()),
575 mime_type: None,
576 source: FileSource::File,
577 };
578
579 let err = process_file_entry(&entry, "s1", &ws, false, &store)
580 .await
581 .unwrap_err();
582 assert_eq!(err.code, INVALID_PARAMS);
583 assert!(err.message.contains("data_b64"));
584 }
585
586 #[tokio::test]
587 async fn path_mode() {
588 let tmp = tempfile::tempdir().unwrap();
589 let ws = tmp.path().to_string_lossy().to_string();
590 let store = setup_store(&ws).await;
591
592 let file_path = tmp.path().join("testfile.pdf");
593 std::fs::write(&file_path, b"%PDF-1.4 test content").unwrap();
594
595 let entry = FileEntry {
596 path: Some(file_path.to_string_lossy().to_string()),
597 data_b64: None,
598 filename: None,
599 mime_type: None,
600 source: FileSource::File,
601 };
602
603 let r = process_file_entry(&entry, "s1", &ws, false, &store)
604 .await
605 .unwrap();
606
607 assert!(r.ref_id.starts_with("sha256:"));
608 assert!(
610 r.marker.starts_with("[Document: testfile.pdf]"),
611 "marker = {}",
612 r.marker
613 );
614 assert!(
615 r.marker.contains("/uploads/"),
616 "marker should include workspace path: {}",
617 r.marker
618 );
619 assert!(!r.deduplicated);
620 assert!(std::path::Path::new(&r.workspace_path).exists());
621 }
622}