@@ -27,13 +27,33 @@ pub mod grpc {
2727 }
2828 }
2929
30- // Helper to run DB operations inside the mutex
30+ // Helper to run DB operations inside the mutex.
31+ //
32+ // Error hygiene (#354): this module is a documented external wire contract,
33+ // so internal error text (rusqlite constraint/column names, file paths)
34+ // must not reach remote clients. Match the HTTP surface (src/web/mod.rs,
35+ // which returns a bare 500 with no detail): log the detail server-side,
36+ // return a generic INTERNAL to the client. Handlers that raise a *typed*
37+ // Status inside the closure (e.g. get_entity's not_found) get it passed
38+ // through unchanged instead of being flattened into INTERNAL.
3139 fn with_db < T > (
3240 server : & MnemeGrpcServer ,
3341 f : impl FnOnce ( & Database ) -> Result < T , Box < dyn std:: error:: Error > > ,
3442 ) -> Result < T , Status > {
3543 let db = server. db . lock ( ) . map_err ( |_| Status :: internal ( "lock poisoned" ) ) ?;
36- f ( & db) . map_err ( |e| Status :: internal ( e. to_string ( ) ) )
44+ f ( & db) . map_err ( sanitize_error)
45+ }
46+
47+ /// Map a handler error to the client-facing Status: intentional `Status`
48+ /// values pass through; everything else is logged and genericized.
49+ fn sanitize_error ( e : Box < dyn std:: error:: Error > ) -> Status {
50+ match e. downcast :: < Status > ( ) {
51+ Ok ( status) => * status,
52+ Err ( e) => {
53+ eprintln ! ( "mimir grpc: internal error: {e}" ) ;
54+ Status :: internal ( "internal error" )
55+ }
56+ }
3757 }
3858
3959 #[ tonic:: async_trait]
@@ -279,6 +299,156 @@ pub mod grpc {
279299 . await ?;
280300 Ok ( ( ) )
281301 }
302+
303+ #[ cfg( test) ]
304+ mod tests {
305+ use super :: mneme_server:: Mneme ;
306+ use super :: * ;
307+ use crate :: db:: Database ;
308+
309+ fn test_server ( ) -> ( MnemeGrpcServer , String ) {
310+ let path = std:: env:: temp_dir ( )
311+ . join ( format ! ( "mimir-test-grpc-{}.db" , uuid:: Uuid :: new_v4( ) ) ) ;
312+ let path_str = path. to_str ( ) . unwrap ( ) . to_string ( ) ;
313+ let db = Database :: open ( & path_str) . expect ( "open test db" ) ;
314+ ( MnemeGrpcServer :: new ( Arc :: new ( Mutex :: new ( db) ) ) , path_str)
315+ }
316+
317+ fn remember_req ( key : & str ) -> RememberRequest {
318+ RememberRequest {
319+ category : "note" . to_string ( ) ,
320+ key : key. to_string ( ) ,
321+ body_json : "{\" content\" :\" hello grpc\" }" . to_string ( ) ,
322+ status : "active" . to_string ( ) ,
323+ r#type : "insight" . to_string ( ) ,
324+ tags : vec ! [ "t1" . to_string( ) ] ,
325+ importance : 1.0 ,
326+ topic_path : String :: new ( ) ,
327+ recall_when : vec ! [ ] ,
328+ always_on : false ,
329+ certainty : 0.5 ,
330+ workspace_hash : String :: new ( ) ,
331+ agent_id : String :: new ( ) ,
332+ visibility : "workspace" . to_string ( ) ,
333+ }
334+ }
335+
336+ #[ test]
337+ fn sanitize_error_hides_internal_detail_from_clients ( ) {
338+ // #354: raw internal error text (constraint/column names) must not
339+ // reach gRPC clients — generic message out, detail logged only.
340+ let e: Box < dyn std:: error:: Error > =
341+ "UNIQUE constraint failed: entities.category, entities.key" . into ( ) ;
342+ let status = sanitize_error ( e) ;
343+ assert_eq ! ( status. code( ) , tonic:: Code :: Internal ) ;
344+ assert_eq ! ( status. message( ) , "internal error" ) ;
345+ }
346+
347+ #[ test]
348+ fn sanitize_error_passes_through_typed_statuses ( ) {
349+ let e: Box < dyn std:: error:: Error > = Box :: new ( Status :: not_found ( "entity not found" ) ) ;
350+ let status = sanitize_error ( e) ;
351+ assert_eq ! ( status. code( ) , tonic:: Code :: NotFound ) ;
352+ assert_eq ! ( status. message( ) , "entity not found" ) ;
353+ }
354+
355+ #[ test]
356+ fn entity_to_proto_maps_fields ( ) {
357+ let e = models:: Entity {
358+ id : "ent-1" . to_string ( ) ,
359+ category : "note" . to_string ( ) ,
360+ key : "k" . to_string ( ) ,
361+ body_json : "{}" . to_string ( ) ,
362+ status : "active" . to_string ( ) ,
363+ entity_type : "insight" . to_string ( ) ,
364+ tags : vec ! [ "a" . to_string( ) ] ,
365+ decay_score : 0.7 ,
366+ retrieval_count : 3 ,
367+ layer : "working" . to_string ( ) ,
368+ topic_path : "x/y" . to_string ( ) ,
369+ archived : false ,
370+ archive_reason : String :: new ( ) ,
371+ links : vec ! [ ] ,
372+ verified : true ,
373+ source : "grpc" . to_string ( ) ,
374+ always_on : true ,
375+ certainty : 0.9 ,
376+ workspace_hash : "ws" . to_string ( ) ,
377+ agent_id : "agent" . to_string ( ) ,
378+ visibility : "workspace" . to_string ( ) ,
379+ created_at_unix_ms : 1 ,
380+ last_accessed_unix_ms : 2 ,
381+ embedding : None ,
382+ } ;
383+ let p = entity_to_proto ( & e) ;
384+ assert_eq ! ( p. id, "ent-1" ) ;
385+ assert_eq ! ( p. category, "note" ) ;
386+ assert_eq ! ( p. key, "k" ) ;
387+ assert_eq ! ( p. r#type, "insight" ) ;
388+ assert_eq ! ( p. tags, vec![ "a" . to_string( ) ] ) ;
389+ assert_eq ! ( p. decay_score, 0.7 ) ;
390+ assert_eq ! ( p. retrieval_count, 3 ) ;
391+ assert ! ( p. verified) ;
392+ assert ! ( p. always_on) ;
393+ assert_eq ! ( p. workspace_hash, "ws" ) ;
394+ assert_eq ! ( p. created_at_unix_ms, 1 ) ;
395+ assert_eq ! ( p. last_accessed_unix_ms, 2 ) ;
396+ }
397+
398+ #[ tokio:: test]
399+ async fn remember_then_get_entity_roundtrip ( ) {
400+ let ( server, path) = test_server ( ) ;
401+ let resp = server
402+ . remember ( Request :: new ( remember_req ( "k1" ) ) )
403+ . await
404+ . expect ( "remember" ) ;
405+ let r = resp. into_inner ( ) ;
406+ assert ! ( !r. id. is_empty( ) ) ;
407+ assert_eq ! ( r. category, "note" ) ;
408+ assert_eq ! ( r. key, "k1" ) ;
409+
410+ let got = server
411+ . get_entity ( Request :: new ( GetEntityRequest { id : r. id . clone ( ) } ) )
412+ . await
413+ . expect ( "get_entity" )
414+ . into_inner ( ) ;
415+ assert_eq ! ( got. id, r. id) ;
416+ assert_eq ! ( got. category, "note" ) ;
417+ assert_eq ! ( got. key, "k1" ) ;
418+ let _ = std:: fs:: remove_file ( & path) ;
419+ }
420+
421+ #[ tokio:: test]
422+ async fn get_entity_missing_returns_not_found_not_internal ( ) {
423+ // The typed not_found raised inside the with_db closure must
424+ // survive sanitize_error instead of being flattened to INTERNAL.
425+ let ( server, path) = test_server ( ) ;
426+ let err = server
427+ . get_entity ( Request :: new ( GetEntityRequest { id : "does-not-exist" . to_string ( ) } ) )
428+ . await
429+ . expect_err ( "missing entity should error" ) ;
430+ assert_eq ! ( err. code( ) , tonic:: Code :: NotFound ) ;
431+ let _ = std:: fs:: remove_file ( & path) ;
432+ }
433+
434+ #[ tokio:: test]
435+ async fn health_and_stats_respond ( ) {
436+ let ( server, path) = test_server ( ) ;
437+ let h = server
438+ . health ( Request :: new ( HealthRequest { } ) )
439+ . await
440+ . expect ( "health" )
441+ . into_inner ( ) ;
442+ assert ! ( h. healthy) ;
443+ let s = server
444+ . stats ( Request :: new ( StatsRequest { } ) )
445+ . await
446+ . expect ( "stats" )
447+ . into_inner ( ) ;
448+ assert_eq ! ( s. total_entities, 0 ) ;
449+ let _ = std:: fs:: remove_file ( & path) ;
450+ }
451+ }
282452}
283453
284454// Non-grpc fallback
@@ -297,4 +467,25 @@ pub mod grpc {
297467 ) -> Result < ( ) , Box < dyn std:: error:: Error > > {
298468 Err ( "gRPC transport not compiled in. Rebuild with: cargo build --features grpc" . into ( ) )
299469 }
470+
471+ #[ cfg( test) ]
472+ mod tests {
473+ use super :: * ;
474+
475+ #[ tokio:: test]
476+ async fn stub_serve_returns_actionable_error ( ) {
477+ let path = std:: env:: temp_dir ( )
478+ . join ( format ! ( "mimir-test-grpc-stub-{}.db" , uuid:: Uuid :: new_v4( ) ) ) ;
479+ let path_str = path. to_str ( ) . unwrap ( ) . to_string ( ) ;
480+ let db = Database :: open ( & path_str) . expect ( "open test db" ) ;
481+ let err = serve (
482+ Arc :: new ( Mutex :: new ( db) ) ,
483+ "127.0.0.1:0" . parse ( ) . unwrap ( ) ,
484+ )
485+ . await
486+ . expect_err ( "stub must refuse to serve" ) ;
487+ assert ! ( err. to_string( ) . contains( "--features grpc" ) ) ;
488+ let _ = std:: fs:: remove_file ( & path_str) ;
489+ }
490+ }
300491}
0 commit comments