@@ -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+
391406impl 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.
512521fn 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]\n GTK_THEME=Adwaita\n not a var name=x\n \
1007+ [Some Future Group]\n whatever=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