crates/veilvoice-gui/src/storage.rs
what this file is for · veilvoice-gui · 659 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Where veiled recordings are written, and the encrypted volume that may hold
//! them.
//!
//! **Roadmap items 82, 83 and 84.** [`veilvoice_setup::volumes`] finds what is
//! mounted; this decides what to do about it, remembers the answer, and refuses
//! to write anywhere the user has not confirmed.
//!
//! # A destination, not a mode
//!
//! With no destination chosen, a veiled recording lands beside the file it came
//! from, which is what VeilVoice has always done. With one chosen, it lands in
//! that directory instead, under the same name. That is the whole of the
//! feature: the encryption belongs entirely to Cryptomator or VeraCrypt, and
//! nothing here adds any of its own or claims to.
//!
//! # Why a chosen destination can still be refused
//!
//! Because of VeraCrypt's hidden volumes, and the refusal is the point rather
//! than an inconvenience. [`veilvoice_setup::volumes::Hidden`] carries the
//! whole argument; the short version is that writing into the outer volume of
//! a container that has a hidden one can destroy the hidden data, nothing can
//! tell the two apart from outside, and so the only safe behaviour is to ask
//! the person who knows and to write nothing until they have answered.
//!
//! A destination whose question is unanswered is *not* silently downgraded to
//! writing beside the source. It blocks the job and says why. Falling back
//! quietly would put a veiled recording somewhere unencrypted while its owner
//! believed it was in a vault, which is the exact failure this exists to
//! prevent.
//!
//! # Roadmap item 84: detection will fail, and that is planned for
//!
//! Portable installs, custom mount points, a platform neither tool supports.
//! The answer is not a silent fallback: it is a directory the user picks by
//! hand, a declaration of what kind of volume it is, and the same confirmation
//! before anything is written. A hand-picked destination is treated exactly
//! like a detected one, including the hidden-volume question.
//!
//! # In plain words
//!
//! Lets you send every veiled recording straight into a Cryptomator or
//! VeraCrypt folder, instead of leaving it next to the original.
//!
//! If VeilVoice cannot find your folders it asks you to point at one. Either
//! way it asks, once, whether a VeraCrypt container has a hidden volume in it,
//! and will not write anything until you answer, because writing into the
//! wrong half of one of those can destroy what is hidden inside.
use std::path::{Path, PathBuf};
use veilvoice_setup::volumes::{self, Hidden, Tool, Volume};
/// The chosen place for veiled output, if there is one.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Destination {
/// The volume, once one has been chosen.
pub volume: Option<Volume>,
}
impl Destination {
/// Rebuild a destination from what was written to the settings file.
///
/// Anything unrecognised produces no destination rather than a guess. A
/// settings file that has been edited into nonsense must not decide where
/// recordings go.
pub fn from_prefs(dir: &str, tool: &str, hidden: &str) -> Self {
if dir.is_empty() {
return Self::default();
}
let Some(tool) = Tool::from_key(tool) else {
return Self::default();
};
Self {
volume: Some(Volume {
path: PathBuf::from(dir),
tool,
hidden: hidden_from_key(hidden),
}),
}
}
/// The three strings the settings file keeps.
pub fn to_prefs(&self) -> (String, String, String) {
match &self.volume {
None => (String::new(), String::new(), String::new()),
Some(v) => (
v.path.display().to_string(),
v.tool.key().to_string(),
hidden_key(v.hidden).to_string(),
),
}
}
/// Whether a job may start.
///
/// True when nothing is chosen, because writing beside the source is
/// VeilVoice's ordinary behaviour and needs no permission.
pub fn ready(&self) -> bool {
self.volume.as_ref().is_none_or(Volume::ready)
}
/// Why a job may not start, for the button's tooltip.
pub fn blocked(&self) -> Option<&'static str> {
self.volume.as_ref().and_then(Volume::blocked)
}
/// Where a recording should be written, given where it would have gone.
///
/// Keeps the file name and replaces the directory. Returns `default`
/// untouched when nothing is chosen, and also when the destination is not
/// ready: a caller that ignores [`Destination::ready`] must not be handed a
/// vault path it was never cleared to use.
///
/// # F-95, and why `mounts` is an argument rather than a cached flag
///
/// F-93 fixed the *panel*, which had asked whether the folder existed when
/// the question is whether anything is mounted on it. It did not fix this,
/// and this is where the file is actually written.
///
/// A destination is chosen while the vault is open and answered for. Some
/// time later the vault is locked, which leaves its mount point behind as
/// an empty directory, and nothing about the destination changes: the
/// hidden-volume answer is still given, so `ready` is still true. Veiling a
/// recording then wrote it into a bare directory on the ordinary disk while
/// its owner believed it had gone into the vault.
///
/// That is F-93's failure arriving through a different door, which is the
/// pattern this project has now recorded three times: a fix applied to the
/// instance that was found rather than to the class. The mount list is
/// therefore passed in and consulted here, at the moment of writing, rather
/// than read once and remembered.
pub fn place(&self, default: &Path, mounts: &[Volume]) -> PathBuf {
let Some(volume) = self
.volume
.as_ref()
.filter(|v| v.ready() && self.still_mounted(mounts))
else {
return default.to_path_buf();
};
match default.file_name() {
Some(name) => volume.path.join(name),
// A path with no file name is not something to be clever about.
None => default.to_path_buf(),
}
}
/// Whether the volume is still mounted, judged against `mounts`.
///
/// **F-93.** The first version of this asked whether the directory existed,
/// which is the wrong question in the one direction that matters:
/// unmounting a volume leaves its mount point behind as an ordinary empty
/// directory. A locked vault therefore looked fine, and VeilVoice would
/// have written a veiled recording onto the unencrypted disk while its
/// owner believed it had gone inside. See
/// [`veilvoice_setup::volumes::covers`].
pub fn still_mounted(&self, mounts: &[Volume]) -> bool {
self.volume
.as_ref()
.is_none_or(|v| volumes::covers(mounts, &v.path))
}
}
/// Everything the window shows about encrypted storage.
pub struct Storage {
/// The chosen destination.
pub destination: Destination,
/// What was mounted when this was last refreshed.
found: Vec<Volume>,
/// Whether either tool appears installed, and the evidence.
presence: Vec<(Tool, String, bool)>,
/// The folder picker, while it is open.
choosing: crate::dialog::Pending,
/// Which tool a hand-picked folder was declared to be.
hand_picked_tool: Tool,
/// Whether the chosen folder was there at the last refresh.
///
/// Cached rather than recomputed each frame. Deciding it means reading the
/// mount table, and doing that sixty times a second for an answer that
/// changes when somebody unlocks a volume is the shape of stutter the draw
/// path already refuses in `app.rs`. Writing it into a new module would
/// have been the same defect in a place that guard test does not look.
present: bool,
}
impl Default for Storage {
fn default() -> Self {
Self {
destination: Destination::default(),
found: Vec::new(),
presence: Vec::new(),
choosing: crate::dialog::Pending::default(),
hand_picked_tool: Tool::Cryptomator,
present: true,
}
}
}
impl Storage {
/// Look again at what is installed and mounted.
///
/// Called when the tab is opened rather than every frame: it reads the
/// mount table, which is cheap but is still a file read, and the draw path
/// does none. See the guard test in `app.rs`.
pub fn refresh(&mut self) {
self.found = volumes::mounted();
self.present = self.destination.still_mounted(&self.found);
self.presence = volumes::Tool::ALL
.iter()
.map(|tool| {
let presence = volumes::installed(*tool);
(*tool, presence.describe(), presence.is_present())
})
.collect();
}
/// What was found at the last refresh.
pub fn found(&self) -> &[Volume] {
&self.found
}
/// Whether anything was found at all, which decides whether roadmap item 84's
/// guided path is the main offer or the fallback.
pub fn found_nothing(&self) -> bool {
self.found.is_empty()
}
/// Whether the chosen folder was there when this was last refreshed.
pub fn present(&self) -> bool {
self.present
}
/// Take a folder the user picked by hand, if the picker has answered.
pub fn take_hand_picked(&mut self) {
if let Some(path) = self.choosing.taken() {
self.destination = Destination {
volume: Some(Volume::found(path, self.hand_picked_tool)),
};
self.present = self.destination.still_mounted(&self.found);
}
}
/// Start the folder picker for roadmap item 84's guided path.
pub fn pick_by_hand(&mut self, tool: Tool) {
self.hand_picked_tool = tool;
self.choosing.start(crate::dialog::Ask::Folder);
}
/// Choose one of the detected volumes.
pub fn choose(&mut self, volume: Volume) {
self.destination = Destination {
volume: Some(volume),
};
self.present = self.destination.still_mounted(&self.found);
}
/// Go back to writing beside the source file.
pub fn clear(&mut self) {
self.destination = Destination::default();
self.present = true;
}
/// Which tool a hand-picked folder is being declared as.
pub fn hand_picked_tool(&self) -> Tool {
self.hand_picked_tool
}
/// Whether either tool was detected, for the guided text.
pub fn presence(&self) -> &[(Tool, String, bool)] {
&self.presence
}
}
/// The encrypted-storage panel, drawn on the security tab.
///
/// Returns true when the chosen destination changed, which is the window's cue
/// to have it remembered.
pub fn panel(storage: &mut Storage, ui: &mut egui::Ui) -> bool {
use crate::theme::palette as p;
use egui::RichText;
let before = storage.destination.clone();
storage.take_hand_picked();
ui.add_space(16.0);
ui.separator();
ui.label(
RichText::new("Where recordings go")
.color(p::blue())
.small(),
);
// Read out first, so the closures below borrow the strings rather than the
// struct they came from.
let chosen = storage
.destination
.volume
.as_ref()
.map(|v| (v.path.display().to_string(), v.tool));
match chosen {
None => {
ui.label(RichText::new("beside the file they came from").color(p::muted()));
}
Some((shown, tool)) => {
let mut drop_it = false;
ui.horizontal(|ui| {
ui.label(RichText::new(shown).color(p::cyan()));
ui.label(RichText::new(tool.name()).color(p::muted()).small());
if ui.button("use the ordinary folder again").clicked() {
drop_it = true;
}
});
if drop_it {
storage.clear();
}
if !storage.present() {
ui.label(
RichText::new(
"that folder is not there now. If the volume is locked, unlock it \
in its own program before veiling anything, or VeilVoice will \
write outside it.",
)
.color(p::red()),
);
}
if let Some(why) = storage.destination.blocked() {
ui.label(RichText::new(why).color(p::yellow()));
}
}
}
// Roadmap item 83. Asked for VeraCrypt and never for Cryptomator, and asked
// before anything is written rather than after something goes wrong.
let needs_answer = storage
.destination
.volume
.as_ref()
.is_some_and(|v| v.tool == Tool::VeraCrypt);
if needs_answer {
ui.add_space(6.0);
ui.label(
RichText::new(
"Does this container have a hidden volume inside it? VeilVoice cannot \
tell, and nothing can: that is what hidden volumes are for. Writing \
into the outer volume of a pair can destroy what is hidden in it.",
)
.color(p::fg()),
);
let current = storage
.destination
.volume
.as_ref()
.map(|v| v.hidden)
.unwrap_or(Hidden::Unanswered);
let mut choice = current;
ui.horizontal_wrapped(|ui| {
ui.selectable_value(&mut choice, Hidden::NoHiddenVolume, "no hidden volume");
ui.selectable_value(
&mut choice,
Hidden::IsTheHiddenVolume,
"this is the hidden one",
);
ui.selectable_value(
&mut choice,
Hidden::OuterVolumeOfAHiddenPair,
"this is the outer one",
);
});
if choice != current {
storage.answer_hidden(choice);
}
}
ui.add_space(8.0);
if ui.button("look again").clicked() {
storage.refresh();
}
for volume in storage.found().to_vec() {
ui.horizontal(|ui| {
ui.label(RichText::new(volume.path.display().to_string()).color(p::fg()));
ui.label(RichText::new(volume.tool.name()).color(p::muted()).small());
if ui.button("write here").clicked() {
storage.choose(volume.clone());
}
});
}
// Roadmap item 84. The guided path, which is the main offer when nothing was
// found rather than a footnote under a list of nothing.
if storage.found_nothing() {
ui.label(
RichText::new("no mounted Cryptomator vault or VeraCrypt volume was found")
.color(p::muted()),
);
for (tool, evidence, present) in storage.presence().to_vec() {
ui.label(
RichText::new(format!("{}: {evidence}", tool.name()))
.color(if present { p::green() } else { p::muted() })
.small(),
);
}
ui.label(
RichText::new(
"If one is open and VeilVoice did not see it, point at it by hand. \
VeilVoice never opens or closes these itself and never asks for their \
password: unlock it in its own program first.",
)
.color(p::muted())
.small(),
);
ui.horizontal(|ui| {
for tool in Tool::ALL {
if ui
.button(format!("choose a {} folder…", tool.name()))
.clicked()
{
storage.pick_by_hand(*tool);
}
}
});
}
ui.add_space(6.0);
ui.label(
RichText::new(volumes::DISK_ADVICE)
.color(p::muted())
.small(),
);
storage.destination != before
}
#[cfg(test)]
mod tests {
use super::*;
/// The mount list that holds the volume `vault` builds.
fn mounted() -> Vec<Volume> {
vec![Volume::found(
PathBuf::from("/media/veracrypt1"),
Tool::VeraCrypt,
)]
}
fn vault(tool: Tool, hidden: Hidden) -> Destination {
Destination {
volume: Some(Volume {
path: PathBuf::from("/media/veracrypt1"),
tool,
hidden,
}),
}
}
/// The panel draws every frame, so nothing in it may touch the disk.
/// Deciding whether the vault is mounted means reading the mount table, and
/// the first version of this module did it from the panel: sixty reads a
/// second for an answer
/// that changes when somebody unlocks a volume. `app.rs` already refuses
/// this in its own draw path; a new module is where that guard does not
/// look, which is exactly why it is worth repeating here.
#[test]
fn the_panel_asks_the_disk_nothing() {
let source = include_str!("storage.rs").replace("\r\n", "\n");
let start = source.find("pub fn panel(").expect("the panel exists");
let end = source[start..]
.find("\n#[cfg(test)]")
.map(|at| start + at)
.unwrap_or(source.len());
let body: String = source[start..end]
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
for forbidden in [
"still_mounted(",
"try_exists",
".exists()",
"read_to_string",
"std::fs::",
] {
assert!(
!body.contains(forbidden),
"the panel calls {forbidden:?}, which asks the disk once per frame"
);
}
}
#[test]
fn no_destination_writes_where_veilvoice_always_did() {
let d = Destination::default();
assert!(d.ready(), "the ordinary path needs no permission");
assert!(d.blocked().is_none());
assert_eq!(
d.place(Path::new("/home/u/talk.veiled.wav"), &[]),
Path::new("/home/u/talk.veiled.wav")
);
}
#[test]
fn a_chosen_vault_keeps_the_file_name_and_replaces_the_folder() {
let d = vault(Tool::VeraCrypt, Hidden::NoHiddenVolume);
assert_eq!(
d.place(Path::new("/home/u/talk.veiled.wav"), &mounted()),
Path::new("/media/veracrypt1/talk.veiled.wav")
);
}
/// F-95. F-93 fixed the panel; this is where the file is written.
///
/// A vault chosen and answered for, then locked, leaves its mount point
/// behind as an empty directory and nothing about the destination changes.
/// Writing there puts a veiled recording on the ordinary disk while its
/// owner believes it went inside.
#[test]
fn a_vault_locked_since_it_was_chosen_never_receives_a_file() {
let d = vault(Tool::VeraCrypt, Hidden::NoHiddenVolume);
assert!(d.ready(), "the hidden question is answered, so it is ready");
assert_eq!(
d.place(Path::new("/home/u/talk.veiled.wav"), &[]),
Path::new("/home/u/talk.veiled.wav"),
"an answered destination that is no longer mounted must not be used"
);
}
/// Roadmap item 83's whole point, from the other side: a caller that forgets to
/// check `ready` must not be handed the vault path anyway.
#[test]
fn an_unanswered_destination_never_yields_a_vault_path() {
let d = vault(Tool::VeraCrypt, Hidden::Unanswered);
assert!(!d.ready());
assert!(d.blocked().is_some());
assert_eq!(
d.place(Path::new("/home/u/talk.veiled.wav"), &mounted()),
Path::new("/home/u/talk.veiled.wav"),
"an unanswered destination must not place a file inside it"
);
}
#[test]
fn the_outer_volume_of_a_hidden_pair_is_refused_here_too() {
let d = vault(Tool::VeraCrypt, Hidden::OuterVolumeOfAHiddenPair);
assert!(!d.ready());
assert!(d.blocked().expect("a reason").contains("destroy"));
}
#[test]
fn a_cryptomator_vault_needs_no_answer() {
let d = vault(Tool::Cryptomator, Hidden::Unanswered);
assert!(d.ready());
assert_eq!(
d.place(Path::new("/home/u/talk.veiled.wav"), &mounted()),
Path::new("/media/veracrypt1/talk.veiled.wav")
);
}
#[test]
fn a_destination_round_trips_through_the_settings_file() {
let original = vault(Tool::VeraCrypt, Hidden::IsTheHiddenVolume);
let (dir, tool, hidden) = original.to_prefs();
assert_eq!(Destination::from_prefs(&dir, &tool, &hidden), original);
let none = Destination::default();
let (dir, tool, hidden) = none.to_prefs();
assert_eq!(Destination::from_prefs(&dir, &tool, &hidden), none);
}
/// A settings file somebody edited must not be able to answer the one
/// question whose wrong answer destroys data.
#[test]
fn a_nonsense_settings_file_leaves_the_question_unanswered() {
let d = Destination::from_prefs("/media/veracrypt1", "veracrypt", "definitely-fine");
assert_eq!(
d.volume.as_ref().expect("a volume").hidden,
Hidden::Unanswered
);
assert!(!d.ready());
// And an unrecognised tool produces no destination at all.
assert_eq!(
Destination::from_prefs("/media/x", "bitlocker", "none"),
Destination::default()
);
}
/// A vault that has been locked since it was chosen is a directory that is
/// no longer encrypted, or is gone.
/// F-93. A locked vault leaves its mount point behind as an ordinary
/// directory, so existence says yes when the honest answer is no.
#[test]
fn a_vault_that_is_no_longer_mounted_is_noticed() {
let d = vault(Tool::VeraCrypt, Hidden::NoHiddenVolume);
let mounted = vec![Volume::found(
PathBuf::from("/media/veracrypt1"),
Tool::VeraCrypt,
)];
assert!(d.still_mounted(&mounted));
// The directory can still be there. Nothing is mounted on it.
let empty: Vec<Volume> = Vec::new();
assert!(
!d.still_mounted(&empty),
"an unmounted volume must not read as present just because its \
mount point is still a directory"
);
// No destination is always fine: writing beside the source needs no
// volume at all.
assert!(Destination::default().still_mounted(&empty));
}
/// A folder chosen inside a mounted vault is inside it.
#[test]
fn a_folder_within_a_mounted_vault_counts_as_mounted() {
let inside = Destination {
volume: Some(Volume {
path: PathBuf::from("/home/u/Vault/recordings"),
tool: Tool::Cryptomator,
hidden: Hidden::Unanswered,
}),
};
let mounted = vec![Volume::found(
PathBuf::from("/home/u/Vault"),
Tool::Cryptomator,
)];
assert!(inside.still_mounted(&mounted));
}
}