security(segment): Object selections take at most 64 clicks at the engine API and the model (#3717)

* security(segment): Object selections take at most 64 clicks at the engine API and the model

* address review: refuse over-cap Object clicks before queueing in background mode
This commit is contained in:
Dylan M. Taylor
2026-11-10 18:07:21 -05:00
committed by GitHub
parent 0121e0ccb0
commit c2e9c456c9
4 changed files with 32 additions and 0 deletions
+4
View File
@@ -98,6 +98,10 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later>
match shape {
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
// refused here, not when the worker later fails at the model's limit after "pending"
if hint.len() + exclude.len() > crate::segment::MAX_CLICKS {
return Err(ai_err(format!("an Object selection takes at most {} clicks", crate::segment::MAX_CLICKS)));
}
s.segmenter.require_model()?;
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
}
+3
View File
@@ -498,6 +498,9 @@ impl Session {
if !clicks.iter().any(|c| c.include) {
return Err("click the object to include it first".into());
}
if clicks.len() > MAX_CLICKS {
return Err(format!("an Object selection takes at most {MAX_CLICKS} clicks"));
}
#[cfg(feature = "sam")]
{
self.segment_wait(id, worker::Kind::Clicks(Self::model_clicks(clicks)), None)?.ok_or_else(|| "nothing was selected".to_string())
+19
View File
@@ -240,6 +240,25 @@ fn object_clicks_are_capped() {
let _ = std::fs::remove_dir_all(&dir);
}
/// In background mode a new Object mask with too many clicks is refused up front, not queued.
#[test]
fn background_object_clicks_over_the_cap_are_refused_before_queueing() {
if !Segmenter::AVAILABLE {
return;
}
let mut s = Session::with_demo();
s.segmenter.background = true;
let dir = damaged_model("later-clicks");
s.segmenter.dir = Some(dir.clone());
let id = s.active().unwrap();
let points: Vec<_> = (0..=crate::segment::MAX_CLICKS).map(|_| json!([0.5, 0.5])).collect();
let e = s.execute("mask.add", &json!({"kind": "object", "points": points})).unwrap_err().to_string();
assert!(e.contains("at most"), "{e}");
assert!(s.develop_of(id).unwrap().masks.is_empty());
assert!(!s.segmenter.busy());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "sam")]
#[test]
fn a_running_model_download_shows_in_activity_and_cancels() {
+6
View File
@@ -76,6 +76,8 @@ pub type Result<T> = std::result::Result<T, Error>;
pub const INPUT: usize = 1008;
/// Side of the low-resolution masks the model predicts.
pub const MASK_SIDE: usize = 288;
/// Most clicks one Object selection may carry (the engine's `MAX_CLICKS`).
pub const MAX_CLICKS: usize = 64;
#[cfg(not(target_arch = "wasm32"))]
const _: () = assert!(MASK_SIDE == tracker::LOW_RES && INPUT == vit::IMAGE);
@@ -203,6 +205,10 @@ impl Sam3 {
if clicks.is_empty() {
return Err(Error::Model("no clicks".into()));
}
// the prompt grows with every click: the editor takes at most this many (the engine's `MAX_CLICKS`)
if clicks.len() > MAX_CLICKS {
return Err(Error::Model(format!("at most {MAX_CLICKS} clicks, not {}", clicks.len())));
}
let (neck, tracker) = self.tracker_parts()?;
if enc.tracker.is_none() {
let levels = neck.forward(&enc.backbone, 3)?;