Skip to content

Commit c752c94

Browse files
committed
review pass: capture could offer a key the file cannot spell
1 parent 6ad7a6b commit c752c94

3 files changed

Lines changed: 87 additions & 22 deletions

File tree

‎src/capture.rs‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -206,17 +206,8 @@ fn print_proposal(selected: &[&Candidate]) {
206206
/// can decide about.
207207
fn print_override_proposal(proposals: &[crate::overrides::Proposal]) {
208208
for p in proposals {
209-
let keys: Vec<String> = p
210-
.keys
211-
.iter()
212-
.map(|(group, key, _)| {
213-
if group == crate::overrides::CONTEXT {
214-
key.clone()
215-
} else {
216-
format!("{group}/{key}")
217-
}
218-
})
219-
.collect();
209+
let keys: Vec<String> =
210+
p.keys.iter().map(|(group, key, _)| crate::overrides::key_label(group, key)).collect();
220211
println!(" + {} [overrides] {} {}", p.app, p.scope.as_str(), keys.join(", "));
221212
}
222213
}

‎src/containerfile.rs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,8 +1901,15 @@ fn etc_writes(containerfile: &str) -> Vec<String> {
19011901
}
19021902
// Split on the shell's separators so a line running several
19031903
// commands is read as several commands, and the destination of
1904-
// each `ln -s` is its own last word.
1905-
for segment in line.split("&&").flat_map(|part| part.split(';')) {
1904+
// each `ln -s` is its own last word. Asked of the whole line
1905+
// first, so the splitting only happens on the lines that could
1906+
// possibly answer yes.
1907+
for segment in line
1908+
.contains("ln -s")
1909+
.then(|| line.split("&&").flat_map(|part| part.split(';')))
1910+
.into_iter()
1911+
.flatten()
1912+
{
19061913
if !segment.contains("ln -s") {
19071914
continue;
19081915
}

‎src/overrides.rs‎

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -388,16 +388,25 @@ pub struct Drift {
388388
pub change: &'static str,
389389
}
390390

391+
/// How a key reads to a person. `[Context]` is where most keys live and
392+
/// naming it every time would bury what the line is about, so only the
393+
/// other groups say which one they are.
394+
///
395+
/// One function because two surfaces print these: `kuma diff` reports
396+
/// drift, `kuma capture` proposes, and a key that reads one way in one
397+
/// and another way in the other is two vocabularies for one thing.
398+
pub fn key_label(group: &str, key: &str) -> String {
399+
if group == CONTEXT {
400+
key.to_string()
401+
} else {
402+
format!("{group}/{key}")
403+
}
404+
}
405+
391406
impl Drift {
392-
/// How it reads in a diff. `[Context]` is where most keys live and
393-
/// naming it every time would bury the app id, so only the other
394-
/// groups say which one they are.
407+
/// How it reads in a diff: the app, then the key.
395408
pub fn item(&self) -> String {
396-
if self.group == CONTEXT {
397-
format!("{} {}", self.app, self.key)
398-
} else {
399-
format!("{} {}/{}", self.app, self.group, self.key)
400-
}
409+
format!("{} {}", self.app, key_label(&self.group, &self.key))
401410
}
402411
}
403412

@@ -510,9 +519,19 @@ pub struct Proposal {
510519
/// still copied through by convergence; it just cannot be proposed,
511520
/// because there is nowhere in the schema to put it.
512521
fn representable(group: &str, key: &str) -> bool {
522+
let spelled_with = |extra: &[char]| {
523+
key.chars().any(|c| c.is_ascii_alphanumeric())
524+
&& key.chars().all(|c| c.is_ascii_alphanumeric() || extra.contains(&c))
525+
};
513526
match group {
514527
CONTEXT => AppOverride::default().context_lists().iter().any(|(name, _)| *name == key),
515-
ENVIRONMENT | SESSION_BUS | SYSTEM_BUS => true,
528+
// The parser's own alphabets, asked here rather than discovered
529+
// afterwards: a key kuma cannot spell in a declaration must not
530+
// be proposed for one, or capture offers an edit the validating
531+
// write then refuses, taking every other proposal down with it.
532+
// Convergence still copies such a key through untouched.
533+
ENVIRONMENT => spelled_with(&['_']),
534+
SESSION_BUS | SYSTEM_BUS => spelled_with(&['.', '-', '_', '*']),
516535
_ => false,
517536
}
518537
}
@@ -679,6 +698,27 @@ mod tests {
679698
assert!(out.contains("sockets=x11;"), "{out}");
680699
}
681700

701+
/// The state file has a writer and a reader and no format anywhere
702+
/// else, so the only thing keeping them agreeing is that they were
703+
/// written together. A key whose group holds a space ("Session Bus
704+
/// Policy") is the shape that would break a naive separator.
705+
#[test]
706+
fn the_state_file_survives_a_round_trip() {
707+
let tmp = tempfile::tempdir().unwrap();
708+
let path = tmp.path().join("state");
709+
let mut owned: BTreeMap<String, Vec<String>> = BTreeMap::new();
710+
owned.insert(
711+
"org.example.App".into(),
712+
vec![
713+
owned_id(CONTEXT, "filesystems"),
714+
owned_id(SESSION_BUS, "org.freedesktop.Flatpak"),
715+
],
716+
);
717+
owned.insert("org.example.Other".into(), vec![owned_id(ENVIRONMENT, "GTK_THEME")]);
718+
write_state(&path, &owned).unwrap();
719+
assert_eq!(read_state(&path), owned);
720+
}
721+
682722
/// A machine that declares no permissions ends up with no state
683723
/// file either. The converger runs on every boot of every machine,
684724
/// and one that creates a file to record that it owns nothing is
@@ -950,6 +990,33 @@ mod tests {
950990
);
951991
}
952992

993+
/// A key the declaration could not spell is not proposed for it.
994+
/// Capture writes through the same validating path `kuma add` uses,
995+
/// so a key with a space in its name would not land quietly: it
996+
/// would fail the write and take every other proposal with it.
997+
#[test]
998+
fn a_key_the_declaration_cannot_spell_is_not_offered() {
999+
let tmp = tempfile::tempdir().unwrap();
1000+
let root = tmp.path();
1001+
let home = root.join("home/mira");
1002+
let store_dir = store(Scope::System, root, &home);
1003+
std::fs::create_dir_all(&store_dir).unwrap();
1004+
std::fs::write(
1005+
store_dir.join("org.example.App"),
1006+
"[Environment]\nGTK_THEME=Adwaita\nnot a var name=x\n\
1007+
[Some Future Group]\nwhatever=1\n",
1008+
)
1009+
.unwrap();
1010+
let installed: BTreeSet<&str> = ["org.example.App"].into_iter().collect();
1011+
let (proposals, _) = capturable(&installed, &Default::default(), root, &home);
1012+
assert_eq!(proposals.len(), 1);
1013+
assert_eq!(
1014+
proposals[0].keys,
1015+
vec![("Environment".to_string(), "GTK_THEME".to_string(), "Adwaita".to_string())],
1016+
"only what the schema can hold"
1017+
);
1018+
}
1019+
9531020
/// One app declares into one store, so an app carrying undeclared
9541021
/// keys in both is named rather than guessed at. Picking a store for
9551022
/// somebody is how a proposal stops being something you can trust

0 commit comments

Comments
 (0)