Skip to main content

max / balanced_breakfast

Audit remediation: observability, adversarial fixes, JS tests, sync hardening Multi-round audit improvements: - Observability A: 61 #[instrument(skip_all)] annotations across all Tauri commands, orchestrator methods, sync service functions - Adversarial fixes: sync_disconnect actually clears session + DB state, OPML URL scheme validation, <base> in HTML sanitizer, escapeAttr hardened (all 5 special chars), parking_lot RwLock, TOCTOU fix in plugin loading - JS test expansion: 37 -> 55 tests. Added settings-sync state machine (8), sources rendering (5), items rendering (5) coverage - Security: Rhai HTTP safety (count limit, size cap, timeout, URL scheme), FTS sanitization hardening, circuit breaker, sync changelog retention cap - Code documentation: architecture.md, plugin_authoring.md, README, module docs
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 03:35 UTC
Commit: d6f00bae5a07da0f4286ecb128365c5f29151f82
Parent: 1f74722
29 files changed, +1466 insertions, -121 deletions
M Cargo.lock +90 -7
@@ -87,6 +87,19 @@
87 87 source = "registry+https://github.com/rust-lang/crates.io-index"
88 88 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
89 89
90 + [[package]]
91 + name = "ammonia"
92 + version = "4.1.2"
93 + source = "registry+https://github.com/rust-lang/crates.io-index"
94 + checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
95 + dependencies = [
96 + "cssparser 0.35.0",
97 + "html5ever 0.35.0",
98 + "maplit",
99 + "tendril",
100 + "url",
101 + ]
102 +
90 103 [[package]]
91 104 name = "android_system_properties"
92 105 version = "0.1.5"
@@ -160,7 +173,7 @@
160 173
161 174 [[package]]
162 175 name = "balanced-breakfast-desktop"
163 - version = "0.1.0"
176 + version = "0.2.1"
164 177 dependencies = [
165 178 "base64 0.22.1",
166 179 "bb-core",
@@ -168,6 +181,7 @@
168 181 "bb-feed",
169 182 "bb-interface",
170 183 "chrono",
184 + "parking_lot",
171 185 "rand 0.8.5",
172 186 "roxmltree",
173 187 "serde",
@@ -207,15 +221,17 @@
207 221
208 222 [[package]]
209 223 name = "bb-core"
210 - version = "0.1.0"
224 + version = "0.2.1"
211 225 dependencies = [
212 226 "aes-gcm",
227 + "ammonia",
213 228 "base64 0.22.1",
214 229 "bb-db",
215 230 "bb-feed",
216 231 "bb-interface",
217 232 "chrono",
218 233 "html2text",
234 + "parking_lot",
219 235 "rand 0.8.5",
220 236 "readable-readability",
221 237 "regex",
@@ -233,7 +249,7 @@
233 249
234 250 [[package]]
235 251 name = "bb-db"
236 - version = "0.1.0"
252 + version = "0.2.1"
237 253 dependencies = [
238 254 "bb-interface",
239 255 "chrono",
@@ -248,7 +264,7 @@
248 264
249 265 [[package]]
250 266 name = "bb-feed"
251 - version = "0.1.0"
267 + version = "0.2.1"
252 268 dependencies = [
253 269 "bb-db",
254 270 "bb-interface",
@@ -263,7 +279,7 @@
263 279
264 280 [[package]]
265 281 name = "bb-interface"
266 - version = "0.1.0"
282 + version = "0.2.1"
267 283 dependencies = [
268 284 "chrono",
269 285 "serde",
@@ -737,6 +753,19 @@
737 753 "syn 1.0.109",
738 754 ]
739 755
756 + [[package]]
757 + name = "cssparser"
758 + version = "0.35.0"
759 + source = "registry+https://github.com/rust-lang/crates.io-index"
760 + checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
761 + dependencies = [
762 + "cssparser-macros",
763 + "dtoa-short",
764 + "itoa 1.0.17",
765 + "phf 0.11.3",
766 + "smallvec",
767 + ]
768 +
740 769 [[package]]
741 770 name = "cssparser-macros"
742 771 version = "0.6.1"
@@ -1714,7 +1743,18 @@
1714 1743 "log",
1715 1744 "mac",
1716 1745 "markup5ever 0.14.1",
1717 - "match_token",
1746 + "match_token 0.1.0",
1747 + ]
1748 +
1749 + [[package]]
1750 + name = "html5ever"
1751 + version = "0.35.0"
1752 + source = "registry+https://github.com/rust-lang/crates.io-index"
1753 + checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
1754 + dependencies = [
1755 + "log",
1756 + "markup5ever 0.35.0",
1757 + "match_token 0.35.0",
1718 1758 ]
1719 1759
1720 1760 [[package]]
@@ -2297,6 +2337,12 @@
2297 2337 source = "registry+https://github.com/rust-lang/crates.io-index"
2298 2338 checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
2299 2339
2340 + [[package]]
2341 + name = "maplit"
2342 + version = "1.0.2"
2343 + source = "registry+https://github.com/rust-lang/crates.io-index"
2344 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2345 +
2300 2346 [[package]]
2301 2347 name = "markup5ever"
2302 2348 version = "0.10.1"
@@ -2339,6 +2385,17 @@
2339 2385 "tendril",
2340 2386 ]
2341 2387
2388 + [[package]]
2389 + name = "markup5ever"
2390 + version = "0.35.0"
2391 + source = "registry+https://github.com/rust-lang/crates.io-index"
2392 + checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
2393 + dependencies = [
2394 + "log",
2395 + "tendril",
2396 + "web_atoms",
2397 + ]
2398 +
2342 2399 [[package]]
2343 2400 name = "match_token"
2344 2401 version = "0.1.0"
@@ -2350,6 +2407,17 @@
2350 2407 "syn 2.0.114",
2351 2408 ]
2352 2409
2410 + [[package]]
2411 + name = "match_token"
2412 + version = "0.35.0"
2413 + source = "registry+https://github.com/rust-lang/crates.io-index"
2414 + checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
2415 + dependencies = [
2416 + "proc-macro2",
2417 + "quote",
2418 + "syn 2.0.114",
2419 + ]
2420 +
2353 2421 [[package]]
2354 2422 name = "matchers"
2355 2423 version = "0.2.0"
@@ -4599,13 +4667,15 @@
4599 4667
4600 4668 [[package]]
4601 4669 name = "synckit-client"
4602 - version = "0.2.0"
4670 + version = "0.2.1"
4603 4671 dependencies = [
4604 4672 "argon2",
4605 4673 "base64 0.22.1",
4674 + "bytes",
4606 4675 "chacha20poly1305",
4607 4676 "chrono",
4608 4677 "keyring",
4678 + "parking_lot",
4609 4679 "rand 0.8.5",
4610 4680 "reqwest 0.12.28",
4611 4681 "serde",
@@ -4614,6 +4684,7 @@
4614 4684 "thiserror 1.0.69",
4615 4685 "tokio",
4616 4686 "tracing",
4687 + "unicode-normalization",
4617 4688 "urlencoding",
4618 4689 "uuid",
4619 4690 ]
@@ -5845,6 +5916,18 @@
5845 5916 "wasm-bindgen",
5846 5917 ]
5847 5918
5919 + [[package]]
5920 + name = "web_atoms"
5921 + version = "0.1.3"
5922 + source = "registry+https://github.com/rust-lang/crates.io-index"
5923 + checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
5924 + dependencies = [
5925 + "phf 0.11.3",
5926 + "phf_codegen 0.11.3",
5927 + "string_cache",
5928 + "string_cache_codegen",
5929 + ]
5930 +
5848 5931 [[package]]
5849 5932 name = "webkit2gtk"
5850 5933 version = "2.0.2"
M Cargo.toml +3
@@ -35,6 +35,9 @@
35 35 base64 = "0.22"
36 36 rand = "0.8"
37 37
38 + # Concurrency
39 + parking_lot = "0.12"
40 +
38 41 # Utilities
39 42 chrono = { version = "0.4.43", features = ["serde"] }
40 43 uuid = { version = "1.20.0", features = ["v4", "serde"] }
M README.md +11 -1
@@ -54,9 +54,19 @@
54 54
55 55 Every plugin defines four functions (`id`, `name`, `config_schema`, `fetch`) plus an optional `capabilities()`. Full authoring guide with field types, return shapes, host functions, and examples: [docs/plugin_authoring.md](docs/plugin_authoring.md).
56 56
57 + ## Features
58 +
59 + - **Unified timeline** -- RSS, Hacker News, arXiv, and custom sources merged into one feed
60 + - **Plugin system** -- Rhai scripting for extensible feed fetching (write a plugin for any source)
61 + - **Reader view** -- clean article rendering with HTML sanitization
62 + - **Search** -- FTS5 full-text search across all items
63 + - **Organization** -- tags, starred items, read/unread tracking, query feeds (saved filters)
64 + - **Cloud sync** -- SyncKit integration with E2E encryption (feeds, tags, read state, preferences)
65 + - **Themes** -- light and dark themes, system auto-detection
66 +
57 67 ## Bundled Plugins
58 68
59 - Three plugins ship with the app: **rss.rhai** (RSS/Atom/JSON Feed), **hackernews.rhai** (HN stories), **arxiv.rhai** (arXiv papers).
69 + Four plugins ship with the app: **rss.rhai** (RSS/Atom/JSON Feed), **hackernews.rhai** (HN stories), **arxiv.rhai** (arXiv papers), **reader.rhai** (web page reader view).
60 70
61 71 ## License
62 72
@@ -25,6 +25,9 @@
25 25 tauri = { version = "2.10.2", features = [] }
26 26 tauri-plugin-dialog = "2.6.0"
27 27
28 + # Lock primitives (no poisoning)
29 + parking_lot.workspace = true
30 +
28 31 # Async runtime
29 32 tokio.workspace = true
30 33
@@ -21,6 +21,9 @@
21 21 base64 = { workspace = true }
22 22 rand = { workspace = true }
23 23
24 + # Lock primitives (no poisoning)
25 + parking_lot.workspace = true
26 +
24 27 # Rhai scripting engine for plugins
25 28 rhai = { version = "1.24.0", features = ["sync", "serde"] }
26 29
@@ -39,5 +42,8 @@
39 42 # Regex for HTML URL rewriting
40 43 regex = "1"
41 44
45 + # HTML sanitization for untrusted feed content
46 + ammonia = "4"
47 +
42 48 # Article extraction (reader view)
43 49 readable-readability = "0.4"
@@ -14,9 +14,9 @@
14 14 /// SyncKit client for cloud sync (None if not configured).
15 15 pub sync_client: Option<SyncKitClient>,
16 16 /// Handle to abort the background auto-fetch task on shutdown.
17 - auto_fetch_handle: std::sync::Mutex<Option<AbortHandle>>,
17 + auto_fetch_handle: parking_lot::Mutex<Option<AbortHandle>>,
18 18 /// Handle to abort the background stale-item cleanup task on shutdown.
19 - cleanup_handle: std::sync::Mutex<Option<AbortHandle>>,
19 + cleanup_handle: parking_lot::Mutex<Option<AbortHandle>>,
20 20 }
21 21
22 22 impl AppState {
@@ -116,73 +116,53 @@
116 116 Ok(Self {
117 117 orchestrator,
118 118 sync_client,
119 - auto_fetch_handle: std::sync::Mutex::new(None),
120 - cleanup_handle: std::sync::Mutex::new(None),
119 + auto_fetch_handle: parking_lot::Mutex::new(None),
120 + cleanup_handle: parking_lot::Mutex::new(None),
121 121 })
122 122 }
123 123
124 124 /// Store the abort handle for the background auto-fetch task.
125 - pub fn set_auto_fetch_handle(&self, handle: AbortHandle) -> Result<(), String> {
126 - let mut guard = self
127 - .auto_fetch_handle
128 - .lock()
129 - .map_err(|e| format!("auto_fetch_handle mutex poisoned: {}", e))?;
125 + pub fn set_auto_fetch_handle(&self, handle: AbortHandle) {
126 + let mut guard = self.auto_fetch_handle.lock();
130 127 // If there's already a running task, abort it before replacing.
131 128 if let Some(old) = guard.take() {
132 129 old.abort();
133 130 }
134 131 *guard = Some(handle);
135 - Ok(())
136 132 }
137 133
138 134 /// Abort the background auto-fetch task if it is running.
139 - pub fn abort_auto_fetch(&self) -> Result<(), String> {
140 - let mut guard = self
141 - .auto_fetch_handle
142 - .lock()
143 - .map_err(|e| format!("auto_fetch_handle mutex poisoned: {}", e))?;
135 + pub fn abort_auto_fetch(&self) {
136 + let mut guard = self.auto_fetch_handle.lock();
144 137 if let Some(handle) = guard.take() {
145 138 info!("Aborting auto-fetch background task");
146 139 handle.abort();
147 140 }
148 - Ok(())
149 141 }
150 142
151 143 /// Store the abort handle for the background stale-item cleanup task.
152 - pub fn set_cleanup_handle(&self, handle: AbortHandle) -> Result<(), String> {
153 - let mut guard = self
154 - .cleanup_handle
155 - .lock()
156 - .map_err(|e| format!("cleanup_handle mutex poisoned: {}", e))?;
144 + pub fn set_cleanup_handle(&self, handle: AbortHandle) {
145 + let mut guard = self.cleanup_handle.lock();
157 146 if let Some(old) = guard.take() {
158 147 old.abort();
159 148 }
160 149 *guard = Some(handle);
161 - Ok(())
162 150 }
163 151
164 152 /// Abort the background cleanup task if it is running.
165 - pub fn abort_cleanup(&self) -> Result<(), String> {
166 - let mut guard = self
167 - .cleanup_handle
168 - .lock()
169 - .map_err(|e| format!("cleanup_handle mutex poisoned: {}", e))?;
153 + pub fn abort_cleanup(&self) {
154 + let mut guard = self.cleanup_handle.lock();
170 155 if let Some(handle) = guard.take() {
171 156 info!("Aborting stale-cleanup background task");
172 157 handle.abort();
173 158 }
174 - Ok(())
175 159 }
176 160 }
177 161
178 162 impl Drop for AppState {
179 163 fn drop(&mut self) {
180 - if let Err(e) = self.abort_auto_fetch() {
181 - error!(error = %e, "Failed to abort auto-fetch on drop");
182 - }
183 - if let Err(e) = self.abort_cleanup() {
184 - error!(error = %e, "Failed to abort cleanup on drop");
185 - }
164 + self.abort_auto_fetch();
165 + self.abort_cleanup();
186 166 }
187 167 }
188 168
@@ -344,9 +324,7 @@
344 324 }
345 325 });
346 326
347 - if let Err(e) = state.set_auto_fetch_handle(handle.inner().abort_handle()) {
348 - error!(error = %e, "Failed to store auto-fetch handle");
349 - }
327 + state.set_auto_fetch_handle(handle.inner().abort_handle());
350 328 }
351 329
352 330 /// Spawn a background task that periodically deletes old read items.
@@ -375,9 +353,7 @@
375 353 }
376 354 });
377 355
378 - if let Err(e) = state.set_cleanup_handle(handle.inner().abort_handle()) {
379 - error!(error = %e, "Failed to store cleanup handle");
380 - }
356 + state.set_cleanup_handle(handle.inner().abort_handle());
381 357 }
382 358
383 359 /// Pure timing check: is a single feed overdue given its last_fetch timestamp?
@@ -41,12 +41,12 @@
41 41 };
42 42
43 43 // Must be authenticated
44 - if client.session_info().is_none() {
44 + if client.session_info().unwrap_or(None).is_none() {
45 45 continue;
46 46 }
47 47
48 48 // Must have encryption key loaded
49 - if !client.has_master_key() {
49 + if !client.has_master_key().unwrap_or(false) {
50 50 continue;
51 51 }
52 52
@@ -4,7 +4,7 @@
4 4 use serde::{Deserialize, Serialize};
5 5 use sqlx::SqlitePool;
6 6 use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient};
7 - use tracing::{debug, info, warn};
7 + use tracing::{debug, info, instrument, warn};
8 8 use uuid::Uuid;
9 9
10 10 use crate::commands::error::ApiError;
@@ -32,6 +32,7 @@
32 32
33 33 // ── sync_state helpers ──
34 34
35 + #[instrument(skip_all)]
35 36 pub async fn get_sync_state(pool: &SqlitePool, key: &str) -> Result<String, ApiError> {
36 37 let row: Option<(String,)> =
37 38 sqlx::query_as("SELECT value FROM sync_state WHERE key = ?")
@@ -43,6 +44,7 @@
43 44 Ok(row.map(|r| r.0).unwrap_or_default())
44 45 }
45 46
47 + #[instrument(skip_all)]
46 48 pub async fn set_sync_state(pool: &SqlitePool, key: &str, value: &str) -> Result<(), ApiError> {
47 49 sqlx::query("INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)")
48 50 .bind(key)
@@ -53,8 +55,23 @@
53 55 Ok(())
54 56 }
55 57
58 + /// Clear all sync state (device_id, cursors, flags) for disconnect.
59 + #[instrument(skip_all)]
60 + pub async fn clear_all_sync_state(pool: &SqlitePool) -> Result<(), ApiError> {
61 + sqlx::query("DELETE FROM sync_state")
62 + .execute(pool)
63 + .await
64 + .map_err(|e| ApiError::database(e.to_string()))?;
65 + sqlx::query("DELETE FROM sync_changelog")
66 + .execute(pool)
67 + .await
68 + .map_err(|e| ApiError::database(e.to_string()))?;
69 + Ok(())
70 + }
71 +
56 72 // ── Device registration ──
57 73
74 + #[instrument(skip_all)]
58 75 pub async fn ensure_device_registered(
59 76 pool: &SqlitePool,
60 77 client: &SyncKitClient,
@@ -85,6 +102,7 @@
85 102
86 103 // ── Push ──
87 104
105 + #[instrument(skip_all)]
88 106 pub async fn push_changes(
89 107 pool: &SqlitePool,
90 108 client: &SyncKitClient,
@@ -151,6 +169,7 @@
151 169
152 170 // ── Pull ──
153 171
172 + #[instrument(skip_all)]
154 173 pub async fn pull_changes(
155 174 pool: &SqlitePool,
156 175 client: &SyncKitClient,
@@ -353,8 +372,13 @@
353 372 let pk = pk_column(table);
354 373
355 374 if table == "feed_tags" {
356 - // Composite PK: row_id is "feed_id:tag"
357 - if let Some((feed_id, tag)) = row_id.split_once(':') {
375 + // Composite PK: row_id is "feed_id:tag" where feed_id is a UUID (36 chars).
376 + // We can't use split_once(':') because tags may contain colons (e.g. "cat:subcat").
377 + // Instead, parse the first 36 characters as feed_id and everything after
378 + // the separator colon (char 37) as the tag.
379 + if row_id.len() > 36 && row_id.as_bytes()[36] == b':' {
380 + let feed_id = &row_id[..36];
381 + let tag = &row_id[37..];
358 382 sqlx::query("DELETE FROM feed_tags WHERE feed_id = ? AND tag = ?")
359 383 .bind(feed_id)
360 384 .bind(tag)
@@ -377,6 +401,7 @@
377 401
378 402 // ── High-level sync ──
379 403
404 + #[instrument(skip_all)]
380 405 pub async fn perform_sync(pool: &SqlitePool, client: &SyncKitClient) -> Result<SyncResult, ApiError> {
381 406 // Clear applying_remote flag in case a previous sync crashed mid-apply.
382 407 // If the flag is stuck at "1", all local changes silently skip the changelog.
@@ -398,6 +423,7 @@
398 423 // ── Initial snapshot ──
399 424
400 425 /// One-time: snapshot all existing rows into the changelog for the first push.
426 + #[instrument(skip_all)]
401 427 pub async fn create_initial_snapshot(pool: &SqlitePool) -> Result<i64, ApiError> {
402 428 let mut total: i64 = 0;
403 429
@@ -473,6 +499,7 @@
473 499 // ── Changelog cleanup ──
474 500
475 501 /// Prune pushed entries older than 7 days.
502 + #[instrument(skip_all)]
476 503 pub async fn cleanup_changelog(pool: &SqlitePool) -> Result<i64, ApiError> {
477 504 let result = sqlx::query(
478 505 "DELETE FROM sync_changelog WHERE pushed = 1 AND timestamp < datetime('now', '-7 days')",
@@ -491,6 +518,7 @@
491 518 /// Enforce a hard cap on total changelog entries to prevent unbounded growth
492 519 /// when sync is disconnected or failing. Deletes the oldest entries (by rowid)
493 520 /// to bring the count back under `MAX_CHANGELOG_ENTRIES`.
521 + #[instrument(skip_all)]
494 522 pub async fn enforce_changelog_retention(pool: &SqlitePool) -> Result<i64, ApiError> {
495 523 let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sync_changelog")
496 524 .fetch_one(pool)
@@ -523,6 +551,7 @@
523 551 }
524 552
525 553 /// Count unpushed changes.
554 + #[instrument(skip_all)]
526 555 pub async fn count_pending_changes(pool: &SqlitePool) -> Result<i64, ApiError> {
527 556 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0")
528 557 .fetch_one(pool)
@@ -841,6 +870,57 @@
841 870 assert_eq!(count.0, 0);
842 871 }
843 872
873 + #[tokio::test]
874 + async fn apply_delete_feed_tags_with_colon_in_tag() {
875 + let pool = setup_test_db().await;
876 + let feed_id = create_test_feed(&pool, "Colon Tag Feed").await;
877 +
878 + // Insert a tag that contains a colon
879 + sqlx::query("INSERT INTO feed_tags (feed_id, tag) VALUES (?, 'cat:subcat')")
880 + .bind(&feed_id)
881 + .execute(&pool)
882 + .await
883 + .unwrap();
884 +
885 + // The row_id format is "feed_id:tag", so with a colon in the tag
886 + // it becomes "uuid:cat:subcat"
887 + let row_id = format!("{}:cat:subcat", feed_id);
888 + apply_delete(&pool, "feed_tags", &row_id).await.unwrap();
889 +
890 + let count: (i64,) = sqlx::query_as(
891 + "SELECT COUNT(*) FROM feed_tags WHERE feed_id = ? AND tag = 'cat:subcat'"
892 + )
893 + .bind(&feed_id)
894 + .fetch_one(&pool)
895 + .await
896 + .unwrap();
897 + assert_eq!(count.0, 0, "tag with colon should be deleted");
898 + }
899 +
900 + #[tokio::test]
901 + async fn apply_delete_feed_tags_simple_tag() {
902 + let pool = setup_test_db().await;
903 + let feed_id = create_test_feed(&pool, "Simple Tag Feed").await;
904 +
905 + sqlx::query("INSERT INTO feed_tags (feed_id, tag) VALUES (?, 'tech')")
906 + .bind(&feed_id)
907 + .execute(&pool)
908 + .await
909 + .unwrap();
910 +
911 + let row_id = format!("{}:tech", feed_id);
912 + apply_delete(&pool, "feed_tags", &row_id).await.unwrap();
913 +
914 + let count: (i64,) = sqlx::query_as(
915 + "SELECT COUNT(*) FROM feed_tags WHERE feed_id = ? AND tag = 'tech'"
916 + )
917 + .bind(&feed_id)
918 + .fetch_one(&pool)
919 + .await
920 + .unwrap();
921 + assert_eq!(count.0, 0, "simple tag should be deleted");
922 + }
923 +
844 924 #[tokio::test]
845 925 async fn apply_delete_feed_items_is_no_op() {
846 926 let pool = setup_test_db().await;