Skip to repository content829 lines · 27.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:37:01.444Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
secure_input.rs
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