@@ -18,10 +18,12 @@ use tracing::{info, warn};
1818use crate :: config:: Config ;
1919
2020const EMBEDDED_PEERS_JSON : & str = include_str ! ( "../../../bootstrap-peers.json" ) ;
21+ const SUPPORTED_VERSION : u32 = 1 ;
2122
2223#[ derive( Debug , Deserialize ) ]
2324struct BootstrapList {
2425 version : u32 ,
26+ #[ serde( default ) ]
2527 peers : Vec < BootstrapPeer > ,
2628}
2729
@@ -38,57 +40,58 @@ struct BootstrapPeer {
3840 added : Option < String > ,
3941}
4042
41- /// Merge the embedded seed list into the runtime config.
42- ///
43- /// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
44- /// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
45- /// - Dedupes against entries already present (env / CLI takes precedence)
46- /// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
47- pub fn merge_seeds ( config : & mut Config ) {
48- if std:: env:: var ( "GITLAWB_BOOTSTRAP_DISABLE_SEEDS" )
43+ /// Counts of newly-added entries returned by `merge_into_vecs`.
44+ #[ derive( Debug , Default , PartialEq , Eq ) ]
45+ struct MergeCounts {
46+ http : usize ,
47+ p2p : usize ,
48+ }
49+
50+ /// Returns true when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value.
51+ fn seeds_disabled ( ) -> bool {
52+ std:: env:: var ( "GITLAWB_BOOTSTRAP_DISABLE_SEEDS" )
4953 . ok ( )
5054 . filter ( |v| !v. is_empty ( ) && v != "0" && !v. eq_ignore_ascii_case ( "false" ) )
5155 . is_some ( )
52- {
53- info ! ( "bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS" ) ;
54- return ;
55- }
56-
57- let list: BootstrapList = match serde_json:: from_str ( EMBEDDED_PEERS_JSON ) {
58- Ok ( l) => l,
59- Err ( e) => {
60- warn ! ( err = %e, "failed to parse embedded bootstrap-peers.json — skipping" ) ;
61- return ;
62- }
63- } ;
56+ }
6457
65- if list. version != 1 {
66- warn ! (
67- version = list. version,
68- "unknown bootstrap-peers.json version — skipping"
69- ) ;
70- return ;
58+ /// Parse the seed list from a JSON string, rejecting unsupported versions.
59+ fn parse_seed_list ( json : & str ) -> Result < BootstrapList , String > {
60+ let list: BootstrapList = serde_json:: from_str ( json) . map_err ( |e| e. to_string ( ) ) ?;
61+ if list. version != SUPPORTED_VERSION {
62+ return Err ( format ! (
63+ "unsupported bootstrap-peers.json version: {} (expected {})" ,
64+ list. version, SUPPORTED_VERSION
65+ ) ) ;
7166 }
67+ Ok ( list)
68+ }
7269
73- let mut added_http = 0 ;
74- let mut added_p2p = 0 ;
70+ /// Pure merge: appends entries from `list` to the two vectors, deduping.
71+ /// Returns counts of entries actually added (i.e. not already present).
72+ fn merge_into_vecs (
73+ list : BootstrapList ,
74+ http_peers : & mut Vec < String > ,
75+ p2p_bootstrap : & mut Vec < String > ,
76+ ) -> MergeCounts {
77+ let mut counts = MergeCounts :: default ( ) ;
7578
7679 for peer in list. peers {
7780 if let Some ( url) = peer
7881 . http_url
7982 . as_ref ( )
80- . filter ( |u| !u. is_empty ( ) && !config . bootstrap_peers . contains ( u) )
83+ . filter ( |u| !u. is_empty ( ) && !http_peers . contains ( u) )
8184 {
82- config . bootstrap_peers . push ( url. clone ( ) ) ;
83- added_http += 1 ;
85+ http_peers . push ( url. clone ( ) ) ;
86+ counts . http += 1 ;
8487 }
8588
8689 if let Some ( addr_str) = peer. p2p_multiaddr . as_ref ( ) . filter ( |s| !s. is_empty ( ) ) {
8790 match Multiaddr :: from_str ( addr_str) {
8891 Ok ( _) => {
89- if !config . p2p_bootstrap . contains ( addr_str) {
90- config . p2p_bootstrap . push ( addr_str. clone ( ) ) ;
91- added_p2p += 1 ;
92+ if !p2p_bootstrap. contains ( addr_str) {
93+ p2p_bootstrap. push ( addr_str. clone ( ) ) ;
94+ counts . p2p += 1 ;
9295 }
9396 }
9497 Err ( e) => warn ! (
@@ -101,11 +104,213 @@ pub fn merge_seeds(config: &mut Config) {
101104 }
102105 }
103106
104- if added_http > 0 || added_p2p > 0 {
107+ counts
108+ }
109+
110+ /// Merge the embedded seed list into the runtime config.
111+ ///
112+ /// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
113+ /// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
114+ /// - Dedupes against entries already present (env / CLI takes precedence)
115+ /// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
116+ pub fn merge_seeds ( config : & mut Config ) {
117+ if seeds_disabled ( ) {
118+ info ! ( "bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS" ) ;
119+ return ;
120+ }
121+
122+ let list = match parse_seed_list ( EMBEDDED_PEERS_JSON ) {
123+ Ok ( l) => l,
124+ Err ( e) => {
125+ warn ! ( err = %e, "failed to load embedded bootstrap-peers.json — skipping" ) ;
126+ return ;
127+ }
128+ } ;
129+
130+ let counts = merge_into_vecs ( list, & mut config. bootstrap_peers , & mut config. p2p_bootstrap ) ;
131+
132+ if counts. http > 0 || counts. p2p > 0 {
105133 info ! (
106- http_peers = added_http ,
107- p2p_peers = added_p2p ,
134+ http_peers = counts . http ,
135+ p2p_peers = counts . p2p ,
108136 "merged bootstrap seed list into config"
109137 ) ;
110138 }
111139}
140+
141+ #[ cfg( test) ]
142+ mod tests {
143+ use super :: * ;
144+
145+ #[ test]
146+ fn parse_valid_v1_list ( ) {
147+ let json = r#"{
148+ "version": 1,
149+ "updated": "2026-04-29",
150+ "peers": [
151+ {
152+ "name": "alpha",
153+ "operator": "Alice",
154+ "did": "did:key:z6MkAlice",
155+ "http_url": "https://alpha.example.com",
156+ "p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546",
157+ "added": "2026-04-29"
158+ }
159+ ]
160+ }"# ;
161+ let list = parse_seed_list ( json) . expect ( "should parse" ) ;
162+ assert_eq ! ( list. version, 1 ) ;
163+ assert_eq ! ( list. peers. len( ) , 1 ) ;
164+ assert_eq ! ( list. peers[ 0 ] . name, "alpha" ) ;
165+ }
166+
167+ #[ test]
168+ fn parse_rejects_unknown_version ( ) {
169+ let json = r#"{ "version": 99, "peers": [] }"# ;
170+ let err = parse_seed_list ( json) . expect_err ( "should reject" ) ;
171+ assert ! ( err. contains( "unsupported" ) ) ;
172+ }
173+
174+ #[ test]
175+ fn parse_rejects_malformed_json ( ) {
176+ let err = parse_seed_list ( "{ not json" ) . expect_err ( "should reject" ) ;
177+ assert ! ( !err. is_empty( ) ) ;
178+ }
179+
180+ #[ test]
181+ fn parse_accepts_empty_peers_array ( ) {
182+ let json = r#"{ "version": 1, "peers": [] }"# ;
183+ let list = parse_seed_list ( json) . expect ( "should parse" ) ;
184+ assert ! ( list. peers. is_empty( ) ) ;
185+ }
186+
187+ #[ test]
188+ fn parse_treats_missing_peers_as_empty ( ) {
189+ let json = r#"{ "version": 1 }"# ;
190+ let list = parse_seed_list ( json) . expect ( "should parse" ) ;
191+ assert ! ( list. peers. is_empty( ) ) ;
192+ }
193+
194+ #[ test]
195+ fn merge_appends_new_http_and_p2p ( ) {
196+ let list = parse_seed_list (
197+ r#"{
198+ "version": 1,
199+ "peers": [
200+ {
201+ "name": "alpha",
202+ "http_url": "https://alpha.example.com",
203+ "p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546"
204+ }
205+ ]
206+ }"# ,
207+ )
208+ . unwrap ( ) ;
209+
210+ let mut http = Vec :: new ( ) ;
211+ let mut p2p = Vec :: new ( ) ;
212+ let counts = merge_into_vecs ( list, & mut http, & mut p2p) ;
213+
214+ assert_eq ! ( counts, MergeCounts { http: 1 , p2p: 1 } ) ;
215+ assert_eq ! ( http, vec![ "https://alpha.example.com" ] ) ;
216+ assert_eq ! ( p2p, vec![ "/ip4/1.2.3.4/tcp/7546" ] ) ;
217+ }
218+
219+ #[ test]
220+ fn merge_dedupes_existing_entries ( ) {
221+ let list = parse_seed_list (
222+ r#"{
223+ "version": 1,
224+ "peers": [
225+ { "name": "alpha", "http_url": "https://alpha.example.com" }
226+ ]
227+ }"# ,
228+ )
229+ . unwrap ( ) ;
230+
231+ let mut http = vec ! [ "https://alpha.example.com" . to_string( ) ] ;
232+ let mut p2p = Vec :: new ( ) ;
233+ let counts = merge_into_vecs ( list, & mut http, & mut p2p) ;
234+
235+ assert_eq ! ( counts. http, 0 , "should not double-add" ) ;
236+ assert_eq ! ( http. len( ) , 1 ) ;
237+ }
238+
239+ #[ test]
240+ fn merge_skips_invalid_p2p_multiaddr ( ) {
241+ let list = parse_seed_list (
242+ r#"{
243+ "version": 1,
244+ "peers": [
245+ {
246+ "name": "bad",
247+ "http_url": "https://bad.example.com",
248+ "p2p_multiaddr": "this is not a multiaddr"
249+ }
250+ ]
251+ }"# ,
252+ )
253+ . unwrap ( ) ;
254+
255+ let mut http = Vec :: new ( ) ;
256+ let mut p2p = Vec :: new ( ) ;
257+ let counts = merge_into_vecs ( list, & mut http, & mut p2p) ;
258+
259+ assert_eq ! ( counts. http, 1 , "http still added" ) ;
260+ assert_eq ! ( counts. p2p, 0 , "invalid p2p skipped" ) ;
261+ assert ! ( p2p. is_empty( ) ) ;
262+ }
263+
264+ #[ test]
265+ fn merge_skips_empty_strings ( ) {
266+ let list = parse_seed_list (
267+ r#"{
268+ "version": 1,
269+ "peers": [
270+ { "name": "blank", "http_url": "", "p2p_multiaddr": "" }
271+ ]
272+ }"# ,
273+ )
274+ . unwrap ( ) ;
275+
276+ let mut http = Vec :: new ( ) ;
277+ let mut p2p = Vec :: new ( ) ;
278+ let counts = merge_into_vecs ( list, & mut http, & mut p2p) ;
279+
280+ assert_eq ! ( counts, MergeCounts :: default ( ) ) ;
281+ }
282+
283+ #[ test]
284+ fn merge_handles_null_optional_fields ( ) {
285+ let list = parse_seed_list (
286+ r#"{
287+ "version": 1,
288+ "peers": [
289+ {
290+ "name": "alpha",
291+ "operator": null,
292+ "did": null,
293+ "http_url": "https://alpha.example.com",
294+ "p2p_multiaddr": null,
295+ "added": null
296+ }
297+ ]
298+ }"# ,
299+ )
300+ . unwrap ( ) ;
301+
302+ let mut http = Vec :: new ( ) ;
303+ let mut p2p = Vec :: new ( ) ;
304+ let counts = merge_into_vecs ( list, & mut http, & mut p2p) ;
305+
306+ assert_eq ! ( counts, MergeCounts { http: 1 , p2p: 0 } ) ;
307+ }
308+
309+ #[ test]
310+ fn embedded_seed_list_parses_successfully ( ) {
311+ // Regression: the canonical bootstrap-peers.json shipped in the repo
312+ // must always be valid, since it's compiled into the binary.
313+ parse_seed_list ( EMBEDDED_PEERS_JSON )
314+ . expect ( "embedded bootstrap-peers.json must always parse" ) ;
315+ }
316+ }
0 commit comments