Skip to content

Commit 1e37a3c

Browse files
committed
Add the ability to compose configurations
Every configurations can be composed with any other to get the union of them in a safe and consistent way. This might require to upgrade access rights when they are not supported by one configuration to make a consistent security policy. This new operation is commutative. Replace Variable::insert() with extend() to make it possible to compose variable definitions. Closes: landlock-lsm#20 Signed-off-by: Mickaël Salaün <mic@digikod.net>
1 parent 782c118 commit 1e37a3c

File tree

5 files changed

+459
-6
lines changed

5 files changed

+459
-6
lines changed

src/config.rs

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
148147
impl 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+
}

src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ mod nonempty;
77
mod parser;
88
mod variable;
99

10+
#[cfg(test)]
11+
#[macro_use]
12+
extern crate lazy_static;
13+
1014
#[cfg(test)]
1115
mod tests_helpers;
1216

@@ -17,5 +21,4 @@ mod tests_parser;
1721
mod tests_variable;
1822

1923
#[cfg(test)]
20-
#[macro_use]
21-
extern crate lazy_static;
24+
mod tests_compose;

src/parser.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ pub enum TemplateToken {
1818
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1919
pub struct TemplateString(pub Vec<TemplateToken>);
2020

21+
impl TemplateString {
22+
#[cfg(test)]
23+
pub(crate) fn from_text<T>(text: T) -> Self
24+
where
25+
T: Into<String>,
26+
{
27+
Self(vec![TemplateToken::Text(text.into())])
28+
}
29+
}
30+
2131
impl std::fmt::Display for TemplateString {
2232
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2333
for token in &self.0 {

0 commit comments

Comments
 (0)