Complete desktop parity and security audit (#42)

This commit is contained in:
2026-08-10 20:38:28 +02:00
parent 4a54bf7b6a
commit 40b15a9713
5 changed files with 340 additions and 36 deletions

View File

@@ -43,6 +43,9 @@ differences, and executable security audit are documented in
[`docs/cli-parity.md`](docs/cli-parity.md). [`docs/cli-parity.md`](docs/cli-parity.md).
The Mutt-inspired terminal interaction model, default keys, and complete TUI The Mutt-inspired terminal interaction model, default keys, and complete TUI
operation matrix are documented in [`apps/tui/COMMANDS.md`](apps/tui/COMMANDS.md). operation matrix are documented in [`apps/tui/COMMANDS.md`](apps/tui/COMMANDS.md).
Desktop parity, native platform smoke checks, accessibility limitations, and
the executable security audit are documented in
[`docs/desktop-audit.md`](docs/desktop-audit.md).
Lossless structured entry fields, semantic/sensitivity metadata, conflict Lossless structured entry fields, semantic/sensitivity metadata, conflict
tokens, and atomic frontend saves are documented in tokens, and atomic frontend saves are documented in
[`docs/entry-documents.md`](docs/entry-documents.md). [`docs/entry-documents.md`](docs/entry-documents.md).

View File

@@ -2,6 +2,32 @@
use iced::keyboard::{self, key::Named}; use iced::keyboard::{self, key::Named};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DesktopPlatform {
MacOs,
Linux,
Windows,
}
impl DesktopPlatform {
pub const fn current() -> Self {
if cfg!(target_os = "macos") {
Self::MacOs
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Linux
}
}
const fn primary_modifier(self) -> keyboard::Modifiers {
match self {
Self::MacOs => keyboard::Modifiers::LOGO,
Self::Linux | Self::Windows => keyboard::Modifiers::CTRL,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum UiAction { pub enum UiAction {
About, About,
@@ -314,8 +340,12 @@ pub fn actions_in(group: MenuGroup) -> impl Iterator<Item = &'static ActionSpec>
} }
pub fn shortcut_label(action: UiAction) -> Option<String> { pub fn shortcut_label(action: UiAction) -> Option<String> {
shortcut_label_for(action, DesktopPlatform::current())
}
pub fn shortcut_label_for(action: UiAction, platform: DesktopPlatform) -> Option<String> {
let shortcut = spec_for(action).shortcut?; let shortcut = spec_for(action).shortcut?;
if cfg!(target_os = "macos") { if platform == DesktopPlatform::MacOs {
Some(shortcut.to_owned()) Some(shortcut.to_owned())
} else { } else {
Some(shortcut.replace('⇧', "Shift+").replace('⌘', "Ctrl+")) Some(shortcut.replace('⇧', "Shift+").replace('⌘', "Ctrl+"))
@@ -685,13 +715,21 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] {
} }
pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<UiAction> { pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<UiAction> {
shortcut_action_for(DesktopPlatform::current(), key, modifiers)
}
pub fn shortcut_action_for(
platform: DesktopPlatform,
key: &keyboard::Key,
modifiers: keyboard::Modifiers,
) -> Option<UiAction> {
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() { if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
return Some(UiAction::TogglePaneFocus); return Some(UiAction::TogglePaneFocus);
} }
if key.as_ref() == keyboard::Key::Named(Named::F1) { if key.as_ref() == keyboard::Key::Named(Named::F1) {
return Some(UiAction::Help); return Some(UiAction::Help);
} }
if !modifiers.command() { if !modifiers.contains(platform.primary_modifier()) {
return None; return None;
} }
match key.as_ref() { match key.as_ref() {
@@ -940,8 +978,20 @@ mod tests {
} }
#[test] #[test]
fn conventional_shortcuts_resolve_to_the_registered_actions() { fn conventional_shortcuts_resolve_on_every_supported_platform() {
let primary = keyboard::Modifiers::COMMAND; for (platform, primary, label) in [
(DesktopPlatform::MacOs, keyboard::Modifiers::LOGO, "⌘S"),
(DesktopPlatform::Linux, keyboard::Modifiers::CTRL, "Ctrl+S"),
(
DesktopPlatform::Windows,
keyboard::Modifiers::CTRL,
"Ctrl+S",
),
] {
assert_eq!(
shortcut_label_for(UiAction::Save, platform).as_deref(),
Some(label)
);
for (key, expected) in [ for (key, expected) in [
("o", UiAction::OpenFolder), ("o", UiAction::OpenFolder),
("s", UiAction::Save), ("s", UiAction::Save),
@@ -956,21 +1006,35 @@ mod tests {
(",", UiAction::Settings), (",", UiAction::Settings),
] { ] {
assert_eq!( assert_eq!(
shortcut_action(&keyboard::Key::Character(key.into()), primary), shortcut_action_for(platform, &keyboard::Key::Character(key.into()), primary,),
Some(expected) Some(expected)
); );
} }
assert_eq!( assert_eq!(
shortcut_action( shortcut_action_for(
platform,
&keyboard::Key::Character("f".into()), &keyboard::Key::Character("f".into()),
primary | keyboard::Modifiers::SHIFT, primary | keyboard::Modifiers::SHIFT,
), ),
Some(UiAction::SearchContents) Some(UiAction::SearchContents)
); );
assert_eq!( assert_eq!(
shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE), shortcut_action_for(
platform,
&keyboard::Key::Named(Named::F1),
keyboard::Modifiers::NONE,
),
Some(UiAction::Help) Some(UiAction::Help)
); );
}
assert_eq!(
shortcut_action_for(
DesktopPlatform::MacOs,
&keyboard::Key::Character("s".into()),
keyboard::Modifiers::CTRL,
),
None
);
assert_eq!(shortcut_label(UiAction::Help).as_deref(), Some("F1")); assert_eq!(shortcut_label(UiAction::Help).as_deref(), Some("F1"));
} }
} }

View File

@@ -852,12 +852,16 @@ fn main() -> iced::Result {
.title(ironstorage::PRODUCT_NAME) .title(ironstorage::PRODUCT_NAME)
.subscription(App::subscription) .subscription(App::subscription)
.exit_on_close_request(false) .exit_on_close_request(false)
.window(window::Settings { .window(window_settings())
.run()
}
fn window_settings() -> window::Settings {
window::Settings {
size: Size::new(1_080.0, 720.0), size: Size::new(1_080.0, 720.0),
min_size: Some(Size::new(720.0, 480.0)), min_size: Some(Size::new(720.0, 480.0)),
..window::Settings::default() ..window::Settings::default()
}) }
.run()
} }
impl App { impl App {
@@ -3345,11 +3349,8 @@ impl App {
.min_size(220) .min_size(220)
.on_resize(8, Message::PaneResized); .on_resize(8, Message::PaneResized);
let shortcut = if cfg!(target_os = "macos") { let shortcut = action::shortcut_label(UiAction::CommandPalette)
"⌘K" .expect("the command palette has a registered shortcut");
} else {
"Ctrl+K"
};
let command_input = text_input( let command_input = text_input(
&format!("Search commands ({shortcut})"), &format!("Search commands ({shortcut})"),
self.palette.query(), self.palette.query(),
@@ -5034,6 +5035,89 @@ mod tests {
use super::*; use super::*;
#[test]
fn desktop_coverage_matrix_contains_every_registered_action() {
let matrix = include_str!("../../../docs/desktop-audit.md");
for spec in action::ACTIONS {
assert!(
matrix.contains(&format!("`{}`", spec.action.id())),
"desktop parity matrix is missing {}",
spec.action.id()
);
}
for required_surface in [
"Base pass",
"Pass OTP",
"Embedded Git",
"Configuration and lock",
"macOS",
"Linux",
"Windows",
] {
assert!(
matrix.contains(required_surface),
"missing {required_surface}"
);
}
}
#[test]
fn production_desktop_sources_preserve_security_and_architecture_boundaries() {
let sources = [
("action.rs", include_str!("action.rs")),
("editor.rs", include_str!("editor.rs")),
("folder_picker.rs", include_str!("folder_picker.rs")),
("main.rs", include_str!("main.rs")),
("native_menu.rs", include_str!("native_menu.rs")),
("navigation.rs", include_str!("navigation.rs")),
("palette.rs", include_str!("palette.rs")),
];
for (name, source) in sources {
let production = source
.split("#[cfg(test)]\nmod tests")
.next()
.unwrap_or(source);
let forbidden_tokens = [
["std::", "process"].concat(),
["process", "::Command"].concat(),
["Command", "::new("].concat(),
["Repository", "::open"].concat(),
["GitRepository", "::"].concat(),
["OtpUri", "::parse"].concat(),
["qrcode", "::QrCode"].concat(),
["fs::", "write"].concat(),
["File", "::create"].concat(),
["OpenOptions", "::new"].concat(),
["println", "!("].concat(),
["eprintln", "!("].concat(),
["dbg", "!("].concat(),
["log", "::info"].concat(),
["log", "::debug"].concat(),
["log", "::error"].concat(),
["tracing", "::info"].concat(),
["tracing", "::debug"].concat(),
["tracing", "::error"].concat(),
["http", "://"].concat(),
["unsafe", " {"].concat(),
];
for forbidden in &forbidden_tokens {
assert!(
!production.contains(forbidden),
"{name} crosses the desktop architecture boundary with {forbidden}"
);
}
}
}
#[test]
fn window_contract_keeps_both_scrollable_panes_usable_at_narrow_size() {
let settings = window_settings();
assert_eq!(settings.size, Size::new(1_080.0, 720.0));
assert_eq!(settings.min_size, Some(Size::new(720.0, 480.0)));
let app = test_app(None);
let _view = app.view();
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct ManualClock(Arc<Mutex<Duration>>); struct ManualClock(Arc<Mutex<Duration>>);

View File

@@ -160,3 +160,35 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
}; };
Some(Accelerator::new(Some(modifiers), code)) Some(Accelerator::new(Some(modifiers), code))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_menu_uses_standard_macos_accelerators() {
for (action, modifiers, code) in [
(UiAction::Settings, Modifiers::SUPER, Code::Comma),
(UiAction::NewEntry, Modifiers::SUPER, Code::KeyN),
(UiAction::OpenFolder, Modifiers::SUPER, Code::KeyO),
(UiAction::Save, Modifiers::SUPER, Code::KeyS),
(UiAction::CloseWindow, Modifiers::SUPER, Code::KeyW),
(UiAction::Quit, Modifiers::SUPER, Code::KeyQ),
(UiAction::Find, Modifiers::SUPER, Code::KeyF),
(
UiAction::SearchContents,
Modifiers::SUPER | Modifiers::SHIFT,
Code::KeyF,
),
(UiAction::CommandPalette, Modifiers::SUPER, Code::KeyK),
(UiAction::Refresh, Modifiers::SUPER, Code::KeyR),
(UiAction::Lock, Modifiers::SUPER, Code::KeyL),
(UiAction::Help, Modifiers::empty(), Code::F1),
] {
let accelerator = accelerator(action).expect("registered native accelerator");
assert_eq!(accelerator.modifiers(), modifiers, "{action:?}");
assert_eq!(accelerator.key(), code, "{action:?}");
}
assert!(accelerator(UiAction::GeneratePassword).is_none());
}
}

121
docs/desktop-audit.md Normal file
View File

@@ -0,0 +1,121 @@
# Desktop parity, platform, and security audit
The Iced application is a presentation adapter over `crates/storage`. The
tables below are the milestone 01/02 coverage record. `action.rs` is the single
desktop action and enablement registry used by menus, shortcuts, direct
controls, context controls, Help, and the command palette. A desktop unit test
requires every registered action ID to remain present in this document.
## Coverage matrix
| Area and compatible operation | Registered desktop action and menu | Direct control, dialog, or view | Command palette |
| --- | --- | --- | --- |
| Configuration and lock: open configured store | `open-folder` (File) | Open Folder button and native folder picker; storage validates and persists configuration | Yes |
| Configuration and lock: edit shared settings | `settings` (IronStorage) | Labelled Settings form for vault, default key, and inactivity timeout | Yes |
| Configuration and lock: refresh typed tree | `refresh` (View) | Sidebar Refresh button | Yes |
| Configuration and lock: lock/unlock | `lock` (Tools) | Lock button; opening protected content starts storage authentication | Lock only; unlock is the protected action being resumed |
| Base pass: `init` root or nested recipient policy | `initialize-store`, `new-folder` (File) | Recipient/default-key form and explicit replacement confirmation | Yes |
| Base pass: default/list/`ls`/`list` | `refresh` (View) | Expandable, scrollable storage-provided sidebar tree | Yes for refresh; browsing is direct navigation |
| Base pass: `show` and reload | `open-entry`, `reload-entry` (Entry) | Entry path control, tree activation, structured viewer, Reload button | Yes |
| Base pass: `insert`/`add` | `new-entry` (File) | New Entry form creates a lossless draft, then the structured editor saves it | Yes |
| Base pass: `edit` and save | `edit-entry`, `save` (Entry/File) | Edit and Save buttons; shared Save/Discard/Cancel guard | Yes |
| Base pass: `generate` and replace | `generate-password` (Entry) | Field Generate control and explicit replacement confirmation | Yes |
| Base pass: explicit secret display and clipboard | `toggle-reveal`, `copy-field`, `copy-edited-field` (Entry) | Per-field labelled Reveal/Hide and Copy controls | Yes |
| Base pass: `find` | `find` (Edit) | Name-search form and typed result activation | Yes |
| Base pass: `grep` | `search-contents` (Edit) | Authenticated decrypted-search form and typed result activation | Yes |
| Base pass: `mv`/`rename`, `cp`/`copy`, `rm`/`remove` | `move-entry`, `copy-entry`, `delete-entry` (Entry) | Sidebar context controls and validated mutation forms; delete is confirmed | Yes |
| Pass OTP: code/show and timed copy | `generate-otp`, `copy-otp` (Entry) | OTP panel shows typed metadata, code, validity, and HOTP confirmation | Yes |
| Pass OTP: insert/add/append and validate | `import-otp` (Entry) | URI/QR import form; storage validates, replaces, and commits | Yes |
| Pass OTP: URI terminal/clipboard/QR | `show-otp-uri`, `copy-otp-uri`, `show-otp-qr` (Entry) | Explicit secret view, timed copy, and storage-provided QR matrix | Yes |
| Pass OTP: remove | `remove-otp` (Entry) | Explicit permanent-removal confirmation | Yes |
| Embedded Git: status, history, remote divergence | `git-status` (Tools) | Git dashboard renders the storage snapshot and recent history | Yes |
| Embedded Git: fetch/pull | `git-pull` (Tools) | Pull progress/cancel control; fetch is the receive phase of the typed pull | Yes |
| Embedded Git: push | `git-push` (Tools) | Push progress/cancel control | Yes |
| Embedded Git: sync and conflicts | `git-sync` (Tools) | Sync progress plus per-path local/remote conflict choices | Yes |
| Desktop command discovery and pane navigation | `command-palette`, `toggle-pane-focus` (View) | Toolbar search, Tab focus transfer, arrows/Home/End/Enter navigation | The palette opens itself by shortcut/control and is intentionally not a result |
| Desktop text editing | `undo`, `redo`, `cut`, `paste` (Edit) | Platform text controls own these standard operations; the shared registry explains why app-level dispatch is disabled | Listed with its current availability |
| Window/application lifecycle | `about`, `close-window`, `quit`, `minimize` (IronStorage/File/Window) | Standard native roles on macOS and equivalent in-window actions elsewhere | Yes except native-only minimize behavior where the OS owns the role |
| Help and current shortcuts | `help` (Help) | Labelled, scrollable Help view generated from the action registry | Yes |
The CLI/TUI expose a few terminal-shaped selectors rather than separate domain
capabilities. Generic `show --qrcode`, OTP version output, arbitrary embedded
Git plumbing, and shell completion are therefore not duplicate desktop
actions. The desktop viewer already renders an ordinary field, OTP version
compatibility belongs to storage/CLI metadata, Git initialization and commits
occur within typed storage mutations, remote configuration is shared Settings,
and desktop command discovery is the palette. Git history and conflict
resolution are controls inside `git-status` rather than separate commands.
These are deliberate presentation differences, not storage-feature gaps.
## Interaction and accessibility evidence
- Keyboard-only operation uses Tab between panes, arrows/Home/End/Enter within
trees, fields, menus, and palette results, Escape to dismiss, and registered
accelerators. The same controls are mouse/touch activatable; sidebar context
actions also accept a secondary click.
- Both panes are independently scrollable, the divider is resizable, action
rows wrap, long names and multiline values are retained, and the supported
narrow window floor is 720 by 480 logical pixels. Iced/winit applies native
display scaling before layout.
- Interactive controls use visible, operation-specific text instead of icon-
only labels. Focused/selected controls use the theme's primary contrast pair;
light, dark, and operating-system high-contrast palettes retain a visible
text label as a non-colour focus cue.
- The app implements no animation or motion-driven state transition. The one-
second subscription updates lease, OTP, Git, and clipboard presentation state
without moving focus or renewing authentication, so reduced-motion mode has
no additional transition to disable.
- Iced 0.14 does not expose a stable application API for supplying a native
screen-reader accessibility tree. Controls therefore have complete visible
labels and deterministic keyboard focus, but native screen-reader role/name
integration is an explicit framework limitation rather than a silent stub.
## Platform menu and workflow smoke contract
`conventional_shortcuts_resolve_on_every_supported_platform` executes the
Command-versus-Control mapping for macOS, Linux, and Windows on every test host.
The macOS-only native adapter test verifies its native accelerators. Linux and
Windows use the same action registry through the in-window menu, so its labels,
enablement, palette dispatch, and workflow state tests are platform-neutral.
Native equivalent runners use stable Rust and run from the repository root:
```sh
cargo test --package ironstorage-desktop --all-targets
cargo check --package ironstorage-desktop --all-targets
cargo run --package ironstorage-desktop
```
For the final smoke step on each native host:
| Platform | Menu/accelerator smoke | Primary workflow smoke |
| --- | --- | --- |
| macOS | System App/File/Edit/View/Entry/Tools/Window/Help menus; Command-O/S/F/K/L, Tab, F1 | Open a test vault, unlock/view/edit/save, search, Git refresh, OTP code/copy, lock, close |
| Linux | In-window menus; Ctrl-O/S/F/K/L, Tab, F1 | Same sequence under X11 or Wayland with the desktop portal available |
| Windows | In-window menus; Ctrl-O/S/F/K/L, Tab, F1 | Same sequence with the native folder picker and Windows secure store available |
The automated state tests use temporary real encrypted stores and cover the
same primary workflows without retaining a GUI password or starting a helper.
Native picker, clipboard, secure-store, and actual screen-reader integration
remain native-host smoke checks because CI cannot emulate those OS services.
## Security and architecture audit
| Risk | Enforced behavior and executable evidence |
| --- | --- |
| Plaintext lifetime and persistence | Entry/OTP values use storage `SecretBytes` or zeroizing edit buffers. Lock, expiry, vault switch, and stale completion paths drop the editor and sensitive presentation state. The source audit rejects desktop filesystem writes. |
| Masking, errors, and diagnostics | Storage supplies sensitivity and redacted typed errors. Viewer/editor tests require masking until explicit reveal; malformed and non-UTF-8 fields remain lossless. Desktop messages are not `Debug`, and the source audit rejects print/debug/log-style output. |
| Clipboard | `NativeClipboardManager` owns timeout and replacement-safe cleanup. Desktop state shows a live remaining-seconds value, cancels cleanup on lock, and ignores stale completions. |
| Authentication expiry | Storage authentication leases own the clock and policy. Passive ticks, rendering, pointer movement, and window events do not renew activity; deterministic tests cover expiry during protected state. |
| Dirty documents and conflicts | Every entry/vault/window/Git worktree replacement routes through one Save/Discard/Cancel decision. Failed saves and conflicts keep the complete draft. |
| Background and window lifecycle | Generation counters reject stale asynchronous results. Lock cancels Git/clipboard work and clears OTP, QR, URI, entry, and editor state. Close and quit use the same dirty guard. |
| Repository and domain ownership | The executable source audit rejects repository/Git construction, process launch, OTP/QR parsing, filesystem writes, unsafe blocks, and non-HTTPS literals in production desktop modules. The folder picker may read only a user-selected QR image; all password-store I/O remains in `crates/storage`. |
Run the complete repository gate after the desktop-specific checks:
```sh
cargo fmt --all -- --check
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```