Select: saved selections checked when a file opens, names capped, docs (review of #318)

A file's saved selections are untrusted: on load at most 25 are kept, names
are trimmed, capped at 255 characters and unique, and ids the document
doesn't have are dropped (after fix_next_id such an id would select whatever
object is drawn next). Save and Edit Selection cap names the same way. The
limit, the name cleanup and the default "Selection N" live on
SavedSelection, shared by the engine, the Save Selection dialog and the
Select menu's slot table. docs/mcp.md documents the select.save /
savedList / recall / editSaved commands; ROADMAP counts 13 stubbed menu items
and names what's still missing (Update Selection, the Layers panel menu).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 05:24:44 +07:00
1 parent 87d1acd589
commit 249b34fc7c
10 files changed
+118 -23

No files matched your search

+2
View File
@@ -380,6 +380,8 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
// Saved before v3: the assigned profiles move out of `unknown`.
doc.migrate_color_profiles();
doc.fix_next_id();
// Untrusted: capped, named and pointing at objects the file has.
doc.tidy_saved_selections();
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect();
Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
}
+32
View File
@@ -203,3 +203,35 @@ fn bug_f64_bit_exact_roundtrip() {
d.raster_effects_ppi = 0.1 + 0.2;
check_native_roundtrip_exact(&d).unwrap();
}
/// Saved selections come from the file: older files have none, and a hostile one is cut down to
/// what the Select menu can list, with names that fit and ids the document has.
#[test]
fn saved_selections_from_a_file_are_tidied() {
let d = rich_doc();
let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap();
assert!(v["document"].get("saved_selections").is_none(), "nothing written when there are none");
assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().saved_selections.is_empty());
let id = fixtures::all_ids(&d).into_iter().next().unwrap();
let missing = fixtures::all_ids(&d).into_iter().map(|NodeId(i)| i).max().unwrap() + 1;
let mut list = vec![
json!({"name": format!(" {} ", "x".repeat(1000)), "objects": [id.0, missing, id.0]}),
json!({"name": " ", "objects": [id.0]}),
json!({"name": "Dup", "objects": []}),
json!({"name": "Dup", "objects": [id.0]}),
];
list.extend((0..100).map(|i| json!({"name": format!("S{i}"), "objects": [id.0]})));
v["document"]["saved_selections"] = json!(list);
let back = load(&serde_json::to_vec(&v).unwrap()).unwrap();
let saved = &back.saved_selections;
assert_eq!(saved.len(), vectorcraft_doc::SavedSelection::MAX);
assert_eq!(saved[0].name, "x".repeat(vectorcraft_doc::SavedSelection::MAX_NAME));
assert_eq!(saved[0].objects, vec![id], "a missing id is dropped, a repeated one kept once");
assert_eq!((saved[1].name.as_str(), saved[1].objects.len()), ("Dup", 0), "blank names go, the first of a name stays");
assert_eq!(saved[2].name, "S0");
// What loads saves and loads again unchanged.
assert_eq!(&load(&save(&back, false)).unwrap().saved_selections, saved);
// Junk in the member is a format error, not a crash.
v["document"]["saved_selections"] = json!([{"name": 5, "objects": "x"}]);
assert!(load(&serde_json::to_vec(&v).unwrap()).is_err());
}