Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:37:31.649Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

secure_input.rs

829 lines · 27.3 KB · rust
1use std::{fmt, ops::Range};
2
3use gpui::{
4    App, Bounds, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity,
5    EntityInputHandler, FocusHandle, Focusable, GlobalElementId, InspectorElementId, IntoElement,
6    KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
7    Pixels, Point, Render, Role, ShapedLine, SharedString, Style, TextRun, UTF16Selection,
8    UnderlineStyle, Window, div, fill, point, px, relative, size,
9};
10use ui::prelude::*;
11use unicode_segmentation::UnicodeSegmentation;
12use zeroize::{Zeroize, Zeroizing};
13
14const MAX_SECRET_BYTES: usize = 1024;
15const MASK_CHARACTER: char = '\u{2022}';
16
17pub(crate) struct SecureInput {
18    focus_handle: FocusHandle,
19    content: Zeroizing<String>,
20    placeholder: SharedString,
21    aria_label: SharedString,
22    selected_range: Range<usize>,
23    selection_reversed: bool,
24    marked_range: Option<Range<usize>>,
25    last_layout: Option<ShapedLine>,
26    last_bounds: Option<Bounds<Pixels>>,
27    is_selecting: bool,
28    tab_index: isize,
29    #[cfg(test)]
30    drop_observer: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
31}
32
33impl SecureInput {
34    pub(crate) fn new(
35        placeholder: impl Into<SharedString>,
36        aria_label: impl Into<SharedString>,
37        tab_index: isize,
38        cx: &mut App,
39    ) -> Self {
40        Self {
41            focus_handle: cx.focus_handle(),
42            content: Zeroizing::new(String::with_capacity(MAX_SECRET_BYTES)),
43            placeholder: placeholder.into(),
44            aria_label: aria_label.into(),
45            selected_range: 0..0,
46            selection_reversed: false,
47            marked_range: None,
48            last_layout: None,
49            last_bounds: None,
50            is_selecting: false,
51            tab_index,
52            #[cfg(test)]
53            drop_observer: None,
54        }
55    }
56
57    pub(crate) fn take(&mut self, cx: &mut Context<Self>) -> String {
58        let content =
59            std::mem::replace(&mut *self.content, String::with_capacity(MAX_SECRET_BYTES));
60        self.reset_interaction_state();
61        cx.notify();
62        content
63    }
64
65    pub(crate) fn clear(&mut self, cx: &mut Context<Self>) {
66        self.content.zeroize();
67        self.reset_interaction_state();
68        cx.notify();
69    }
70
71    fn reset_interaction_state(&mut self) {
72        self.selected_range = 0..0;
73        self.selection_reversed = false;
74        self.marked_range = None;
75        self.last_layout = None;
76        self.last_bounds = None;
77        self.is_selecting = false;
78    }
79
80    fn replace_range(&mut self, range: Range<usize>, new_text: &str) -> bool {
81        if range.start > range.end
82            || range.end > self.content.len()
83            || !self.content.is_char_boundary(range.start)
84            || !self.content.is_char_boundary(range.end)
85        {
86            return false;
87        }
88
89        let Some(new_length) = self
90            .content
91            .len()
92            .checked_sub(range.len())
93            .and_then(|length| length.checked_add(new_text.len()))
94        else {
95            return false;
96        };
97        if new_length > MAX_SECRET_BYTES {
98            return false;
99        }
100
101        self.content.replace_range(range.clone(), new_text);
102        let cursor = range.start + new_text.len();
103        self.selected_range = cursor..cursor;
104        self.selection_reversed = false;
105        self.marked_range = None;
106        self.last_layout = None;
107        true
108    }
109
110    fn left(&mut self, select: bool, cx: &mut Context<Self>) {
111        if select {
112            self.select_to(self.previous_boundary(self.cursor_offset()), cx);
113        } else if self.selected_range.is_empty() {
114            self.move_to(self.previous_boundary(self.cursor_offset()), cx);
115        } else {
116            self.move_to(self.selected_range.start, cx);
117        }
118    }
119
120    fn right(&mut self, select: bool, cx: &mut Context<Self>) {
121        if select {
122            self.select_to(self.next_boundary(self.cursor_offset()), cx);
123        } else if self.selected_range.is_empty() {
124            self.move_to(self.next_boundary(self.cursor_offset()), cx);
125        } else {
126            self.move_to(self.selected_range.end, cx);
127        }
128    }
129
130    fn backspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
131        if self.selected_range.is_empty() {
132            let previous = self.previous_boundary(self.cursor_offset());
133            if previous == self.cursor_offset() {
134                window.play_system_bell();
135                return;
136            }
137            self.select_to(previous, cx);
138        }
139        let selected_range = self.selected_range.clone();
140        self.replace_range(selected_range, "");
141        cx.notify();
142    }
143
144    fn delete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
145        if self.selected_range.is_empty() {
146            let next = self.next_boundary(self.cursor_offset());
147            if next == self.cursor_offset() {
148                window.play_system_bell();
149                return;
150            }
151            self.select_to(next, cx);
152        }
153        let selected_range = self.selected_range.clone();
154        self.replace_range(selected_range, "");
155        cx.notify();
156    }
157
158    fn paste(&mut self, window: &mut Window, cx: &mut Context<Self>) {
159        let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
160            return;
161        };
162        let mut text = Zeroizing::new(text);
163        text.retain(|character| character != '\n' && character != '\r');
164        let selected_range = self.selected_range.clone();
165        if !self.replace_range(selected_range, &text) {
166            window.play_system_bell();
167        }
168        cx.notify();
169    }
170
171    fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
172        let modifiers = event.keystroke.modifiers;
173        let handled = if modifiers.secondary() {
174            match event.keystroke.key.as_str() {
175                "a" => {
176                    self.selected_range = 0..self.content.len();
177                    self.selection_reversed = false;
178                    cx.notify();
179                    true
180                }
181                "v" => {
182                    self.paste(window, cx);
183                    true
184                }
185                // Consume these shortcuts so a parent action can never copy or cut the secret.
186                "c" | "x" => true,
187                _ => false,
188            }
189        } else {
190            match event.keystroke.key.as_str() {
191                "backspace" => {
192                    self.backspace(window, cx);
193                    true
194                }
195                "delete" => {
196                    self.delete(window, cx);
197                    true
198                }
199                "left" => {
200                    self.left(modifiers.shift, cx);
201                    true
202                }
203                "right" => {
204                    self.right(modifiers.shift, cx);
205                    true
206                }
207                "home" => {
208                    if modifiers.shift {
209                        self.select_to(0, cx);
210                    } else {
211                        self.move_to(0, cx);
212                    }
213                    true
214                }
215                "end" => {
216                    if modifiers.shift {
217                        self.select_to(self.content.len(), cx);
218                    } else {
219                        self.move_to(self.content.len(), cx);
220                    }
221                    true
222                }
223                _ => false,
224            }
225        };
226
227        if handled {
228            cx.stop_propagation();
229        }
230    }
231
232    fn on_mouse_down(
233        &mut self,
234        event: &MouseDownEvent,
235        window: &mut Window,
236        cx: &mut Context<Self>,
237    ) {
238        window.focus(&self.focus_handle, cx);
239        self.is_selecting = true;
240        let offset = self.index_for_mouse_position(event.position);
241        if event.modifiers.shift {
242            self.select_to(offset, cx);
243        } else {
244            self.move_to(offset, cx);
245        }
246    }
247
248    fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
249        self.is_selecting = false;
250    }
251
252    fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
253        if self.is_selecting {
254            self.select_to(self.index_for_mouse_position(event.position), cx);
255        }
256    }
257
258    fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
259        self.selected_range = offset..offset;
260        self.selection_reversed = false;
261        cx.notify();
262    }
263
264    fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
265        if self.selection_reversed {
266            self.selected_range.start = offset;
267        } else {
268            self.selected_range.end = offset;
269        }
270        if self.selected_range.end < self.selected_range.start {
271            self.selection_reversed = !self.selection_reversed;
272            self.selected_range = self.selected_range.end..self.selected_range.start;
273        }
274        cx.notify();
275    }
276
277    fn cursor_offset(&self) -> usize {
278        if self.selection_reversed {
279            self.selected_range.start
280        } else {
281            self.selected_range.end
282        }
283    }
284
285    fn previous_boundary(&self, offset: usize) -> usize {
286        self.content
287            .grapheme_indices(true)
288            .rev()
289            .find_map(|(index, _)| (index < offset).then_some(index))
290            .unwrap_or(0)
291    }
292
293    fn next_boundary(&self, offset: usize) -> usize {
294        self.content
295            .grapheme_indices(true)
296            .find_map(|(index, _)| (index > offset).then_some(index))
297            .unwrap_or(self.content.len())
298    }
299
300    fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
301        if self.content.is_empty() {
302            return 0;
303        }
304        let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref())
305        else {
306            return self.content.len();
307        };
308        if position.y < bounds.top() {
309            return 0;
310        }
311        if position.y > bounds.bottom() {
312            return self.content.len();
313        }
314        self.content_offset_for_display(line.closest_index_for_x(position.x - bounds.left()))
315    }
316
317    fn masked_text(&self) -> SharedString {
318        std::iter::repeat_n(MASK_CHARACTER, self.content.chars().count())
319            .collect::<String>()
320            .into()
321    }
322
323    fn display_offset_for_content(&self, offset: usize) -> usize {
324        self.content[..offset].chars().count() * MASK_CHARACTER.len_utf8()
325    }
326
327    fn content_offset_for_display(&self, offset: usize) -> usize {
328        let character_index = offset / MASK_CHARACTER.len_utf8();
329        self.content
330            .char_indices()
331            .nth(character_index)
332            .map_or(self.content.len(), |(index, _)| index)
333    }
334
335    fn offset_from_utf16_in(text: &str, offset: usize) -> usize {
336        let mut utf8_offset = 0;
337        let mut utf16_offset = 0;
338        for character in text.chars() {
339            if utf16_offset >= offset {
340                break;
341            }
342            utf16_offset += character.len_utf16();
343            utf8_offset += character.len_utf8();
344        }
345        utf8_offset
346    }
347
348    fn offset_from_utf16(&self, offset: usize) -> usize {
349        Self::offset_from_utf16_in(&self.content, offset)
350    }
351
352    fn offset_to_utf16(&self, offset: usize) -> usize {
353        self.content[..offset].encode_utf16().count()
354    }
355
356    fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
357        self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
358    }
359
360    fn range_from_utf16(&self, range: &Range<usize>) -> Range<usize> {
361        self.offset_from_utf16(range.start)..self.offset_from_utf16(range.end)
362    }
363
364    fn masked_text_for_range(&self, range: Range<usize>) -> Option<String> {
365        let text = self.content.get(range)?;
366        Some(std::iter::repeat_n(MASK_CHARACTER, text.encode_utf16().count()).collect::<String>())
367    }
368}
369
370impl fmt::Debug for SecureInput {
371    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
372        formatter
373            .debug_struct("SecureInput")
374            .field("content", &"[REDACTED]")
375            .finish_non_exhaustive()
376    }
377}
378
379impl Drop for SecureInput {
380    fn drop(&mut self) {
381        self.content.zeroize();
382        #[cfg(test)]
383        if let Some(observer) = self.drop_observer.as_ref() {
384            observer.store(true, std::sync::atomic::Ordering::SeqCst);
385        }
386    }
387}
388
389impl EntityInputHandler for SecureInput {
390    fn text_for_range(
391        &mut self,
392        range_utf16: Range<usize>,
393        actual_range: &mut Option<Range<usize>>,
394        _: &mut Window,
395        _: &mut Context<Self>,
396    ) -> Option<String> {
397        let range = self.range_from_utf16(&range_utf16);
398        actual_range.replace(self.range_to_utf16(&range));
399        self.masked_text_for_range(range)
400    }
401
402    fn selected_text_range(
403        &mut self,
404        _: bool,
405        _: &mut Window,
406        _: &mut Context<Self>,
407    ) -> Option<UTF16Selection> {
408        Some(UTF16Selection {
409            range: self.range_to_utf16(&self.selected_range),
410            reversed: self.selection_reversed,
411        })
412    }
413
414    fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
415        self.marked_range
416            .as_ref()
417            .map(|range| self.range_to_utf16(range))
418    }
419
420    fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
421        self.marked_range = None;
422        cx.notify();
423    }
424
425    fn replace_text_in_range(
426        &mut self,
427        range_utf16: Option<Range<usize>>,
428        new_text: &str,
429        window: &mut Window,
430        cx: &mut Context<Self>,
431    ) {
432        let range = range_utf16
433            .as_ref()
434            .map(|range| self.range_from_utf16(range))
435            .or_else(|| self.marked_range.clone())
436            .unwrap_or_else(|| self.selected_range.clone());
437        if !self.replace_range(range, new_text) {
438            window.play_system_bell();
439        }
440        cx.notify();
441    }
442
443    fn replace_and_mark_text_in_range(
444        &mut self,
445        range_utf16: Option<Range<usize>>,
446        new_text: &str,
447        new_selected_range_utf16: Option<Range<usize>>,
448        window: &mut Window,
449        cx: &mut Context<Self>,
450    ) {
451        let range = range_utf16
452            .as_ref()
453            .map(|range| self.range_from_utf16(range))
454            .or_else(|| self.marked_range.clone())
455            .unwrap_or_else(|| self.selected_range.clone());
456        let start = range.start;
457        if !self.replace_range(range, new_text) {
458            window.play_system_bell();
459            return;
460        }
461
462        self.marked_range = (!new_text.is_empty()).then_some(start..start + new_text.len());
463        self.selected_range = new_selected_range_utf16
464            .map(|range| {
465                let selected_start = Self::offset_from_utf16_in(new_text, range.start);
466                let selected_end = Self::offset_from_utf16_in(new_text, range.end);
467                start + selected_start..start + selected_end
468            })
469            .unwrap_or_else(|| {
470                let end = start + new_text.len();
471                end..end
472            });
473        cx.notify();
474    }
475
476    fn bounds_for_range(
477        &mut self,
478        range_utf16: Range<usize>,
479        bounds: Bounds<Pixels>,
480        _: &mut Window,
481        _: &mut Context<Self>,
482    ) -> Option<Bounds<Pixels>> {
483        let range = self.range_from_utf16(&range_utf16);
484        let start = self.display_offset_for_content(range.start);
485        let end = self.display_offset_for_content(range.end);
486        let line = self.last_layout.as_ref()?;
487        Some(Bounds::from_corners(
488            point(bounds.left() + line.x_for_index(start), bounds.top()),
489            point(bounds.left() + line.x_for_index(end), bounds.bottom()),
490        ))
491    }
492
493    fn character_index_for_point(
494        &mut self,
495        point: Point<Pixels>,
496        _: &mut Window,
497        _: &mut Context<Self>,
498    ) -> Option<usize> {
499        let bounds = self.last_bounds?;
500        let local_point = bounds.localize(&point)?;
501        let line = self.last_layout.as_ref()?;
502        let display_index = line.index_for_x(point.x - local_point.x)?;
503        Some(self.offset_to_utf16(self.content_offset_for_display(display_index)))
504    }
505}
506
507struct SecureInputElement {
508    input: Entity<SecureInput>,
509}
510
511struct PrepaintState {
512    line: Option<ShapedLine>,
513    cursor: Option<PaintQuad>,
514    selection: Option<PaintQuad>,
515}
516
517impl IntoElement for SecureInputElement {
518    type Element = Self;
519
520    fn into_element(self) -> Self::Element {
521        self
522    }
523}
524
525impl Element for SecureInputElement {
526    type RequestLayoutState = ();
527    type PrepaintState = PrepaintState;
528
529    fn id(&self) -> Option<ElementId> {
530        None
531    }
532
533    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
534        None
535    }
536
537    fn request_layout(
538        &mut self,
539        _: Option<&GlobalElementId>,
540        _: Option<&InspectorElementId>,
541        window: &mut Window,
542        cx: &mut App,
543    ) -> (LayoutId, Self::RequestLayoutState) {
544        let mut style = Style::default();
545        style.size.width = relative(1.).into();
546        style.size.height = window.line_height().into();
547        (window.request_layout(style, [], cx), ())
548    }
549
550    fn prepaint(
551        &mut self,
552        _: Option<&GlobalElementId>,
553        _: Option<&InspectorElementId>,
554        bounds: Bounds<Pixels>,
555        _: &mut Self::RequestLayoutState,
556        window: &mut Window,
557        cx: &mut App,
558    ) -> Self::PrepaintState {
559        let input = self.input.read(cx);
560        let selected_range = input.selected_range.clone();
561        let cursor = input.cursor_offset();
562        let text_style = window.text_style();
563        let is_placeholder = input.content.is_empty();
564        let display_text = if is_placeholder {
565            input.placeholder.clone()
566        } else {
567            input.masked_text()
568        };
569        let text_color = if is_placeholder {
570            cx.theme().colors().text_muted
571        } else {
572            text_style.color
573        };
574        let base_run = TextRun {
575            len: display_text.len(),
576            font: text_style.font(),
577            color: text_color,
578            background_color: None,
579            underline: None,
580            strikethrough: None,
581        };
582        let runs = if let Some(marked_range) = input.marked_range.as_ref() {
583            let marked_start = input.display_offset_for_content(marked_range.start);
584            let marked_end = input.display_offset_for_content(marked_range.end);
585            vec![
586                TextRun {
587                    len: marked_start,
588                    ..base_run.clone()
589                },
590                TextRun {
591                    len: marked_end - marked_start,
592                    underline: Some(UnderlineStyle {
593                        color: Some(base_run.color),
594                        thickness: px(1.),
595                        wavy: false,
596                    }),
597                    ..base_run.clone()
598                },
599                TextRun {
600                    len: display_text.len() - marked_end,
601                    ..base_run
602                },
603            ]
604            .into_iter()
605            .filter(|run| run.len > 0)
606            .collect()
607        } else {
608            vec![base_run]
609        };
610        let font_size = text_style.font_size.to_pixels(window.rem_size());
611        let line = window
612            .text_system()
613            .shape_line(display_text, font_size, &runs, None);
614        let display_cursor = input.display_offset_for_content(cursor);
615        let cursor_position = line.x_for_index(display_cursor);
616        let (selection, cursor) = if selected_range.is_empty() {
617            (
618                None,
619                Some(fill(
620                    Bounds::new(
621                        point(bounds.left() + cursor_position, bounds.top()),
622                        size(px(2.), bounds.bottom() - bounds.top()),
623                    ),
624                    cx.theme().colors().text_accent,
625                )),
626            )
627        } else {
628            let selection_start = input.display_offset_for_content(selected_range.start);
629            let selection_end = input.display_offset_for_content(selected_range.end);
630            (
631                Some(fill(
632                    Bounds::from_corners(
633                        point(
634                            bounds.left() + line.x_for_index(selection_start),
635                            bounds.top(),
636                        ),
637                        point(
638                            bounds.left() + line.x_for_index(selection_end),
639                            bounds.bottom(),
640                        ),
641                    ),
642                    cx.theme().colors().element_selected,
643                )),
644                None,
645            )
646        };
647
648        PrepaintState {
649            line: Some(line),
650            cursor,
651            selection,
652        }
653    }
654
655    fn paint(
656        &mut self,
657        _: Option<&GlobalElementId>,
658        _: Option<&InspectorElementId>,
659        bounds: Bounds<Pixels>,
660        _: &mut Self::RequestLayoutState,
661        prepaint: &mut Self::PrepaintState,
662        window: &mut Window,
663        cx: &mut App,
664    ) {
665        let focus_handle = self.input.read(cx).focus_handle.clone();
666        window.handle_input(
667            &focus_handle,
668            ElementInputHandler::new(bounds, self.input.clone()),
669            cx,
670        );
671        if let Some(selection) = prepaint.selection.take() {
672            window.paint_quad(selection);
673        }
674        if let Some(line) = prepaint.line.take() {
675            if let Err(error) = line.paint(
676                bounds.origin,
677                window.line_height(),
678                gpui::TextAlign::Left,
679                None,
680                window,
681                cx,
682            ) {
683                zlog::error!("failed to paint secure input: {error}");
684            }
685            self.input.update(cx, |input, _| {
686                input.last_layout = Some(line);
687                input.last_bounds = Some(bounds);
688            });
689        }
690        if focus_handle.is_focused(window) {
691            if let Some(cursor) = prepaint.cursor.take() {
692                window.paint_quad(cursor);
693            }
694        }
695    }
696}
697
698impl Render for SecureInput {
699    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
700        let colors = cx.theme().colors();
701        let focus_handle = self
702            .focus_handle
703            .clone()
704            .tab_index(self.tab_index)
705            .tab_stop(true);
706        div()
707            .id(("secure-input", cx.entity_id()))
708            .role(Role::PasswordInput)
709            .aria_label(self.aria_label.clone())
710            .aria_placeholder(self.placeholder.clone())
711            .track_focus(&focus_handle)
712            .key_context("SecureInput")
713            .on_key_down(cx.listener(Self::key_down))
714            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
715            .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
716            .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
717            .on_mouse_move(cx.listener(Self::on_mouse_move))
718            .cursor(CursorStyle::IBeam)
719            .h_8()
720            .w_full()
721            .rounded_md()
722            .border_1()
723            .border_color(colors.border)
724            .bg(colors.editor_background)
725            .px_2()
726            .py_1()
727            .focus(|style| style.border_color(colors.border_focused))
728            .child(SecureInputElement { input: cx.entity() })
729    }
730}
731
732impl Focusable for SecureInput {
733    fn focus_handle(&self, _: &App) -> FocusHandle {
734        self.focus_handle.clone()
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use std::sync::{
741        Arc,
742        atomic::{AtomicBool, Ordering},
743    };
744
745    use gpui::TestAppContext;
746
747    use super::*;
748
749    #[gpui::test]
750    fn enforces_byte_cap(cx: &mut TestAppContext) {
751        let mut input = cx.update(|cx| SecureInput::new("Secret", "Secret value", 0, cx));
752        assert!(input.replace_range(0..0, &"a".repeat(MAX_SECRET_BYTES)));
753        assert!(!input.replace_range(MAX_SECRET_BYTES..MAX_SECRET_BYTES, "b"));
754        assert_eq!(input.content.len(), MAX_SECRET_BYTES);
755    }
756
757    #[gpui::test]
758    fn take_replaces_storage_with_empty_preallocated_buffer(cx: &mut TestAppContext) {
759        let input = cx.update(|cx| cx.new(|cx| SecureInput::new("Secret", "Secret value", 0, cx)));
760        cx.update(|cx| {
761            input.update(cx, |input, _| {
762                assert!(input.replace_range(0..0, "correct horse battery staple"));
763            });
764        });
765        let mut taken = cx.update(|cx| input.update(cx, SecureInput::take));
766
767        assert_eq!(taken, "correct horse battery staple");
768        cx.update(|cx| {
769            let input = input.read(cx);
770            assert!(input.content.is_empty());
771            assert!(input.content.capacity() >= MAX_SECRET_BYTES);
772        });
773        taken.zeroize();
774    }
775
776    #[gpui::test]
777    fn clear_zeroizes_and_reuses_storage(cx: &mut TestAppContext) {
778        let input = cx.update(|cx| cx.new(|cx| SecureInput::new("Secret", "Secret value", 0, cx)));
779        cx.update(|cx| {
780            input.update(cx, |input, _| {
781                assert!(input.replace_range(0..0, "sensitive"));
782            });
783        });
784        cx.update(|cx| input.update(cx, SecureInput::clear));
785
786        cx.update(|cx| {
787            let input = input.read(cx);
788            assert!(input.content.is_empty());
789            assert!(input.content.capacity() >= MAX_SECRET_BYTES);
790            assert_eq!(input.selected_range, 0..0);
791        });
792    }
793
794    #[gpui::test]
795    fn drop_runs_zeroizing_destructor(cx: &mut TestAppContext) {
796        let dropped = Arc::new(AtomicBool::new(false));
797        let mut input = cx.update(|cx| SecureInput::new("Secret", "Secret value", 0, cx));
798        input.drop_observer = Some(dropped.clone());
799        assert!(input.replace_range(0..0, "sensitive"));
800
801        drop(input);
802
803        assert!(dropped.load(Ordering::SeqCst));
804    }
805
806    #[gpui::test]
807    fn debug_output_is_redacted(cx: &mut TestAppContext) {
808        let mut input = cx.update(|cx| SecureInput::new("Secret", "Secret value", 0, cx));
809        assert!(input.replace_range(0..0, "never print this"));
810        let debug = format!("{input:?}");
811
812        assert!(debug.contains("[REDACTED]"));
813        assert!(!debug.contains("never print this"));
814    }
815
816    #[gpui::test]
817    fn input_handler_ranges_return_only_utf16_aligned_masks(cx: &mut TestAppContext) {
818        let mut input = cx.update(|cx| SecureInput::new("Secret", "Secret value", 0, cx));
819        assert!(input.replace_range(0..0, "a🔑b"));
820
821        let masked = input
822            .masked_text_for_range(1..5)
823            .expect("valid secret range");
824
825        assert_eq!(masked, "••");
826        assert!(!masked.contains('🔑'));
827    }
828}
829
Served at tenant.openagents/omega Member data and write actions are omitted.