@@ -60,14 +60,28 @@ struct JsonRpcError {
6060/// Match `nestgate_core::nat_traversal::BEACON_DATASET` when core is linked.
6161const BEACON_DATASET : & str = "_known_beacons" ;
6262
63- /// Filesystem-backed storage state.
63+ /// Default namespace for cross-spring shared storage.
64+ const DEFAULT_NAMESPACE : & str = "shared" ;
65+
66+ /// Filesystem-backed storage state with family-scoped namespace isolation.
67+ ///
68+ /// Directory layout:
69+ /// ```text
70+ /// {base}/datasets/{family_id}/{namespace}/{key}.json (JSON values)
71+ /// {base}/datasets/{family_id}/{namespace}/_blobs/{key} (binary blobs)
72+ /// ```
6473///
65- /// Keys are stored as individual JSON files under `{base}/datasets/default/{key}.json`.
66- /// This replaces the former in-memory `HashMap` so IPC storage survives restarts.
74+ /// - `family_id` is resolved from env (`NESTGATE_FAMILY_ID` / `FAMILY_ID` / `BIOMEOS_FAMILY_ID`),
75+ /// defaulting to `"default"`.
76+ /// - `namespace` isolates each caller (spring). The `"shared"` namespace is the cross-spring
77+ /// meeting point, readable/writable by all springs in the family. Omitting `namespace` from
78+ /// request params defaults to `"shared"` for backward compatibility.
6779#[ derive( Clone ) ]
6880pub ( super ) struct StorageState {
69- /// Root directory for the default dataset.
70- dataset_dir : PathBuf ,
81+ /// Root directory for this family: `{base}/datasets/{family_id}`.
82+ family_dir : PathBuf ,
83+ /// The resolved family identifier.
84+ family_id : String ,
7185 #[ expect(
7286 dead_code,
7387 reason = "Template storage wired when template RPC handlers are enabled"
@@ -80,43 +94,81 @@ pub(super) struct StorageState {
8094 audits : crate :: rpc:: audit_storage:: AuditStorage ,
8195}
8296
97+ /// Resolve the family identifier from environment, with cascading fallback.
98+ fn resolve_family_id ( ) -> String {
99+ std:: env:: var ( "NESTGATE_FAMILY_ID" )
100+ . or_else ( |_| std:: env:: var ( "FAMILY_ID" ) )
101+ . or_else ( |_| std:: env:: var ( "BIOMEOS_FAMILY_ID" ) )
102+ . unwrap_or_else ( |_| "default" . to_string ( ) )
103+ }
104+
83105impl StorageState {
84106 fn new ( ) -> Result < Self > {
85- let dataset_dir = get_storage_base_path ( ) . join ( "datasets" ) . join ( "default" ) ;
86- std:: fs:: create_dir_all ( & dataset_dir) ?;
107+ let family_id = resolve_family_id ( ) ;
108+ let family_dir = get_storage_base_path ( ) . join ( "datasets" ) . join ( & family_id) ;
109+ let shared_dir = family_dir. join ( DEFAULT_NAMESPACE ) ;
110+ std:: fs:: create_dir_all ( & shared_dir) ?;
87111 Ok ( Self {
88- dataset_dir,
112+ family_dir,
113+ family_id,
89114 templates : crate :: rpc:: template_storage:: TemplateStorage :: new ( ) ,
90115 audits : crate :: rpc:: audit_storage:: AuditStorage :: new ( ) ,
91116 } )
92117 }
93118
94- /// Sanitize a key to a safe filename (reject path traversal).
95- fn key_path ( & self , key : & str ) -> std:: result:: Result < PathBuf , ( i32 , Cow < ' static , str > ) > {
96- if key. is_empty ( )
97- || key. contains ( '/' )
98- || key. contains ( '\\' )
99- || key. contains ( ".." )
100- || key. starts_with ( '.' )
119+ /// Validate a name segment (key or namespace) — reject path traversal.
120+ fn validate_segment (
121+ name : & str ,
122+ field : & ' static str ,
123+ ) -> std:: result:: Result < ( ) , ( i32 , Cow < ' static , str > ) > {
124+ if name. is_empty ( )
125+ || name. contains ( '/' )
126+ || name. contains ( '\\' )
127+ || name. contains ( ".." )
128+ || name. starts_with ( '.' )
101129 {
102- return Err ( ( -32602 , Cow :: Borrowed ( "Invalid key: must be a simple name" ) ) ) ;
130+ return Err ( (
131+ -32602 ,
132+ Cow :: Owned ( format ! ( "Invalid {field}: must be a simple name" ) ) ,
133+ ) ) ;
103134 }
104- Ok ( self . dataset_dir . join ( format ! ( "{key}.json" ) ) )
135+ Ok ( ( ) )
136+ }
137+
138+ /// Resolve a namespace directory, creating it on first access.
139+ fn namespace_dir ( & self , namespace : & str ) -> PathBuf {
140+ self . family_dir . join ( namespace)
141+ }
142+
143+ /// Sanitize a key to a safe filename within a namespace.
144+ fn key_path (
145+ & self ,
146+ namespace : & str ,
147+ key : & str ,
148+ ) -> std:: result:: Result < PathBuf , ( i32 , Cow < ' static , str > ) > {
149+ Self :: validate_segment ( namespace, "namespace" ) ?;
150+ Self :: validate_segment ( key, "key" ) ?;
151+ Ok ( self . namespace_dir ( namespace) . join ( format ! ( "{key}.json" ) ) )
152+ }
153+
154+ /// Blob storage path within a namespace.
155+ fn blob_path (
156+ & self ,
157+ namespace : & str ,
158+ key : & str ,
159+ ) -> std:: result:: Result < PathBuf , ( i32 , Cow < ' static , str > ) > {
160+ Self :: validate_segment ( namespace, "namespace" ) ?;
161+ Self :: validate_segment ( key, "key" ) ?;
162+ Ok ( self . namespace_dir ( namespace) . join ( "_blobs" ) . join ( key) )
105163 }
106164
107165 /// NAT traversal info is stored under the `_nat` sub-directory.
108166 fn nat_dir ( & self ) -> PathBuf {
109- self . dataset_dir
110- . parent ( )
111- . unwrap_or ( & self . dataset_dir )
112- . join ( "_nat" )
167+ self . family_dir . join ( "_nat" )
113168 }
114169
115170 fn beacon_dir ( & self ) -> PathBuf {
116- self . dataset_dir
117- . parent ( )
118- . unwrap_or ( & self . dataset_dir )
119- . join ( BEACON_DATASET )
171+ self . family_dir . join ( BEACON_DATASET )
120172 }
121173}
122174
@@ -152,6 +204,21 @@ impl UnixSocketRpcHandler {
152204 "storage.list" => unix_adapter_handlers:: handle_storage_list ( state, & request) . await ,
153205 "storage.delete" => unix_adapter_handlers:: handle_storage_delete ( state, & request) . await ,
154206 "storage.exists" => unix_adapter_handlers:: handle_storage_exists ( state, & request) ,
207+ "storage.store_blob" => {
208+ unix_adapter_handlers:: handle_storage_store_blob ( state, & request) . await
209+ }
210+ "storage.retrieve_blob" => {
211+ unix_adapter_handlers:: handle_storage_retrieve_blob ( state, & request) . await
212+ }
213+ "storage.retrieve_range" => {
214+ unix_adapter_handlers:: handle_storage_retrieve_range ( state, & request) . await
215+ }
216+ "storage.object.size" => {
217+ unix_adapter_handlers:: handle_storage_object_size ( state, & request) . await
218+ }
219+ "storage.namespaces.list" => {
220+ unix_adapter_handlers:: handle_storage_namespaces_list ( state) . await
221+ }
155222 "session.save" => unix_adapter_handlers:: handle_session_save ( state, & request) . await ,
156223 "session.load" => unix_adapter_handlers:: handle_session_load ( state, & request) . await ,
157224 "session.list" => unix_adapter_handlers:: handle_session_list ( state, & request) . await ,
@@ -173,17 +240,13 @@ impl UnixSocketRpcHandler {
173240 "capabilities.list" | "discover_capabilities" => {
174241 Ok ( unix_adapter_handlers:: capabilities_response ( ) )
175242 }
176- "identity.get" => {
177- let family_id =
178- std:: env:: var ( "NESTGATE_FAMILY_ID" ) . unwrap_or_else ( |_| "default" . to_string ( ) ) ;
179- Ok ( json ! ( {
180- "primal" : nestgate_config:: constants:: system:: DEFAULT_SERVICE_NAME ,
181- "version" : env!( "CARGO_PKG_VERSION" ) ,
182- "domain" : "storage" ,
183- "license" : "AGPL-3.0-or-later" ,
184- "family_id" : family_id
185- } ) )
186- }
243+ "identity.get" => Ok ( json ! ( {
244+ "primal" : nestgate_config:: constants:: system:: DEFAULT_SERVICE_NAME ,
245+ "version" : env!( "CARGO_PKG_VERSION" ) ,
246+ "domain" : "storage" ,
247+ "license" : "AGPL-3.0-or-later" ,
248+ "family_id" : state. family_id
249+ } ) ) ,
187250
188251 // nat.* — NAT traversal info uses its own sub-directory
189252 "nat.store_traversal_info" => {
0 commit comments