Skip to content

Commit 9ee09a6

Browse files
loganjLarryclaude
committed
fix(relay): limit the operator rename to listed channels
A relay operator's name-only kind:9002 now applies only to a listed channel (open, not unlisted, stream or forum, not deleted), the only channels whose names must be unique. A private channel, an unlisted channel and a DM need a channel owner or admin, as before. The relay looks up the operator only for a rename of an archived channel, and the kind:9002 tag lists now have one definition. The rename script checks the community first: it stops for an archived community, one being deleted, or an unknown host, and says what to do. It stops at the first auth refusal (CLI exit code 3), and its header says the operator key must be a member of the community, because POST /events checks membership first. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Logan Johnson <[email protected]>
1 parent fbeafeb commit 9ee09a6

4 files changed

Lines changed: 302 additions & 84 deletions

File tree

‎crates/buzz-db/src/runtime/tests/listed_channel_name_postgres_tests.rs‎

Lines changed: 123 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -192,39 +192,30 @@ async fn plan_picks_a_free_name_when_the_short_one_is_taken() {
192192
drop_scratch_db(&admin, pool, &name).await;
193193
}
194194

195-
/// The real script, with a stand-in CLI that logs each call and applies the
196-
/// rename. A name may hold any character but NUL, so a name holding control
197-
/// characters must still yield exactly one rename, of its own channel. Like
198-
/// clap, the stand-in takes a value that starts with "-" only as `--name=`.
195+
/// Runs the real script for `host` against database `db`, with `cli_body` as
196+
/// the stand-in CLI. Returns the script's output and each CLI call's
197+
/// arguments.
199198
#[cfg(unix)]
200-
#[tokio::test]
201-
#[ignore = "requires Postgres"]
202-
async fn script_renames_each_planned_channel_once_whatever_its_name() {
203-
let admin = PgPool::connect(&admin_url().await).await.unwrap();
204-
let (pool, name) = create_scratch_db(&admin, "listed_name_script").await;
205-
let seed = Seed::new(&pool).await;
206-
let [kept, duplicate, other] = [(); 3].map(|_| Uuid::new_v4());
207-
let tricky = format!("-ops\x1e{other}\x1fowned\x1f\n\t\"$(x)");
208-
seed.open(&pool, kept, &tricky, 30).await;
209-
seed.open(&pool, duplicate, &tricky, 10).await;
210-
seed.open(&pool, other, "other", 20).await;
211-
199+
async fn run_rename_script(
200+
host: &str,
201+
db: &str,
202+
cli_body: &str,
203+
) -> (std::process::Output, Vec<Vec<Vec<u8>>>) {
212204
let dir = std::env::temp_dir().join(format!("listed-name-script-{}", Uuid::new_v4().simple()));
213205
std::fs::create_dir(&dir).unwrap();
214206
let (cli, log) = (dir.join("buzz"), dir.join("calls"));
215207
std::fs::write(
216208
&cli,
217-
"#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\0' \"$@\" >>\"$STUB_LOG\"\n\
218-
[[ $3 == --channel=* && $4 == --name=* ]] || exit 2\n\
219-
psql \"$DATABASE_URL\" -X -q -v ON_ERROR_STOP=1 -v id=\"${3#*=}\" -v name=\"${4#*=}\" <<'SQL'\n\
220-
UPDATE channels SET name = :'name' WHERE id = :'id';\nSQL\n",
209+
format!(
210+
"#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\0' \"$@\" '' >>\"$STUB_LOG\"\n{cli_body}"
211+
),
221212
)
222213
.unwrap();
223214
use std::os::unix::fs::PermissionsExt;
224215
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
225216

226217
let base = admin_url().await;
227-
let database_url = format!("{}/{}", &base[..base.rfind('/').unwrap()], name);
218+
let database_url = format!("{}/{}", &base[..base.rfind('/').unwrap()], db);
228219
let mut path = std::env::var("PATH").unwrap_or_default();
229220
if let Ok(bin) = std::env::var("PG_BIN_DIR") {
230221
path = format!("{bin}:{path}");
@@ -233,40 +224,137 @@ async fn script_renames_each_planned_channel_once_whatever_its_name() {
233224
env!("CARGO_MANIFEST_DIR"),
234225
"/../../scripts/maintenance/rename_duplicate_listed_channel_names.sh"
235226
))
236-
.arg(&seed.host)
227+
.arg(host)
237228
.env("PATH", path)
238229
.env("DATABASE_URL", &database_url)
239230
.env("BUZZ_PRIVATE_KEY", "unused")
240231
.env("BUZZ_CLI", &cli)
241232
.env("STUB_LOG", &log)
242233
.output()
243234
.expect("run the rename script");
235+
236+
// Each call ends with an empty field, so "\0\0" splits the calls.
237+
let calls = std::fs::read(&log).unwrap_or_default();
238+
let calls = calls
239+
.split(|b| *b == 0)
240+
.collect::<Vec<_>>()
241+
.split(|field| field.is_empty())
242+
.filter(|call| !call.is_empty())
243+
.map(|call| call.iter().map(|field| field.to_vec()).collect())
244+
.collect();
245+
std::fs::remove_dir_all(&dir).unwrap();
246+
(output, calls)
247+
}
248+
249+
/// The real script, with a stand-in CLI that logs each call and applies the
250+
/// rename. A name may hold any character but NUL, so a name holding control
251+
/// characters must still yield exactly one rename, of its own channel. Like
252+
/// clap, the stand-in takes a value that starts with "-" only as `--name=`.
253+
#[cfg(unix)]
254+
#[tokio::test]
255+
#[ignore = "requires Postgres"]
256+
async fn script_renames_each_planned_channel_once_whatever_its_name() {
257+
let admin = PgPool::connect(&admin_url().await).await.unwrap();
258+
let (pool, name) = create_scratch_db(&admin, "listed_name_script").await;
259+
let seed = Seed::new(&pool).await;
260+
let [kept, duplicate, other] = [(); 3].map(|_| Uuid::new_v4());
261+
let tricky = format!("-ops\x1e{other}\x1fowned\x1f\n\t\"$(x)");
262+
seed.open(&pool, kept, &tricky, 30).await;
263+
seed.open(&pool, duplicate, &tricky, 10).await;
264+
seed.open(&pool, other, "other", 20).await;
265+
266+
let (output, calls) = run_rename_script(
267+
&seed.host,
268+
&name,
269+
"[[ $3 == --channel=* && $4 == --name=* ]] || exit 2\n\
270+
psql \"$DATABASE_URL\" -X -q -v ON_ERROR_STOP=1 -v id=\"${3#*=}\" -v name=\"${4#*=}\" <<'SQL'\n\
271+
UPDATE channels SET name = :'name' WHERE id = :'id';\nSQL\n",
272+
)
273+
.await;
244274
assert!(
245275
output.status.success(),
246276
"script failed: {}",
247277
String::from_utf8_lossy(&output.stderr)
248278
);
249279

250280
let new_name = renamed(duplicate, &tricky).1;
251-
let calls = std::fs::read(&log).unwrap();
252-
let args: Vec<&[u8]> = calls.split(|b| *b == 0).collect();
253-
let channel = format!("--channel={duplicate}");
254-
let name_arg = format!("--name={new_name}");
255-
let expected: Vec<&[u8]> = vec![
256-
b"channels",
257-
b"update",
258-
channel.as_bytes(),
259-
name_arg.as_bytes(),
260-
b"",
261-
];
262-
assert_eq!(args, expected, "one call, for the duplicate only");
281+
let expected: Vec<Vec<Vec<u8>>> = vec![vec![
282+
b"channels".to_vec(),
283+
b"update".to_vec(),
284+
format!("--channel={duplicate}").into_bytes(),
285+
format!("--name={new_name}").into_bytes(),
286+
]];
287+
assert_eq!(calls, expected, "one call, for the duplicate only");
263288
let mut names = vec![
264289
(kept, tricky.clone()),
265290
(duplicate, new_name.clone()),
266291
(other, "other".to_owned()),
267292
];
268293
names.sort();
269294
assert_eq!(seed.names(&pool).await, names);
270-
std::fs::remove_dir_all(&dir).unwrap();
295+
drop_scratch_db(&admin, pool, &name).await;
296+
}
297+
298+
/// An auth refusal (CLI exit code 3, for example 403 relay_membership_required)
299+
/// stops the script at the first rename, with a message that names the
300+
/// membership precondition.
301+
/// Mutation: drop the exit-code-3 branch in the script → RED.
302+
#[cfg(unix)]
303+
#[tokio::test]
304+
#[ignore = "requires Postgres"]
305+
async fn script_stops_at_the_first_auth_refusal() {
306+
let admin = PgPool::connect(&admin_url().await).await.unwrap();
307+
let (pool, name) = create_scratch_db(&admin, "listed_name_script_auth").await;
308+
let seed = Seed::new(&pool).await;
309+
for (group, age) in [("first", 30), ("first", 10), ("second", 30), ("second", 10)] {
310+
seed.open(&pool, Uuid::new_v4(), group, age).await;
311+
}
312+
313+
let (output, calls) = run_rename_script(&seed.host, &name, "exit 3\n").await;
314+
let stderr = String::from_utf8_lossy(&output.stderr);
315+
assert_eq!(output.status.code(), Some(1), "{stderr}");
316+
assert_eq!(calls.len(), 1, "it stops after the first refusal");
317+
assert!(stderr.contains("a member of"), "{stderr}");
318+
drop_scratch_db(&admin, pool, &name).await;
319+
}
320+
321+
/// The relay serves only an active, unarchived community, so the script stops
322+
/// before any rename for an archived community, one being deleted, or a host
323+
/// no community has, and says why.
324+
/// Mutation: drop the community check in the script → RED.
325+
#[cfg(unix)]
326+
#[tokio::test]
327+
#[ignore = "requires Postgres"]
328+
async fn script_stops_for_a_community_the_relay_does_not_serve() {
329+
let admin = PgPool::connect(&admin_url().await).await.unwrap();
330+
let (pool, name) = create_scratch_db(&admin, "listed_name_script_state").await;
331+
let archived = Seed::new(&pool).await;
332+
let deleting = Seed::new(&pool).await;
333+
for seed in [&archived, &deleting] {
334+
seed.open(&pool, Uuid::new_v4(), "ops", 30).await;
335+
seed.open(&pool, Uuid::new_v4(), "ops", 10).await;
336+
}
337+
sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1")
338+
.bind(archived.community)
339+
.execute(&pool)
340+
.await
341+
.unwrap();
342+
crate::test_support::quiesce_community_for_tests(
343+
&pool,
344+
buzz_core::CommunityId::from_uuid(deleting.community),
345+
)
346+
.await;
347+
348+
for (host, message) in [
349+
(archived.host.as_str(), "Unarchive it"),
350+
(deleting.host.as_str(), "being deleted"),
351+
("missing.example", "no community has host"),
352+
] {
353+
let (output, calls) = run_rename_script(host, &name, "exit 0\n").await;
354+
let stderr = String::from_utf8_lossy(&output.stderr);
355+
assert_eq!(output.status.code(), Some(1), "{host}: {stderr}");
356+
assert!(calls.is_empty(), "{host}: no rename");
357+
assert!(stderr.contains(message), "{host}: {stderr}");
358+
}
271359
drop_scratch_db(&admin, pool, &name).await;
272360
}

‎crates/buzz-relay/src/handlers/group_state_postgres_tests.rs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,3 +1250,70 @@ async fn a_relay_operator_may_only_rename_a_channel() {
12501250
"{tags:?}"
12511251
);
12521252
}
1253+
1254+
/// The operator rename covers only listed channels: open, not unlisted, a
1255+
/// stream or forum. A relay operator may not rename a private channel, an
1256+
/// unlisted channel or a DM.
1257+
/// Mutation: drop the listed check in `operator_may_rename` → RED.
1258+
#[tokio::test]
1259+
#[ignore = "requires Postgres"]
1260+
async fn a_relay_operator_may_not_rename_an_unlisted_channel() {
1261+
let state = state().await;
1262+
let tenant = community(&state, "group-state-operator-unlisted").await;
1263+
let owner = Keys::generate();
1264+
let other = Keys::generate();
1265+
let operator = Keys::generate();
1266+
state
1267+
.db
1268+
.upsert_relay_operator(
1269+
&operator.public_key().to_bytes(),
1270+
"operator",
1271+
&owner.public_key().to_bytes(),
1272+
true,
1273+
)
1274+
.await
1275+
.expect("grant relay role");
1276+
let private = create_channel(&state, &tenant, &owner, &[&["visibility", "private"]]).await;
1277+
let unlisted = create_channel(&state, &tenant, &owner, &[&["unlisted", "true"]]).await;
1278+
let (dm, _) = state
1279+
.db
1280+
.open_dm(
1281+
tenant.community(),
1282+
&[
1283+
&owner.public_key().to_bytes(),
1284+
&other.public_key().to_bytes(),
1285+
],
1286+
&owner.public_key().to_bytes(),
1287+
)
1288+
.await
1289+
.expect("open dm");
1290+
1291+
for (label, channel) in [("private", private), ("unlisted", unlisted), ("dm", dm.id)] {
1292+
let before = state
1293+
.db
1294+
.get_channel(tenant.community(), channel)
1295+
.await
1296+
.expect("channel");
1297+
if label == "unlisted" {
1298+
assert!(before.unlisted, "the channel is unlisted");
1299+
}
1300+
let event = EventBuilder::new(Kind::Custom(9002), "")
1301+
.tags([
1302+
Tag::parse(["h", &channel.to_string()]).unwrap(),
1303+
Tag::parse(["name", "by-operator"]).unwrap(),
1304+
])
1305+
.sign_with_keys(&operator)
1306+
.expect("sign 9002");
1307+
match ingest_event(&state, &tenant, event, http_auth(&operator)).await {
1308+
Err(IngestError::Rejected(_)) => {}
1309+
Err(other) => panic!("{label}: expected a refusal, got {other:?}"),
1310+
Ok(result) => panic!("{label}: expected a refusal, got {:?}", result.message),
1311+
}
1312+
let after = state
1313+
.db
1314+
.get_channel(tenant.community(), channel)
1315+
.await
1316+
.expect("channel");
1317+
assert_eq!(after.name, before.name, "{label}");
1318+
}
1319+
}

0 commit comments

Comments
 (0)