fix: reject unsupported kinds in move_draft, survive NUL-poisoned draft rows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-08 12:24:14 +02:00
co-authored by Claude Opus 5
parent 5b806a5e3c
commit c6bf9ca965
3 changed files with 55 additions and 6 deletions
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft AS d\n SET path = $3,\n value = CASE\n WHEN position(chr(92) || 'u0000' in d.value::text) > 0 THEN d.value\n WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text)\n THEN to_json(jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false))\n ELSE d.value\n END\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ::text = ANY($5::text[])\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = d.workspace_id\n AND o.path = $3\n AND o.typ = d.typ\n AND o.email IS NOT DISTINCT FROM d.email\n )\n RETURNING d.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917"
}
+17
View File
@@ -760,6 +760,23 @@ async fn move_draft(
) -> Result<String> {
let path = path.to_path();
let new_path = req.new_path.as_str();
// Only the full-page editor kinds. The in-value rewrite below keys off
// `typed_path_field()`, which answers `draft_path` for every non-script kind
// — true for flows and apps, wrong for resources, variables and triggers,
// whose drafts keep their deploy target in `value.path`. Moving one of those
// would inject a `draft_path` nothing reads and leave the real target naming
// the old location, so the next deploy would recreate it where it came from.
if !matches!(
kind,
UserDraftItemKind::Script
| UserDraftItemKind::Flow
| UserDraftItemKind::App
| UserDraftItemKind::RawApp
) {
return Err(Error::BadRequest(format!(
"moving a {kind:?} draft is not supported — only scripts, flows and apps"
)));
}
// A summary-only edit is a legitimate use of this endpoint: the drawer edits
// both fields, and for a draft-only script the path it posts back is the row
// path unchanged (`list_scripts` only reports `draft_path` when it differs).
+12 -6
View File
@@ -625,15 +625,21 @@ pub async fn move_drafts_for_path(
// this draft would send the item back where it came from.
// - anything else: a rename the user staged in their editor. Deploying
// should still land there, so the move leaves it alone.
//
// `draft.value` is a `json` column, so `to_jsonb` raises 22P05 on a row that
// still carries a NUL escape from before the write-time `strip_json_nul`
// guard (see `backend/tests/drafts_nul.rs`). Such a row is skipped by the
// rewrite and carried on its `path` column alone: one teammate's poisoned
// draft must not abort an unrelated user's rename mid-transaction.
let moved = sqlx::query_scalar!(
r#"UPDATE draft AS d
SET path = $3,
value = to_json(
CASE WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text)
THEN jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)
ELSE to_jsonb(d.value)
END
)
value = CASE
WHEN position(chr(92) || 'u0000' in d.value::text) > 0 THEN d.value
WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text)
THEN to_json(jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false))
ELSE d.value
END
WHERE d.workspace_id = $1
AND d.path = $2
AND d.typ::text = ANY($5::text[])