@@ -64,7 +64,7 @@ impl TryFrom<NonEmptyStruct<JsonConfig>> for Config {
6464 let name = variable. name . parse ( ) ?;
6565 let literal = variable. literal . unwrap_or_default ( ) ;
6666 // TODO: Check and warn if users tried to use variable in literal strings?
67- config. variables . insert ( name, literal. into_iter ( ) . collect ( ) ) ;
67+ config. variables . extend ( name, literal. into_iter ( ) . collect ( ) ) ;
6868 }
6969
7070 for ruleset in json. ruleset . unwrap_or_default ( ) {
@@ -144,8 +144,72 @@ pub enum ParseTomlError {
144144 SerdeToml ( #[ from] toml:: de:: Error ) ,
145145}
146146
147- // TODO: Add a merge method to compose with another Config.
148147impl Config {
148+ /// Composes two configurations by taking the union of `other` with `self`
149+ /// in a safe best-effort way.
150+ ///
151+ /// When composing configurations with different ABI versions (e.g., one
152+ /// against ABI::V1 and another against ABI::V2), the composition will
153+ /// upgrade the lower ABI configuration to match the higher one, ensuring
154+ /// the resulting configuration remains functional.
155+ ///
156+ /// # Behavior
157+ ///
158+ /// - Handled access rights are combined using bitwise OR.
159+ /// - Existing rules are augmented with additional access rights to maintain
160+ /// compatibility.
161+ /// - Paths not handling newer access rights automatically receive them.
162+ /// - Variables from both configurations are merged.
163+ ///
164+ /// # Commutativity
165+ ///
166+ /// This operation is commutative: `a.compose(&b)` produces the same result
167+ /// as `b.compose(&a)`. The order of composition does not affect the final
168+ /// configuration, ensuring predictable behavior regardless of the sequence
169+ /// in which configurations are combined.
170+ pub fn compose ( & mut self , other : & Self ) {
171+ // The full rule access rights for other are the union of the explicit
172+ // allowed access rights and the unhandled ones compared to self.
173+ let other_implicit_fs = self . handled_fs & !other. handled_fs ;
174+ let other_implicit_net = self . handled_net & !other. handled_net ;
175+ let self_implicit_fs = other. handled_fs & !self . handled_fs ;
176+ let self_implicit_net = other. handled_net & !self . handled_net ;
177+
178+ // First step: upgrade the current access rights according to other's
179+ // handled access rights.
180+ self . rules_path_beneath
181+ . values_mut ( )
182+ . for_each ( |access| * access |= self_implicit_fs) ;
183+ self . rules_net_port
184+ . values_mut ( )
185+ . for_each ( |access| * access |= self_implicit_net) ;
186+
187+ // Second step: add the new rules from other, upgraded according to
188+ // implicit handled access rights.
189+ for ( path, access) in & other. rules_path_beneath {
190+ self . rules_path_beneath
191+ . entry ( path. clone ( ) )
192+ . and_modify ( |a| * a |= * access | other_implicit_fs)
193+ . or_insert ( * access | other_implicit_fs) ;
194+ }
195+ for ( port, access) in & other. rules_net_port {
196+ self . rules_net_port
197+ . entry ( * port)
198+ . and_modify ( |a| * a |= * access | other_implicit_net)
199+ . or_insert ( * access | other_implicit_net) ;
200+ }
201+
202+ // Third step: merge variables.
203+ for ( name, value) in other. variables . iter ( ) {
204+ self . variables . extend ( name. clone ( ) , value. clone ( ) ) ;
205+ }
206+
207+ // Fourth step: upgrade the handled access rights.
208+ self . handled_fs |= other. handled_fs ;
209+ self . handled_net |= other. handled_net ;
210+ self . scoped |= other. scoped ;
211+ }
212+
149213 pub fn parse_json < R > ( reader : R ) -> Result < Self , ParseJsonError >
150214 where
151215 R : std:: io:: Read ,
@@ -233,3 +297,81 @@ impl TryFrom<Config> for ResolvedConfig {
233297 } )
234298 }
235299}
300+
301+ #[ cfg( test) ]
302+ mod tests_compose {
303+ use super :: * ;
304+ use landlock:: { Access , ABI } ;
305+
306+ #[ test]
307+ fn test_empty_ruleset ( ) {
308+ let mut c1 = Config {
309+ handled_fs : AccessFs :: Execute . into ( ) ,
310+ ..Default :: default ( )
311+ } ;
312+ let c2 = c1. clone ( ) ;
313+ c1. compose ( & c2) ;
314+ assert_eq ! ( c1, c2) ;
315+ }
316+
317+ #[ test]
318+ fn test_different_ruleset ( ) {
319+ let mut c1 = Config {
320+ handled_fs : AccessFs :: Execute . into ( ) ,
321+ ..Default :: default ( )
322+ } ;
323+ let c2 = Config {
324+ handled_net : AccessNet :: BindTcp . into ( ) ,
325+ ..Default :: default ( )
326+ } ;
327+ let expect = Config {
328+ handled_fs : AccessFs :: Execute . into ( ) ,
329+ handled_net : AccessNet :: BindTcp . into ( ) ,
330+ ..Default :: default ( )
331+ } ;
332+ c1. compose ( & c2) ;
333+ assert_eq ! ( c1, expect) ;
334+ }
335+
336+ #[ test]
337+ fn test_compose_v1_v2_without_one_right ( ) {
338+ let c1_access = AccessFs :: from_all ( ABI :: V1 ) ;
339+ let mut c1 = Config {
340+ handled_fs : c1_access,
341+ rules_path_beneath : [
342+ ( TemplateString :: from_text ( "/common" ) , c1_access) ,
343+ ( TemplateString :: from_text ( "/c1" ) , c1_access) ,
344+ ]
345+ . into ( ) ,
346+ ..Default :: default ( )
347+ } ;
348+
349+ assert ! ( c1_access. contains( AccessFs :: WriteFile ) ) ;
350+ let c2_access = AccessFs :: from_all ( ABI :: V2 ) & !AccessFs :: WriteFile ;
351+ let c2 = Config {
352+ handled_fs : c2_access,
353+ rules_path_beneath : [
354+ ( TemplateString :: from_text ( "/common" ) , c2_access) ,
355+ ( TemplateString :: from_text ( "/c2" ) , c2_access) ,
356+ ]
357+ . into ( ) ,
358+ ..Default :: default ( )
359+ } ;
360+
361+ let expect = Config {
362+ handled_fs : c1_access | c2_access,
363+ rules_path_beneath : [
364+ ( TemplateString :: from_text ( "/common" ) , c1_access | c2_access) ,
365+ ( TemplateString :: from_text ( "/c1" ) , c1_access | c2_access) ,
366+ (
367+ TemplateString :: from_text ( "/c2" ) ,
368+ c2_access | AccessFs :: WriteFile ,
369+ ) ,
370+ ]
371+ . into ( ) ,
372+ ..Default :: default ( )
373+ } ;
374+ c1. compose ( & c2) ;
375+ assert_eq ! ( c1, expect) ;
376+ }
377+ }
0 commit comments