max / makenotwork
6 files changed,
+863 insertions,
-0 deletions
| @@ -0,0 +1,286 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "bitflags" | |
| 7 | + | version = "2.13.1" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 12 | + | name = "bumpalo" | |
| 13 | + | version = "3.20.3" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 16 | + | ||
| 17 | + | [[package]] | |
| 18 | + | name = "cc" | |
| 19 | + | version = "1.4.0" | |
| 20 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 21 | + | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | |
| 22 | + | dependencies = [ | |
| 23 | + | "find-msvc-tools", | |
| 24 | + | "shlex", | |
| 25 | + | ] | |
| 26 | + | ||
| 27 | + | [[package]] | |
| 28 | + | name = "cfg-if" | |
| 29 | + | version = "1.0.4" | |
| 30 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 31 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 32 | + | ||
| 33 | + | [[package]] | |
| 34 | + | name = "fallible-iterator" | |
| 35 | + | version = "0.3.0" | |
| 36 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 37 | + | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 38 | + | ||
| 39 | + | [[package]] | |
| 40 | + | name = "fallible-streaming-iterator" | |
| 41 | + | version = "0.1.9" | |
| 42 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 43 | + | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 44 | + | ||
| 45 | + | [[package]] | |
| 46 | + | name = "find-msvc-tools" | |
| 47 | + | version = "0.1.9" | |
| 48 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 49 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 50 | + | ||
| 51 | + | [[package]] | |
| 52 | + | name = "foldhash" | |
| 53 | + | version = "0.2.0" | |
| 54 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 55 | + | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 56 | + | ||
| 57 | + | [[package]] | |
| 58 | + | name = "hashbrown" | |
| 59 | + | version = "0.16.1" | |
| 60 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 61 | + | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 62 | + | dependencies = [ | |
| 63 | + | "foldhash", | |
| 64 | + | ] | |
| 65 | + | ||
| 66 | + | [[package]] | |
| 67 | + | name = "hashlink" | |
| 68 | + | version = "0.11.1" | |
| 69 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 70 | + | checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" | |
| 71 | + | dependencies = [ | |
| 72 | + | "hashbrown", | |
| 73 | + | ] | |
| 74 | + | ||
| 75 | + | [[package]] | |
| 76 | + | name = "js-sys" | |
| 77 | + | version = "0.3.103" | |
| 78 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 79 | + | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" | |
| 80 | + | dependencies = [ | |
| 81 | + | "cfg-if", | |
| 82 | + | "wasm-bindgen", | |
| 83 | + | ] | |
| 84 | + | ||
| 85 | + | [[package]] | |
| 86 | + | name = "libsqlite3-sys" | |
| 87 | + | version = "0.37.0" | |
| 88 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 89 | + | checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" | |
| 90 | + | dependencies = [ | |
| 91 | + | "cc", | |
| 92 | + | "pkg-config", | |
| 93 | + | "vcpkg", | |
| 94 | + | ] | |
| 95 | + | ||
| 96 | + | [[package]] | |
| 97 | + | name = "once_cell" | |
| 98 | + | version = "1.21.4" | |
| 99 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 100 | + | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 101 | + | ||
| 102 | + | [[package]] | |
| 103 | + | name = "pkg-config" | |
| 104 | + | version = "0.3.33" | |
| 105 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 106 | + | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 107 | + | ||
| 108 | + | [[package]] | |
| 109 | + | name = "proc-macro2" | |
| 110 | + | version = "1.0.107" | |
| 111 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 112 | + | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 113 | + | dependencies = [ | |
| 114 | + | "unicode-ident", | |
| 115 | + | ] | |
| 116 | + | ||
| 117 | + | [[package]] | |
| 118 | + | name = "quote" | |
| 119 | + | version = "1.0.47" | |
| 120 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 121 | + | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 122 | + | dependencies = [ | |
| 123 | + | "proc-macro2", | |
| 124 | + | ] | |
| 125 | + | ||
| 126 | + | [[package]] | |
| 127 | + | name = "rsqlite-vfs" | |
| 128 | + | version = "0.1.1" | |
| 129 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 130 | + | checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 131 | + | dependencies = [ | |
| 132 | + | "hashbrown", | |
| 133 | + | "thiserror", | |
| 134 | + | ] | |
| 135 | + | ||
| 136 | + | [[package]] | |
| 137 | + | name = "rusqlite" | |
| 138 | + | version = "0.39.0" | |
| 139 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 140 | + | checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" | |
| 141 | + | dependencies = [ | |
| 142 | + | "bitflags", | |
| 143 | + | "fallible-iterator", | |
| 144 | + | "fallible-streaming-iterator", | |
| 145 | + | "hashlink", | |
| 146 | + | "libsqlite3-sys", | |
| 147 | + | "smallvec", | |
| 148 | + | "sqlite-wasm-rs", | |
| 149 | + | ] | |
| 150 | + | ||
| 151 | + | [[package]] | |
| 152 | + | name = "rustversion" | |
| 153 | + | version = "1.0.23" | |
| 154 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 155 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 156 | + | ||
| 157 | + | [[package]] | |
| 158 | + | name = "shlex" | |
| 159 | + | version = "2.0.1" | |
| 160 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 161 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 162 | + | ||
| 163 | + | [[package]] | |
| 164 | + | name = "smallvec" | |
| 165 | + | version = "1.15.2" | |
| 166 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 167 | + | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | |
| 168 | + | ||
| 169 | + | [[package]] | |
| 170 | + | name = "sqlite-wasm-rs" | |
| 171 | + | version = "0.5.5" | |
| 172 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 173 | + | checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 174 | + | dependencies = [ | |
| 175 | + | "cc", | |
| 176 | + | "js-sys", | |
| 177 | + | "rsqlite-vfs", | |
| 178 | + | "wasm-bindgen", | |
| 179 | + | ] | |
| 180 | + | ||
| 181 | + | [[package]] | |
| 182 | + | name = "syn" | |
| 183 | + | version = "2.0.119" | |
| 184 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 185 | + | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 186 | + | dependencies = [ | |
| 187 | + | "proc-macro2", | |
| 188 | + | "quote", | |
| 189 | + | "unicode-ident", | |
| 190 | + | ] | |
| 191 | + | ||
| 192 | + | [[package]] | |
| 193 | + | name = "syn" | |
| 194 | + | version = "3.0.3" | |
| 195 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 196 | + | checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" | |
| 197 | + | dependencies = [ | |
| 198 | + | "proc-macro2", | |
| 199 | + | "quote", | |
| 200 | + | "unicode-ident", | |
| 201 | + | ] | |
| 202 | + | ||
| 203 | + | [[package]] | |
| 204 | + | name = "synckit-config" | |
| 205 | + | version = "0.1.0" | |
| 206 | + | dependencies = [ | |
| 207 | + | "rusqlite", | |
| 208 | + | "thiserror", | |
| 209 | + | ] | |
| 210 | + | ||
| 211 | + | [[package]] | |
| 212 | + | name = "thiserror" | |
| 213 | + | version = "2.0.19" | |
| 214 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 215 | + | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" | |
| 216 | + | dependencies = [ | |
| 217 | + | "thiserror-impl", | |
| 218 | + | ] | |
| 219 | + | ||
| 220 | + | [[package]] | |
| 221 | + | name = "thiserror-impl" | |
| 222 | + | version = "2.0.19" | |
| 223 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 224 | + | checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" | |
| 225 | + | dependencies = [ | |
| 226 | + | "proc-macro2", | |
| 227 | + | "quote", | |
| 228 | + | "syn 3.0.3", | |
| 229 | + | ] | |
| 230 | + | ||
| 231 | + | [[package]] | |
| 232 | + | name = "unicode-ident" | |
| 233 | + | version = "1.0.24" | |
| 234 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 235 | + | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 236 | + | ||
| 237 | + | [[package]] | |
| 238 | + | name = "vcpkg" | |
| 239 | + | version = "0.2.15" | |
| 240 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 241 | + | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 242 | + | ||
| 243 | + | [[package]] | |
| 244 | + | name = "wasm-bindgen" | |
| 245 | + | version = "0.2.126" | |
| 246 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 247 | + | checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" | |
| 248 | + | dependencies = [ | |
| 249 | + | "cfg-if", | |
| 250 | + | "once_cell", | |
| 251 | + | "rustversion", | |
| 252 | + | "wasm-bindgen-macro", | |
| 253 | + | "wasm-bindgen-shared", | |
| 254 | + | ] | |
| 255 | + | ||
| 256 | + | [[package]] | |
| 257 | + | name = "wasm-bindgen-macro" | |
| 258 | + | version = "0.2.126" | |
| 259 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 260 | + | checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" | |
| 261 | + | dependencies = [ | |
| 262 | + | "quote", | |
| 263 | + | "wasm-bindgen-macro-support", | |
| 264 | + | ] | |
| 265 | + | ||
| 266 | + | [[package]] | |
| 267 | + | name = "wasm-bindgen-macro-support" | |
| 268 | + | version = "0.2.126" | |
| 269 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 270 | + | checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" | |
| 271 | + | dependencies = [ | |
| 272 | + | "bumpalo", | |
| 273 | + | "proc-macro2", | |
| 274 | + | "quote", | |
| 275 | + | "syn 2.0.119", | |
| 276 | + | "wasm-bindgen-shared", | |
| 277 | + | ] | |
| 278 | + | ||
| 279 | + | [[package]] | |
| 280 | + | name = "wasm-bindgen-shared" | |
| 281 | + | version = "0.2.126" | |
| 282 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 283 | + | checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" | |
| 284 | + | dependencies = [ | |
| 285 | + | "unicode-ident", | |
| 286 | + | ] |
| @@ -0,0 +1,37 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "synckit-config" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | license-file = "LICENSE" | |
| 6 | + | description = "Local key/value app settings with sync postures. The store half of SyncKit's config sync, usable with no network." | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | rusqlite = { version = "0.39", features = ["bundled"] } | |
| 10 | + | thiserror = "2" | |
| 11 | + | ||
| 12 | + | [lints.rust] | |
| 13 | + | unused = "warn" | |
| 14 | + | unreachable_pub = "warn" | |
| 15 | + | ||
| 16 | + | [lints.clippy] | |
| 17 | + | pedantic = { level = "warn", priority = -1 } | |
| 18 | + | # Allow-list tuned from a measured breakdown across the ecosystem (2026-07-22). | |
| 19 | + | # Keep this block identical across repos. | |
| 20 | + | module_name_repetitions = "allow" | |
| 21 | + | missing_errors_doc = "allow" | |
| 22 | + | missing_panics_doc = "allow" | |
| 23 | + | doc_markdown = "allow" | |
| 24 | + | cast_possible_truncation = "allow" | |
| 25 | + | cast_sign_loss = "allow" | |
| 26 | + | cast_precision_loss = "allow" | |
| 27 | + | cast_possible_wrap = "allow" | |
| 28 | + | cast_lossless = "allow" | |
| 29 | + | must_use_candidate = "allow" | |
| 30 | + | too_many_lines = "allow" | |
| 31 | + | struct_excessive_bools = "allow" | |
| 32 | + | similar_names = "allow" | |
| 33 | + | items_after_statements = "allow" | |
| 34 | + | single_match_else = "allow" | |
| 35 | + | match_same_arms = "allow" | |
| 36 | + | unnecessary_wraps = "allow" | |
| 37 | + | type_complexity = "allow" |
| @@ -0,0 +1,120 @@ | |||
| 1 | + | Required Notice: Copyright 2026 Make Creative, LLC (https://makenot.work) | |
| 2 | + | ||
| 3 | + | PolyForm Noncommercial License 1.0.0 | |
| 4 | + | ||
| 5 | + | <https://polyformproject.org/licenses/noncommercial/1.0.0> | |
| 6 | + | ||
| 7 | + | Acceptance | |
| 8 | + | ||
| 9 | + | In order to get any license under these terms, you must agree to them as | |
| 10 | + | both strict obligations and conditions to all your licenses. | |
| 11 | + | ||
| 12 | + | Copyright License | |
| 13 | + | ||
| 14 | + | The licensor grants you a copyright license for the software to do | |
| 15 | + | everything you might do with the software that would otherwise infringe | |
| 16 | + | the licensor's copyright in it for any permitted purpose. However, you | |
| 17 | + | may only distribute the software according to Distribution License and | |
| 18 | + | make changes or new works based on the software according to Changes and | |
| 19 | + | New Works License. | |
| 20 | + | ||
| 21 | + | Distribution License | |
| 22 | + | ||
| 23 | + | The licensor grants you an additional copyright license to distribute | |
| 24 | + | copies of the software. Your license to distribute covers distributing | |
| 25 | + | the software with changes and new works permitted by Changes and New | |
| 26 | + | Works License. | |
| 27 | + | ||
| 28 | + | Notices | |
| 29 | + | ||
| 30 | + | You must ensure that anyone who gets a copy of any part of the software | |
| 31 | + | from you also gets a copy of these terms or the URL for them above, as | |
| 32 | + | well as copies of any plain-text lines beginning with "Required Notice:" | |
| 33 | + | that the licensor provided with the software. For example: | |
| 34 | + | ||
| 35 | + | Required Notice: Copyright Yoyodyne, Inc. (http://example.com) | |
| 36 | + | ||
| 37 | + | Changes and New Works License | |
| 38 | + | ||
| 39 | + | The licensor grants you an additional copyright license to make changes | |
| 40 | + | and new works based on the software for any permitted purpose. | |
| 41 | + | ||
| 42 | + | Patent License | |
| 43 | + | ||
| 44 | + | The licensor grants you a patent license for the software that covers | |
| 45 | + | patent claims the licensor can license, or becomes able to license, that | |
| 46 | + | you would infringe by using the software. | |
| 47 | + | ||
| 48 | + | Noncommercial Purposes | |
| 49 | + | ||
| 50 | + | Any noncommercial purpose is a permitted purpose. | |
| 51 | + | ||
| 52 | + | Personal Uses | |
| 53 | + | ||
| 54 | + | Personal use for research, experiment, and testing for the benefit of | |
| 55 | + | public knowledge, personal study, private entertainment, hobby projects, | |
| 56 | + | amateur pursuits, or religious observance, without any anticipated | |
| 57 | + | commercial application, is use for a permitted purpose. | |
| 58 | + | ||
| 59 | + | Noncommercial Organizations | |
| 60 | + | ||
| 61 | + | Use by any charitable organization, educational institution, public | |
| 62 | + | research organization, public safety or health organization, | |
| 63 | + | environmental protection organization, or government institution is use | |
| 64 | + | for a permitted purpose regardless of the source of funding or | |
| 65 | + | obligations resulting from the funding. | |
| 66 | + | ||
| 67 | + | Fair Use | |
| 68 | + | ||
| 69 | + | You may have "fair use" rights for the software under the law. These | |
| 70 | + | terms do not limit them. | |
| 71 | + | ||
| 72 | + | No Other Rights | |
| 73 | + | ||
| 74 | + | These terms do not allow you to sublicense or transfer any of your | |
| 75 | + | licenses to anyone else, or prevent the licensor from granting licenses | |
| 76 | + | to anyone else. These terms do not imply any other licenses. | |
| 77 | + | ||
| 78 | + | Patent Defense | |
| 79 | + | ||
| 80 | + | If you make any written claim that the software infringes or contributes | |
| 81 | + | to infringement of any patent, your patent license for the software | |
| 82 | + | granted under these terms ends immediately. If your company makes such a | |
| 83 | + | claim, your patent license ends immediately for work on behalf of your | |
| 84 | + | company. | |
| 85 | + | ||
| 86 | + | Violations | |
| 87 | + | ||
| 88 | + | The first time you are notified in writing that you have violated any of | |
| 89 | + | these terms, or done anything with the software not covered by your | |
| 90 | + | licenses, your licenses can nonetheless continue if you come into full | |
| 91 | + | compliance with these terms, and take practical steps to correct past | |
| 92 | + | violations, within 32 days of receiving notice. Otherwise, all your | |
| 93 | + | licenses end immediately. | |
| 94 | + | ||
| 95 | + | No Liability | |
| 96 | + | ||
| 97 | + | As far as the law allows, the software comes as is, without any warranty | |
| 98 | + | or condition, and the licensor will not be liable to you for any damages | |
| 99 | + | arising out of these terms or the use or nature of the software, under | |
| 100 | + | any kind of legal claim. | |
| 101 | + | ||
| 102 | + | Definitions | |
| 103 | + | ||
| 104 | + | The licensor is the individual or entity offering these terms, and the | |
| 105 | + | software is the software the licensor makes available under these terms. | |
| 106 | + | ||
| 107 | + | You refers to the individual or entity agreeing to these terms. | |
| 108 | + | ||
| 109 | + | Your company is any legal entity, sole proprietorship, or other kind of | |
| 110 | + | organization that you work for, plus all organizations that have control | |
| 111 | + | over, are under the control of, or are under common control with that | |
| 112 | + | organization. Control means ownership of substantially all the assets of | |
| 113 | + | an entity, or the power to direct its management and policies by vote, | |
| 114 | + | contract, or otherwise. Control can be direct or indirect. | |
| 115 | + | ||
| 116 | + | Your licenses are all the licenses granted to you for the software under | |
| 117 | + | these terms. | |
| 118 | + | ||
| 119 | + | Use means anything you do with the software requiring one of your | |
| 120 | + | licenses. |
| @@ -0,0 +1,42 @@ | |||
| 1 | + | //! Local key/value app settings, with a per-key sync posture. | |
| 2 | + | //! | |
| 3 | + | //! The store half of SyncKit's config sync, and the whole of it for an app that | |
| 4 | + | //! does not sync. A [`ConfigStore`] is a SQLite key/value table an app reads and | |
| 5 | + | //! writes with no network in sight; a [`ConfigSpec`] declares which of those keys | |
| 6 | + | //! may cross the sync boundary. `synckit-client` turns a spec into a | |
| 7 | + | //! `SyncTable`, but nothing here depends on it, so a no-sync consumer — the Alloy | |
| 8 | + | //! console remembering a theme — links this crate and not the sync runtime. | |
| 9 | + | //! | |
| 10 | + | //! ## Why this is its own crate, and why it is SQLite | |
| 11 | + | //! | |
| 12 | + | //! Four apps stored settings four ways (a synced SQLite table, an | |
| 13 | + | //! unconditionally-synced one, `localStorage`, a TOML file) and the theme | |
| 14 | + | //! extraction was about to make it five. The unifying substrate is SQLite rows, | |
| 15 | + | //! not a text file, because config syncs **per key**: two devices editing | |
| 16 | + | //! different keys must both survive (row-level HLC merge, which a whole-file blob | |
| 17 | + | //! cannot do), and some keys must never sync at all (see posture). Rows are what | |
| 18 | + | //! buy both. A text file would need a projection layer and a sidecar for the | |
| 19 | + | //! per-key merge metadata — SQLite by the back door. Design note: | |
| 20 | + | //! `synckit-config-design` in the wiki. | |
| 21 | + | //! | |
| 22 | + | //! ## Posture, and why it fails closed | |
| 23 | + | //! | |
| 24 | + | //! [`Posture`] is the one security-load-bearing type here. It descends from | |
| 25 | + | //! audiofiles' `DeviceLocal`/`Replicated`, which exists because a hostile server | |
| 26 | + | //! steered a local filesystem path across the sync boundary through an | |
| 27 | + | //! unclassified config key (fuzz-2026-07-06 #2, -07-20 #1, -07-21 #3). Two rules | |
| 28 | + | //! carried over intact: | |
| 29 | + | //! | |
| 30 | + | //! - The default is [`Posture::Local`]. A key crosses only when a spec declares | |
| 31 | + | //! it [`Posture::Synced`]. | |
| 32 | + | //! - An **undeclared** key is `Local`, never `Synced`. So the sync filter a spec | |
| 33 | + | //! drives fails closed: a key nobody classified stays on the device rather than | |
| 34 | + | //! leaking because someone forgot to name it. | |
| 35 | + | ||
| 36 | + | #![forbid(unsafe_code)] | |
| 37 | + | ||
| 38 | + | mod spec; | |
| 39 | + | mod store; | |
| 40 | + | ||
| 41 | + | pub use spec::{ConfigSpec, PolicyRow, Posture}; | |
| 42 | + | pub use store::{ConfigError, ConfigStore, Result}; |
| @@ -0,0 +1,178 @@ | |||
| 1 | + | //! What a config's keys are, and which of them may sync. | |
| 2 | + | //! | |
| 3 | + | //! A [`ConfigSpec`] is the source of truth for postures: a static list of the | |
| 4 | + | //! keys an app knows, each tagged [`Synced`] or [`Local`]. It is pure data with | |
| 5 | + | //! no connection and no I/O, so it can be a `const` an app declares once. The | |
| 6 | + | //! sync side ([`synckit-client`]) reads [`policy_rows`](ConfigSpec::policy_rows) | |
| 7 | + | //! to seed the filter that keeps `Local` keys off the wire. | |
| 8 | + | //! | |
| 9 | + | //! [`Synced`]: Posture::Synced | |
| 10 | + | //! [`Local`]: Posture::Local | |
| 11 | + | ||
| 12 | + | /// Whether a config key may be replicated to the user's other devices. | |
| 13 | + | /// | |
| 14 | + | /// The default is [`Local`](Posture::Local), and so is the answer for any key a | |
| 15 | + | /// spec does not mention: see [`ConfigSpec::posture`]. That is the whole safety | |
| 16 | + | /// property — a key crosses the sync boundary only when someone wrote down that | |
| 17 | + | /// it should. | |
| 18 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 19 | + | pub enum Posture { | |
| 20 | + | /// Stays on this device. Filesystem paths, local safety gates, and anything | |
| 21 | + | /// not explicitly cleared to sync. The default. | |
| 22 | + | #[default] | |
| 23 | + | Local, | |
| 24 | + | /// A user preference worth carrying across the user's devices. | |
| 25 | + | Synced, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl Posture { | |
| 29 | + | /// Whether a key of this posture is replicated. What the policy table stores. | |
| 30 | + | pub const fn replicated(self) -> bool { | |
| 31 | + | matches!(self, Posture::Synced) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// One row of the policy the sync filter reads: a key and whether it replicates. | |
| 36 | + | /// | |
| 37 | + | /// [`ConfigSpec::policy_rows`] yields these for the sync side to seed. A plain | |
| 38 | + | /// pair rather than a richer type because that is all the SQL filter needs, and | |
| 39 | + | /// keeping it flat means the seeding query is an obvious upsert. | |
| 40 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 41 | + | pub struct PolicyRow { | |
| 42 | + | pub key: &'static str, | |
| 43 | + | pub replicated: bool, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// The keys a config knows, and their postures. | |
| 47 | + | /// | |
| 48 | + | /// Declared as a `const` per app: the table it lives in, and every key with its | |
| 49 | + | /// posture. Only [`Synced`](Posture::Synced) keys need listing for correctness — | |
| 50 | + | /// an unlisted key reads as [`Local`](Posture::Local) — but listing the local | |
| 51 | + | /// ones too documents the surface and lets a test assert the set is complete. | |
| 52 | + | #[derive(Debug, Clone, Copy)] | |
| 53 | + | pub struct ConfigSpec { | |
| 54 | + | table: &'static str, | |
| 55 | + | keys: &'static [(&'static str, Posture)], | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl ConfigSpec { | |
| 59 | + | /// A spec over `table`, with `keys` declaring each known key's posture. | |
| 60 | + | /// | |
| 61 | + | /// Duplicate keys are a programming error the caller controls (the list is a | |
| 62 | + | /// `const`); the last one wins in [`posture`](Self::posture), matching how a | |
| 63 | + | /// reader would expect a later override to read, but a spec should not carry | |
| 64 | + | /// duplicates and [`policy_rows`](Self::policy_rows) emits them as written. | |
| 65 | + | pub const fn new(table: &'static str, keys: &'static [(&'static str, Posture)]) -> Self { | |
| 66 | + | Self { table, keys } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// The SQLite table the config lives in. | |
| 70 | + | pub const fn table(&self) -> &'static str { | |
| 71 | + | self.table | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// The declared keys and their postures, in declaration order. | |
| 75 | + | pub fn keys(&self) -> impl Iterator<Item = (&'static str, Posture)> + '_ { | |
| 76 | + | self.keys.iter().copied() | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// The posture of `key`. | |
| 80 | + | /// | |
| 81 | + | /// **Fail-closed: an undeclared key is [`Local`](Posture::Local).** This is | |
| 82 | + | /// the rule the whole sync boundary rests on. A key nobody classified never | |
| 83 | + | /// replicates, so forgetting to name a new local path does not leak it — the | |
| 84 | + | /// failure of omission keeps data home rather than sending it. | |
| 85 | + | pub fn posture(&self, key: &str) -> Posture { | |
| 86 | + | self.keys | |
| 87 | + | .iter() | |
| 88 | + | .rev() | |
| 89 | + | .find(|(name, _)| *name == key) | |
| 90 | + | .map_or(Posture::Local, |(_, posture)| *posture) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Whether `key` may cross the sync boundary. Shorthand for a `Synced` | |
| 94 | + | /// posture, and the question the sync filter actually asks. | |
| 95 | + | pub fn is_synced(&self, key: &str) -> bool { | |
| 96 | + | self.posture(key).replicated() | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// The policy rows the sync side seeds, one per declared key. | |
| 100 | + | /// | |
| 101 | + | /// Only these rows exist in the policy table, and the sync filter admits a | |
| 102 | + | /// key only if its row says `replicated`. A key absent here is absent there, | |
| 103 | + | /// which is the same fail-closed rule as [`posture`](Self::posture) enforced | |
| 104 | + | /// in SQL rather than in Rust. | |
| 105 | + | pub fn policy_rows(&self) -> impl Iterator<Item = PolicyRow> + '_ { | |
| 106 | + | self.keys.iter().map(|(key, posture)| PolicyRow { | |
| 107 | + | key, | |
| 108 | + | replicated: posture.replicated(), | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[cfg(test)] | |
| 114 | + | mod tests { | |
| 115 | + | use super::*; | |
| 116 | + | ||
| 117 | + | const SPEC: ConfigSpec = ConfigSpec::new( | |
| 118 | + | "app_config", | |
| 119 | + | &[ | |
| 120 | + | ("theme", Posture::Synced), | |
| 121 | + | ("sidebar_visible", Posture::Synced), | |
| 122 | + | ("mirror_path", Posture::Local), | |
| 123 | + | ], | |
| 124 | + | ); | |
| 125 | + | ||
| 126 | + | #[test] | |
| 127 | + | fn the_default_posture_is_local() { | |
| 128 | + | assert_eq!(Posture::default(), Posture::Local); | |
| 129 | + | assert!(!Posture::Local.replicated()); | |
| 130 | + | assert!(Posture::Synced.replicated()); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // The rule the sync boundary rests on: a key nobody classified stays home. | |
| 134 | + | // A forgotten local path must not leak because it was never named. | |
| 135 | + | #[test] | |
| 136 | + | fn an_undeclared_key_is_local_not_synced() { | |
| 137 | + | assert_eq!(SPEC.posture("never_heard_of_it"), Posture::Local); | |
| 138 | + | assert!(!SPEC.is_synced("never_heard_of_it")); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[test] | |
| 142 | + | fn declared_keys_report_their_posture() { | |
| 143 | + | assert!(SPEC.is_synced("theme")); | |
| 144 | + | assert!(SPEC.is_synced("sidebar_visible")); | |
| 145 | + | assert!(!SPEC.is_synced("mirror_path"), "a local path never syncs"); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // policy_rows is what the sync filter seeds; a key absent here is absent in | |
| 149 | + | // the filter, which is the same fail-closed rule enforced in SQL. | |
| 150 | + | #[test] | |
| 151 | + | fn policy_rows_carry_every_declared_key_and_its_replication() { | |
| 152 | + | let rows: Vec<PolicyRow> = SPEC.policy_rows().collect(); | |
| 153 | + | assert_eq!(rows.len(), 3); | |
| 154 | + | assert_eq!( | |
| 155 | + | rows[0], | |
| 156 | + | PolicyRow { | |
| 157 | + | key: "theme", | |
| 158 | + | replicated: true | |
| 159 | + | }, | |
| 160 | + | ); | |
| 161 | + | assert_eq!( | |
| 162 | + | rows[2], | |
| 163 | + | PolicyRow { | |
| 164 | + | key: "mirror_path", | |
| 165 | + | replicated: false, | |
| 166 | + | }, | |
| 167 | + | ); | |
| 168 | + | // No row for an unclassified key, so the SQL filter cannot admit one. | |
| 169 | + | assert!(!rows.iter().any(|r| r.key == "never_heard_of_it")); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn the_table_and_key_set_are_readable() { | |
| 174 | + | assert_eq!(SPEC.table(), "app_config"); | |
| 175 | + | let keys: Vec<&str> = SPEC.keys().map(|(k, _)| k).collect(); | |
| 176 | + | assert_eq!(keys, ["theme", "sidebar_visible", "mirror_path"]); | |
| 177 | + | } | |
| 178 | + | } |
| @@ -0,0 +1,200 @@ | |||
| 1 | + | //! The local key/value store: a SQLite table an app reads and writes. | |
| 2 | + | //! | |
| 3 | + | //! No network, no posture in sight — posture governs what *syncs*, and this is | |
| 4 | + | //! the store that exists whether or not an app syncs at all. It creates one | |
| 5 | + | //! table, `key TEXT PRIMARY KEY, value TEXT NOT NULL`, and offers get/set/unset | |
| 6 | + | //! over it. A no-sync consumer (the Alloy console) uses only this; a syncing one | |
| 7 | + | //! writes through the same store and lets `synckit-client` observe the table. | |
| 8 | + | ||
| 9 | + | use rusqlite::Connection; | |
| 10 | + | ||
| 11 | + | use crate::spec::ConfigSpec; | |
| 12 | + | ||
| 13 | + | /// Anything that can go wrong reading or writing config. | |
| 14 | + | /// | |
| 15 | + | /// One variant, wrapping rusqlite, because there is one failure mode: the | |
| 16 | + | /// database. A key that is not set is [`get`](ConfigStore::get) returning `None`, | |
| 17 | + | /// not an error. | |
| 18 | + | #[derive(Debug, thiserror::Error)] | |
| 19 | + | pub enum ConfigError { | |
| 20 | + | #[error("config store: {0}")] | |
| 21 | + | Db(#[from] rusqlite::Error), | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | pub type Result<T> = std::result::Result<T, ConfigError>; | |
| 25 | + | ||
| 26 | + | /// A config's local key/value store, over one SQLite table. | |
| 27 | + | /// | |
| 28 | + | /// Holds the table name and borrows the connection per call rather than owning | |
| 29 | + | /// it, because the syncing apps keep config in their existing database next to | |
| 30 | + | /// synced tables, and the store must not assume the connection is only its own. | |
| 31 | + | /// A no-sync app opens a connection for this store alone; either works. | |
| 32 | + | pub struct ConfigStore { | |
| 33 | + | table: &'static str, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl ConfigStore { | |
| 37 | + | /// Create the config table if it is absent, and return a handle to it. | |
| 38 | + | /// | |
| 39 | + | /// Idempotent: safe to call at every startup. Takes the [`ConfigSpec`] so the | |
| 40 | + | /// table name is the spec's and not restated, which keeps the store and the | |
| 41 | + | /// sync side pointed at the same table. | |
| 42 | + | pub fn open(conn: &Connection, spec: &ConfigSpec) -> Result<Self> { | |
| 43 | + | let table = spec.table(); | |
| 44 | + | // The table name is a compile-time `&'static str` from a spec, never user | |
| 45 | + | // input, so the format is safe; values always go through bound | |
| 46 | + | // parameters below. | |
| 47 | + | conn.execute_batch(&format!( | |
| 48 | + | "CREATE TABLE IF NOT EXISTS {table} (\ | |
| 49 | + | key TEXT PRIMARY KEY,\ | |
| 50 | + | value TEXT NOT NULL\ | |
| 51 | + | ) WITHOUT ROWID;" | |
| 52 | + | ))?; | |
| 53 | + | Ok(Self { table }) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// The value stored at `key`, or `None` if nothing is. | |
| 57 | + | /// | |
| 58 | + | /// An unset key is `None`, not an error: "the user has not chosen a theme" is | |
| 59 | + | /// a normal state, and the caller supplies the default (see the theme | |
| 60 | + | /// resolver, which reads `None` as "follow the system"). | |
| 61 | + | pub fn get(&self, conn: &Connection, key: &str) -> Result<Option<String>> { | |
| 62 | + | let value = conn | |
| 63 | + | .query_row( | |
| 64 | + | &format!("SELECT value FROM {} WHERE key = ?1", self.table), | |
| 65 | + | [key], | |
| 66 | + | |row| row.get::<_, String>(0), | |
| 67 | + | ) | |
| 68 | + | .map(Some) | |
| 69 | + | .or_else(|error| match error { | |
| 70 | + | rusqlite::Error::QueryReturnedNoRows => Ok(None), | |
| 71 | + | other => Err(other), | |
| 72 | + | })?; | |
| 73 | + | Ok(value) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Set `key` to `value`, replacing any previous value. | |
| 77 | + | /// | |
| 78 | + | /// One upsert, so a syncing app's change-capture trigger sees a single INSERT | |
| 79 | + | /// or UPDATE rather than a delete-then-insert that would enqueue two changes | |
| 80 | + | /// for one edit. | |
| 81 | + | pub fn set(&self, conn: &Connection, key: &str, value: &str) -> Result<()> { | |
| 82 | + | conn.execute( | |
| 83 | + | &format!( | |
| 84 | + | "INSERT INTO {} (key, value) VALUES (?1, ?2) \ | |
| 85 | + | ON CONFLICT(key) DO UPDATE SET value = excluded.value", | |
| 86 | + | self.table | |
| 87 | + | ), | |
| 88 | + | (key, value), | |
| 89 | + | )?; | |
| 90 | + | Ok(()) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Remove `key`. Absent already is not an error. | |
| 94 | + | pub fn unset(&self, conn: &Connection, key: &str) -> Result<()> { | |
| 95 | + | conn.execute(&format!("DELETE FROM {} WHERE key = ?1", self.table), [key])?; | |
| 96 | + | Ok(()) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Every key and value, ordered by key for a stable read. | |
| 100 | + | /// | |
| 101 | + | /// For an app that wants to show or export the whole config; the common path | |
| 102 | + | /// is [`get`](Self::get) of one key. | |
| 103 | + | pub fn all(&self, conn: &Connection) -> Result<Vec<(String, String)>> { | |
| 104 | + | let mut stmt = conn.prepare(&format!( | |
| 105 | + | "SELECT key, value FROM {} ORDER BY key", | |
| 106 | + | self.table | |
| 107 | + | ))?; | |
| 108 | + | let rows = stmt | |
| 109 | + | .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? | |
| 110 | + | .collect::<rusqlite::Result<Vec<_>>>()?; | |
| 111 | + | Ok(rows) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[cfg(test)] | |
| 116 | + | mod tests { | |
| 117 | + | use super::*; | |
| 118 | + | use crate::spec::Posture; | |
| 119 | + | ||
| 120 | + | const SPEC: ConfigSpec = ConfigSpec::new( | |
| 121 | + | "app_config", | |
| 122 | + | &[("theme", Posture::Synced), ("mirror_path", Posture::Local)], | |
| 123 | + | ); | |
| 124 | + | ||
| 125 | + | fn store() -> (Connection, ConfigStore) { | |
| 126 | + | let conn = Connection::open_in_memory().unwrap(); | |
| 127 | + | let store = ConfigStore::open(&conn, &SPEC).unwrap(); | |
| 128 | + | (conn, store) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[test] | |
| 132 | + | fn a_value_round_trips() { | |
| 133 | + | let (conn, store) = store(); | |
| 134 | + | assert_eq!(store.get(&conn, "theme").unwrap(), None, "unset reads None"); | |
| 135 | + | ||
| 136 | + | store.set(&conn, "theme", "akari-night").unwrap(); | |
| 137 | + | assert_eq!( | |
| 138 | + | store.get(&conn, "theme").unwrap().as_deref(), | |
| 139 | + | Some("akari-night"), | |
| 140 | + | ); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn set_replaces_rather_than_duplicating() { | |
| 145 | + | let (conn, store) = store(); | |
| 146 | + | store.set(&conn, "theme", "flatwhite").unwrap(); | |
| 147 | + | store.set(&conn, "theme", "akari-night").unwrap(); | |
| 148 | + | assert_eq!( | |
| 149 | + | store.get(&conn, "theme").unwrap().as_deref(), | |
| 150 | + | Some("akari-night"), | |
| 151 | + | ); | |
| 152 | + | assert_eq!(store.all(&conn).unwrap().len(), 1, "one row, not two"); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn unset_removes_and_is_idempotent() { | |
| 157 | + | let (conn, store) = store(); | |
| 158 | + | store.set(&conn, "theme", "flatwhite").unwrap(); | |
| 159 | + | store.unset(&conn, "theme").unwrap(); | |
| 160 | + | assert_eq!(store.get(&conn, "theme").unwrap(), None); | |
| 161 | + | // Removing what is already gone is not an error. | |
| 162 | + | store.unset(&conn, "theme").unwrap(); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn open_is_idempotent() { | |
| 167 | + | let (conn, _store) = store(); | |
| 168 | + | // A second open over the same connection must not fail on the existing | |
| 169 | + | // table; every startup calls this. | |
| 170 | + | ConfigStore::open(&conn, &SPEC).unwrap(); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn all_is_ordered_by_key() { | |
| 175 | + | let (conn, store) = store(); | |
| 176 | + | store.set(&conn, "theme", "flatwhite").unwrap(); | |
| 177 | + | store.set(&conn, "mirror_path", "/mnt/x").unwrap(); | |
| 178 | + | assert_eq!( | |
| 179 | + | store.all(&conn).unwrap(), | |
| 180 | + | vec![ | |
| 181 | + | ("mirror_path".to_string(), "/mnt/x".to_string()), | |
| 182 | + | ("theme".to_string(), "flatwhite".to_string()), | |
| 183 | + | ], | |
| 184 | + | ); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // The store is posture-blind: it holds a local path the same as a synced | |
| 188 | + | // preference. Keeping that path off the wire is the sync side's job, driven | |
| 189 | + | // by the spec; the local store simply stores. | |
| 190 | + | #[test] | |
| 191 | + | fn the_store_holds_local_and_synced_keys_alike() { | |
| 192 | + | let (conn, store) = store(); | |
| 193 | + | store.set(&conn, "mirror_path", "/mnt/samples").unwrap(); | |
| 194 | + | assert_eq!( | |
| 195 | + | store.get(&conn, "mirror_path").unwrap().as_deref(), | |
| 196 | + | Some("/mnt/samples"), | |
| 197 | + | "a Local key is still stored locally; posture governs sync, not storage", | |
| 198 | + | ); | |
| 199 | + | } | |
| 200 | + | } |