crates/veilvoice-cli/src/main.rs
what this file is for · veilvoice-cli · 3461 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
//! `veilvoice`, the command-line interface.
//!
//! Everything VeilVoice does, available without a desktop: it runs over SSH, in
//! a container, and on machines that have no GUI toolkit at all. The same
//! engine backs both this and the graphical app.
//!
//! # What is here
//!
//! Twenty subcommands, and they divide into five groups:
//!
//! * **Audio** -- `anonymise` a file, `live` scramble a microphone, list
//! `devices`, `conversation` for a recording with several people in it.
//! * **Privacy of the files themselves** -- `clean` metadata, `encrypt`,
//! `decrypt`, `keygen`, `shred`.
//! * **Watching the machine** -- `watch` the microphone and camera, `guard`
//! VeilVoice's own files against tampering, `sentry` for canaries and how
//! fast a folder is changing, `capture` for which screen recorders are
//! running.
//! * **The app lock** -- `lock set|status|change|remove`, and `policy` for
//! settings somebody has fixed so the interface cannot turn them off.
//! * **Getting it onto the machine** -- `install`, `uninstall`, `companions`,
//! and `gui` to open the desktop application.
//!
//! That last group is a front end over [`veilvoice_setup`], which the desktop
//! application's setup tab also calls. The careful part -- editing `PATH` --
//! has one implementation and one set of tests, rather than one per front
//! end.
//!
//! # Two behaviours that surprise people, on purpose
//!
//! **`anonymise` writes `<out>.veil`, not a bare WAV.** Recordings are
//! encrypted at rest by default. `--encrypt=false` opts out and requires
//! `--yes`, because an unsealed recording is the thing somebody later wishes
//! they had not produced. The wiki explains where the WAV went.
//!
//! **The front-ends refuse rather than downgrade.** Asked to encrypt with
//! nothing to encrypt with, this exits with an error instead of writing plain
//! audio and mentioning it. Quiet degradation to a weaker posture is the defect
//! class this project has found in itself most often.
//!
//! # Passphrase prompts cannot be piped
//!
//! `rpassword` needs a real console; piping a passphrase in blocks on
//! `CONIN$` rather than reading it. That is a property of terminal input, not a
//! bug here, and it means anything that prompts cannot be smoke-tested from a
//! non-interactive shell. The layer *beneath* each prompt is therefore tested
//! instead -- see [`crate::atrest`] and [`crate::lock`], where the logic lives
//! precisely so it can be reached without a terminal.
//!
//! # A clap ordering rule worth knowing
//!
//! An argument declared beside `#[command(subcommand)]` must precede the
//! subcommand on the command line unless it is marked `global = true`. So
//! `veilvoice lock --path X status` parses and `veilvoice lock status --path X`
//! does not, except that `--path` is now global specifically so both do.
//!
//! # In plain words
//!
//! This is VeilVoice without a window.
//!
//! Everything the program does, typed instead of clicked: disguise a recording,
//! scramble a microphone while you talk, seal a file, strip a photograph's hidden
//! labels, handle a recording with several people in it.
//!
//! It is the same code underneath, so it works the same way -- over a remote
//! connection, on a machine with no desktop, or from a script that runs it a
//! thousand times.
#![forbid(unsafe_code)]
mod accel;
mod appctl;
mod atrest;
mod capture;
mod conversation;
mod decoy;
mod failsafe;
mod guard;
mod gui;
mod input;
mod lock;
mod mandate;
mod priv_mode;
#[cfg(feature = "live")]
mod record;
// Only the live path draws a meter, and the crate builds without that path on
// the BSDs, where `cpal` has no backend.
#[cfg(feature = "live")]
mod meter;
mod policy;
mod sentry;
mod theme;
use atrest::{prompt_secret, read_new_password};
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
use std::process::ExitCode;
use theme::{colour, err, field, heading, ok, paint, warn};
#[cfg(feature = "live")]
use veilvoice_audio::devices;
use veilvoice_audio::io as audio_io;
use veilvoice_core::{AccentConfig, DeidConfig};
use veilvoice_crypto::{container, hybrid, kdf};
use veilvoice_guard::sentry::rate::{Limits, Threshold};
use veilvoice_meta::Policy;
use veilvoice_policy::Requirement;
use veilvoice_setup::{companions, install};
#[derive(Parser)]
#[command(
name = "veilvoice",
version,
about = "Irreversible voice de-identification, fully offline.",
long_about = "VeilVoice destroys the biometric voiceprint of a speaker: the \
pitch, the formants, the timbre and the melody of an accent. It keeps the words clean and \
transcribable. This command line talks to no servers, ever: the one thing in VeilVoice that reaches the network is the desktop app's check-for-updates button, and it is not here."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// De-identify an audio file and write a WAV.
Anonymise {
/// Audio file to read (wav, mp3, flac, ogg, m4a, ...).
input: PathBuf,
/// Where to write the result. Defaults to `<input>.veiled.wav`.
#[arg(short, long)]
output: Option<PathBuf>,
/// How far pitch and formants are pushed from the original, 0.0–1.0.
#[arg(long, default_value_t = 1.0)]
intensity: f32,
/// Keep the speaker's accent and intonation intact.
#[arg(long)]
keep_accent: bool,
/// Seconds between rolls of the modulation seed. 0 keeps one stream
/// for the whole session.
#[arg(long, default_value_t = 2.0)]
reseed_secs: f32,
/// Draw each gap from a range instead, in milliseconds: `250,1800`.
///
/// A fixed interval is a fixed thing to observe. With a range, the gap
/// before every roll is drawn fresh from the modulation stream, so the
/// ratchet has no period at all.
///
/// Without this, the range is **drawn from the operating system's
/// random source at launch**, so it is a property of this run rather
/// than a number compiled into every copy of VeilVoice. Pass
/// `--reseed-range fixed` to use `--reseed-secs` instead.
///
/// A value that is not a usable range is **refused with the reason**,
/// never adjusted to fit.
#[arg(long)]
reseed_range: Option<String>,
/// Also strip metadata from the written file.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
clean_metadata: bool,
/// Encrypt the result at rest. On by default: the words survive
/// de-identification on purpose, so an unencrypted result is still a
/// recording of everything that was said.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
encrypt: bool,
/// Seal to a recipient's public key file instead of a passphrase.
#[arg(long, value_name = "PUBKEY")]
encrypt_to: Option<PathBuf>,
/// Skip the confirmation when writing an unencrypted recording.
#[arg(long)]
yes: bool,
},
/// Scramble a microphone live, into a device or a virtual cable.
#[cfg(feature = "live")]
Live {
/// Input device name. Defaults to the system default.
#[arg(short, long)]
input: Option<String>,
/// Output device name. Defaults to a virtual cable if one is found.
#[arg(short, long)]
output: Option<String>,
/// How far pitch and formants are pushed from the original, 0.0–1.0.
#[arg(long, default_value_t = 1.0)]
intensity: f32,
/// Keep the speaker's accent and intonation intact.
#[arg(long)]
keep_accent: bool,
/// Seconds between rolls of the modulation seed. 0 keeps one stream
/// for the whole session.
#[arg(long, default_value_t = 2.0)]
reseed_secs: f32,
/// Draw each gap from a range instead, in milliseconds: `250,1800`.
///
/// A fixed interval is a fixed thing to observe. With a range, the gap
/// before every roll is drawn fresh from the modulation stream, so the
/// ratchet has no period at all.
///
/// Without this, the range is **drawn from the operating system's
/// random source at launch**, so it is a property of this run rather
/// than a number compiled into every copy of VeilVoice. Pass
/// `--reseed-range fixed` to use `--reseed-secs` instead.
///
/// A value that is not a usable range is **refused with the reason**,
/// never adjusted to fit.
#[arg(long)]
reseed_range: Option<String>,
/// Listen to yourself veiled, instead of sending it anywhere.
///
/// Routes the veiled voice to this machine's **default output** rather
/// than to a virtual cable, so it goes to your headphones and to
/// nothing else. This is the way to find out what you sound like, and
/// that the microphone is the one you meant, before an interview
/// starts rather than during it.
///
/// Use headphones. Speakers plus a microphone is a feedback loop.
#[arg(long)]
preview: bool,
/// Do not draw the level meters.
///
/// The meters are on by default because the two questions in a live
/// session are "is it hearing me" and "is anything coming out", and a
/// bar answers both at a glance. This turns them off for a terminal
/// that is being logged or read by something other than a person.
#[arg(long)]
no_monitor: bool,
},
/// Record yourself already veiled, straight into an encrypted file.
///
/// The third case beside `live` and `anonymise`. `live` veils and keeps
/// nothing; `anonymise` veils a recording that already exists, in the
/// clear, on your disk. This keeps the result and never creates the
/// original: the only file it ever writes is the encrypted one.
///
/// Stops when you press Enter, or after `--seconds`. Ctrl-C abandons the
/// recording and keeps nothing, which is how you throw one away.
#[cfg(feature = "live")]
Record {
/// Input device name. Defaults to the system default.
#[arg(short, long)]
input: Option<String>,
/// Device to monitor on. Defaults to the system default output.
#[arg(short, long)]
output: Option<String>,
/// Stop after this many seconds instead of waiting for Enter.
#[arg(long)]
seconds: Option<f32>,
/// Where to write it. Defaults to a timestamped name here.
#[arg(long)]
to: Option<PathBuf>,
/// Seal to a recipient's public key instead of a passphrase.
#[arg(long, value_name = "KEY")]
to_public_key: Option<PathBuf>,
/// Write it unencrypted. Asks first.
#[arg(long)]
plaintext: bool,
/// Answer that question with yes.
#[arg(long)]
yes: bool,
/// How far pitch and formants are pushed from the original, 0.0-1.0.
#[arg(long, default_value_t = 1.0)]
intensity: f32,
/// Keep the speaker's accent and intonation intact.
#[arg(long)]
keep_accent: bool,
/// Seconds between rolls of the modulation seed.
#[arg(long, default_value_t = 2.0)]
reseed_secs: f32,
/// Draw each gap from a range instead, in milliseconds: `250,1800`.
#[arg(long)]
reseed_range: Option<String>,
/// Do not draw the level meters.
#[arg(long)]
no_monitor: bool,
},
/// List the audio devices this machine offers.
#[cfg(feature = "live")]
Devices,
/// Strip identifying metadata from an audio or image file, in place.
Clean {
/// File to clean.
file: PathBuf,
/// Whether to leave plausible placeholder tags behind.
#[arg(long, value_enum, default_value_t = CleanPolicy::Strip)]
policy: CleanPolicy,
},
/// Encrypt a file into a `.veil` container.
Encrypt {
/// File to encrypt.
input: PathBuf,
/// Where to write the container. Defaults to `<input>.veil`.
#[arg(short, long)]
output: Option<PathBuf>,
/// Encrypt to a recipient's public key file instead of a password.
#[arg(long)]
to: Option<PathBuf>,
},
/// Decrypt a `.veil` container.
Decrypt {
/// Container to decrypt.
input: PathBuf,
/// Where to write the plaintext.
#[arg(short, long)]
output: PathBuf,
/// Private key file, when the container was sealed to a public key.
#[arg(long)]
key: Option<PathBuf>,
},
/// Generate a hybrid post-quantum key pair.
Keygen {
/// Where to write the public key.
#[arg(long, default_value = "veilvoice.pub")]
public: PathBuf,
/// Where to write the private key.
#[arg(long, default_value = "veilvoice.key")]
secret: PathBuf,
},
/// Record and check the integrity of VeilVoice's own files.
Guard {
#[command(subcommand)]
action: guard::Action,
/// Where the record is kept. Defaults to this platform's config
/// directory, beside the app lock.
#[arg(long, global = true)]
path: Option<PathBuf>,
},
/// Manage the application lock that guards the desktop app.
Lock {
#[command(subcommand)]
action: lock::Action,
/// Lock file to operate on. Defaults to this platform's config
/// directory. Global, so it reads naturally either side of the action.
#[arg(long, global = true)]
path: Option<PathBuf>,
},
/// Show which applications are using the microphone and camera.
Watch {
/// Print a snapshot and exit instead of watching continuously.
#[arg(long)]
once: bool,
/// Seconds between checks.
#[arg(long, default_value_t = 2.0)]
interval: f32,
},
/// Securely erase a file, then delete it. Irreversible.
Shred {
/// File to destroy.
file: PathBuf,
/// Overwrite passes (1-32).
#[arg(long, default_value_t = 3)]
passes: u8,
/// Skip the typed confirmation. For scripts that already mean it.
#[arg(long)]
yes: bool,
},
/// Show version and build information.
Info,
/// The graphics hardware here, and what it is good for.
///
/// Lists the devices, says which can encode video, and suggests one. It
/// also says, with the measurement behind it, why veiling a voice does not
/// use a graphics card: it is already about a hundred times faster than
/// real time, and moving that work onto a card would slow it down.
Accel,
/// Open the desktop application.
///
/// Runs `veilvoice-gui` from beside this program. It is a separate
/// executable rather than a mode of this one for a reason that cannot be
/// engineered around without `unsafe`: a Windows PE declares exactly one
/// subsystem. A console binary that opened a window would flash a console
/// every time -- which is precisely the defect v0.1.10 shipped -- and a
/// windowed binary would send this program's output nowhere when run from
/// a terminal. Switching at run time needs `AttachConsole`/`FreeConsole`,
/// which is FFI, and every crate here carries `#![forbid(unsafe_code)]`.
///
/// `veilvoice g` is the same command. It looks beside this program first,
/// then where an install puts it, then on your PATH -- and if it finds
/// nothing it says where it looked. The window opens and this terminal is
/// yours again immediately; closing it will not close the window.
#[command(alias = "g")]
Gui {
/// Open it without printing anything.
#[arg(long, short)]
quiet: bool,
},
/// Copy VeilVoice somewhere the system can find it, and add it to PATH.
///
/// Entirely optional: VeilVoice runs from wherever it is unpacked, and
/// nothing has to be installed. This exists so that typing `veilvoice` in
/// a terminal works. Per-user, no administrator, and everything it does is
/// undone by `veilvoice uninstall`.
Install {
/// Report what is installed, and change nothing.
#[arg(long)]
status: bool,
},
/// Undo what `install` did: the PATH entry, the uninstall entry, and the
/// installed copy.
Uninstall {
/// Do not ask for confirmation.
#[arg(long)]
yes: bool,
},
/// Settings fixed so the interface cannot turn them off.
///
/// **Every setting a policy can reach makes VeilVoice stricter.** There is
/// nothing here that turns protection off, no requirement that lowers the
/// de-identification floor, and no room in the format to write one. That is
/// why the policy is read at every launch without a passphrase: the worst
/// an edited policy file can do is restrict this machine further than its
/// owner intended, which is a nuisance rather than a privacy failure.
///
/// The passphrase seals a copy, so anybody who has it can prove the policy
/// in force is the one that was written. It is not enforcement: anything
/// that can replace VeilVoice's own executable can ignore all of this, and
/// anything running as you can delete the file.
Policy {
#[command(subcommand)]
what: PolicyCommand,
},
/// The two things VeilVoice insists on, unless you say otherwise.
///
/// By default it wants a password for itself and encrypts every recording
/// where it is stored. Both are on without anybody choosing them, because
/// both protect the same thing: de-identification takes the voiceprint out
/// of a recording and leaves every word that was said in it.
///
/// This is the one place in VeilVoice that can make it *less* strict, and
/// it is the opposite tool to `veilvoice policy`. A sealed policy is
/// somebody setting rules for somebody else and can only tighten; this is
/// your own baseline for your own machine, and you may relax it. Doing so
/// is written down with the date, so the choice is never a mystery later.
Mandate {
#[command(subcommand)]
what: MandateCommand,
},
/// A recording with several people in it: a voice each, and subtitles.
///
/// Run an interview through `anonymise` and both people come out as the
/// same voice -- private, and unusable, because nobody can tell a question
/// from its answer. This gives each speaker their own destination voice and
/// destroys each voiceprint just as thoroughly.
///
/// **You say who is talking.** Working that out from the audio needs a
/// trained model; there is none here and no server to ask, and a wrong
/// guess would either merge two people or invent a third without anything
/// in the output showing it. So the plan is a text file of turns, or one
/// microphone per person.
///
/// **What a conversation keeps** is the shape of the conversation: how many
/// people, who spoke when, and for how long. That is kept on purpose --
/// it is what makes the result worth listening to -- and it is information
/// about the conversation.
Conversation {
#[command(subcommand)]
what: ConversationCommand,
},
/// Which screen recorders are running, and which you meant to run.
///
/// **VeilVoice does not hide its own window from capture.** You can record
/// this application with OBS or anything else, deliberately, and nothing
/// here prevents it. Excluding a window from capture needs `unsafe` FFI,
/// which every crate in this workspace forbids, so the exclusion is not
/// built -- see ROADMAP.md.
///
/// What this does is tell you a recorder is running, once, and then stop
/// telling you if you say you meant it. A monitor that warns every thirty
/// seconds while you record a tutorial is a monitor you switch off, and
/// then it is not watching for the recorder you did *not* start.
///
/// Two things it cannot do. It only knows the programs in its table, so an
/// empty report is not evidence that nothing is recording. And it cannot
/// tell whether a program that is running is actually capturing anything --
/// a meeting application being open is not somebody watching your screen.
Capture {
#[command(subcommand)]
what: CaptureCommand,
},
/// A second passphrase that opens an empty VeilVoice, and its limits.
///
/// A decoy is a way to comply with somebody standing over you without
/// handing over your recordings. **It does not give you deniability**:
/// VeilVoice is open source and this feature is documented, so anybody who
/// recognises the program can ask you for the other passphrase.
///
/// **No passphrase destroys anything, deliberately.** On modern storage a
/// write does not overwrite, so a feature that claimed to would be lying to
/// you at the worst possible moment.
Decoy,
/// The safety catch: what it watches for, and what it cannot do.
///
/// Failsafe notices the moment another program picks up a **real**
/// microphone while you are being veiled -- the accident where you plug in
/// a headset and your computer quietly switches a call over to it, so your
/// own voice goes out with nothing on screen looking any different.
///
/// It is on by default. **It cannot stop your computer handing a
/// microphone over**; it notices within about a second and acts, and the
/// difference between those two things is printed every time.
Failsafe {
/// The device VeilVoice is veiling into, so a program on that cable is
/// not mistaken for the accident.
#[arg(long)]
veiling: Option<String>,
},
/// What VeilVoice is running with, and what that lets it see.
///
/// Most of VeilVoice needs no special permissions; the monitoring features
/// see further as an administrator. This reports which you are getting and
/// prints the command to run it the other way.
///
/// **It never raises its own privileges, installs a service, or asks for a
/// password.** Those are changes to your machine and they should be ones
/// you made on purpose.
Privilege,
/// Learn what normally runs here, then notice what does not.
///
/// Run `learn` while you work normally for a few days, `learn --finish` to
/// close the baseline, and `check` afterwards.
///
/// **It does not block anything and cannot.** It is a way of noticing, not
/// a lock on the door: a program it calls unknown is still running. Real
/// enforcement needs a kernel driver or a signed system policy, and neither
/// is something this project ships.
Appctl {
#[command(subcommand)]
what: AppctlCommand,
},
/// What running programs can see your keyboard and mouse.
///
/// Names the programs that are **able** to observe input -- remote-support
/// tools, macro recorders, password managers, screen readers -- and says
/// what each one is for. Nearly all of it is software you installed on
/// purpose.
///
/// It cannot tell you whether anything is logging your keystrokes, and
/// nothing can: the mechanisms a logger uses are the mechanisms
/// accessibility software uses, and software written to hide is written to
/// hide from this. **A result of nothing found does not mean nothing is
/// watching.** That sentence is printed with every result, not behind a
/// flag.
Input {
#[command(subcommand)]
what: Option<InputCommand>,
},
/// Canaries, and how fast a folder is changing.
///
/// Two early warnings that something is going through your files. Neither
/// stops anything, and neither names the program responsible.
///
/// A **canary** is a file VeilVoice writes and nothing reads. If it ever
/// changes, something walked that folder and wrote to everything in it. It
/// only fires if whatever is running reaches that folder, so a quiet canary
/// is not evidence that nothing happened.
///
/// A **baseline** records what a folder holds now, and `check` says how
/// much of it changed since and how fast. That number cannot tell
/// ransomware from a backup restore, a photo import or a compiler, so it is
/// reported against thresholds you set rather than as a verdict.
Sentry {
#[command(subcommand)]
what: SentryCommand,
},
/// Optional third-party software VeilVoice works with, and whether this
/// machine already has it.
///
/// Nothing here is part of VeilVoice and nothing here is needed to run it.
/// A virtual audio cable is what lets live mode feed a veiled microphone
/// into a call; an audio editor is how most people trim a recording first.
///
/// With no arguments this only *reports*. `--install NAME` is the explicit
/// yes, one named program at a time, and even then VeilVoice will not run
/// somebody else's installer: for proprietary software it prints the
/// vendor's page, and for anything needing root it prints the command
/// rather than asking for a password.
Companions {
/// Install one, by name. Without this, nothing is installed.
#[arg(long, value_name = "NAME")]
install: Option<String>,
},
/// Check a download, and what does the checking
///
/// With nothing after it, this finds a release near you and checks all of
/// it: the signature over the hash list, every archive against that list,
/// the contents manifest, and every file you extracted, one by one. That
/// is the whole of what used to be a separate `veilvoice-verify` program,
/// which no longer ships because this is where it belonged.
///
/// `veilvoice verify --help` lists the rest: `key`, `sums`, `file`,
/// `gnupg`, `release`, and the build-it-yourself half -- `deps`, `build`
/// and `reproduce`. They are printed below the flags, from the verifier's
/// own help, so the two cannot disagree.
///
/// `--how` explains how a release is verified and what GnuPG adds that
/// VeilVoice cannot add for itself.
///
/// `--script` writes a short shell script that does the check with `gpg`
/// and `sha256sum` and nothing from this project. That is the point of it.
/// The program telling you a download is genuine came out of that
/// download, so the check worth most is the one made by software this
/// project did not write.
#[command(after_long_help = veilvoice_verify::help_text())]
Verify {
/// Explain how a release is verified, without checking anything.
///
/// This was what `verify` printed with no arguments, before the
/// verifier was folded in and the useful default became doing the
/// check rather than describing it.
#[arg(long)]
how: bool,
/// Write the verification script to standard output instead.
#[arg(long)]
script: bool,
/// Write a script that rebuilds the release from source and compares
/// the result with the published binary. A stronger check than a
/// hash: a hash proves the file is the one whose hash was signed, and
/// says nothing about what is inside it.
#[arg(long)]
build_script: bool,
/// Which system the script is for: linux, macos, bsd or windows.
/// Defaults to this one.
#[arg(long, value_name = "SYSTEM")]
system: Option<String>,
/// The macOS spelling of the verification script, which uses `shasum`
/// rather than `sha256sum`. A shorthand for `--system macos`.
#[arg(long)]
macos: bool,
/// A verifier command and its arguments, passed through untouched.
///
/// Not modelled here on purpose: the verifier has its own parser, its
/// own help and its own documented exit statuses, and a second parser
/// in this file is how the two drift apart.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Turn a veiled recording into a video with a black picture
///
/// For posting somewhere that will not accept an audio file. The picture is
/// black for the length of the recording and is not the point.
///
/// Needs `ffmpeg`, which VeilVoice does not ship and will not install: it
/// prints the exact command when the tool is not there.
Video {
/// The veiled recording to put in it.
audio: PathBuf,
/// Where to write the video. Defaults to the recording's name with
/// `.mp4`.
#[arg(short, long)]
output: Option<PathBuf>,
/// Frame size: monitor, 720p, 1080p, 1440p, 4k, or 1920x1080.
///
/// `monitor` matches the display this is running on where the platform
/// will say what that is, and says so and uses 1080p where it will not,
/// which on a machine with no display server is the ordinary case.
#[arg(long, default_value = "monitor")]
size: String,
/// Frames per second, from 5 to 60.
#[arg(long, default_value_t = 30)]
fps: u32,
/// Print the command rather than running it.
#[arg(long)]
dry_run: bool,
},
/// Take the sound out of a recording made somewhere else
///
/// OBS and everything like it write containers holding a video stream and
/// an audio stream. VeilVoice reads audio, so this pulls the audio out into
/// a WAV that `veilvoice anonymise` can take.
///
/// Needs `ffmpeg`, for the same reason and with the same behaviour.
Import {
/// The recording to take the sound out of.
source: PathBuf,
/// Where to write the WAV. Defaults to the source's name with `.wav`.
#[arg(short, long)]
output: Option<PathBuf>,
/// Print the command rather than running it.
#[arg(long)]
dry_run: bool,
},
/// Encrypted volumes this machine has: Cryptomator and VeraCrypt
///
/// Reports what is installed and what is mounted right now. It never opens,
/// closes or unlocks anything, and never asks for a volume password:
/// mounting your encrypted storage is your act, taken in the tool you
/// chose.
Volumes,
}
/// What `veilvoice conversation` can do.
impl From<FixCommand> for conversation::Fix {
fn from(command: FixCommand) -> Self {
match command {
FixCommand::Reassign { at, to } => conversation::Fix::Reassign { at, to },
FixCommand::Split { at } => conversation::Fix::Split { at },
FixCommand::Merge { at, with } => conversation::Fix::Merge { at, with },
FixCommand::Move { at, start, end } => conversation::Fix::Move { at, start, end },
FixCommand::Name { speaker, to } => conversation::Fix::Name { speaker, to },
// `--clear` and `--to` cannot both be given: clap refuses that
// pair, so `clear` here means the colour is being taken away.
FixCommand::Colour { speaker, to, clear } => conversation::Fix::Colour {
speaker,
to: if clear { None } else { to },
},
}
}
}
/// The corrections `veilvoice conversation fix` can make.
#[derive(Subcommand)]
enum FixCommand {
/// Give the stretch of audio at a moment to a different speaker.
///
/// The wrong-voice fix. The timing is untouched.
Reassign {
/// A moment inside the stretch that is wrong, in seconds.
#[arg(long)]
at: f64,
/// Which speaker it should be, numbered from zero.
#[arg(long)]
to: usize,
},
/// Cut the stretch at a moment in two.
///
/// For a hand-over that was missed, where one stretch holds two people.
/// Both halves keep the speaker, so follow this with `reassign` on
/// whichever half was wrong. The words stay with the first half: there is
/// no way to know where in a sentence a moment falls.
Split {
/// Where to cut, in seconds.
#[arg(long)]
at: f64,
},
/// Join two neighbouring stretches of the same speaker.
///
/// For when a split was wrong, or a detector chopped one sentence into
/// three. They have to be the same speaker and to touch.
Merge {
/// A moment inside the first stretch, in seconds.
#[arg(long)]
at: f64,
/// A moment inside the second, in seconds.
#[arg(long)]
with: f64,
},
/// Move where a stretch starts and ends.
///
/// For a hand-over caught a second late.
Move {
/// A moment inside the stretch to move, in seconds.
#[arg(long)]
at: f64,
/// The new start, in seconds.
#[arg(long)]
start: f64,
/// The new end, in seconds.
#[arg(long)]
end: f64,
},
/// Change what a speaker is called.
///
/// A label for a human and **not** part of the de-identification: writing
/// somebody's real name here puts their real name in the subtitles.
Name {
/// Which speaker, numbered from zero.
#[arg(long)]
speaker: usize,
/// The new name.
#[arg(long)]
to: String,
},
/// Change the colour a speaker is drawn in.
///
/// `#rrggbb`. Also not part of the de-identification: the colour somebody
/// always uses is as identifying as their name.
Colour {
/// Which speaker, numbered from zero.
#[arg(long)]
speaker: usize,
/// The colour, as `#rrggbb`.
#[arg(long, conflicts_with = "clear")]
to: Option<String>,
/// Go back to the colour their slot gets from the palette.
#[arg(long)]
clear: bool,
},
}
#[derive(Subcommand)]
enum ConversationCommand {
/// Describe a plan: who is in it, which voice each gets, and any overlaps.
///
/// Reads the plan and nothing else. Worth doing before a long render.
Inspect {
/// The plan file.
plan: PathBuf,
},
/// Correct a plan before rendering it.
///
/// A stretch of audio given to the wrong person is the one mistake here
/// that **cannot be heard in the result**: every voice in a veiled
/// recording is unfamiliar, so a listener has nothing to compare against
/// and nobody notices. It has to be right before the render, which is what
/// these are for.
///
/// Every one of them rewrites the plan file in place and changes nothing
/// else. The audio is not touched and nothing is rendered.
Fix {
/// The plan file to correct.
plan: PathBuf,
#[command(subcommand)]
what: FixCommand,
},
/// Render a recording according to a plan.
///
/// Writes the audio and both subtitle formats. Audio that no turn claims
/// is **silenced**, never passed through -- it has not been veiled, and a
/// gap in a plan must not put a real voice into the result. How much went
/// is printed.
Render {
/// The plan file.
plan: PathBuf,
/// The recording.
input: PathBuf,
/// Where to write the audio. The subtitles take the same name.
#[arg(short, long)]
output: Option<PathBuf>,
/// 0..1 -- how far the transform pushes.
#[arg(long, default_value_t = 1.0)]
intensity: f32,
/// Leave the speaker's accent and intonation intact.
#[arg(long)]
keep_accent: bool,
/// Seconds between modulation seed rolls; 0 keeps one stream.
#[arg(long, default_value_t = 2.0)]
reseed_secs: f32,
/// Draw each gap from a range instead, in milliseconds: `250,1800`.
///
/// A fixed interval is a fixed thing to observe. With a range, the gap
/// before every roll is drawn fresh from the modulation stream, so the
/// ratchet has no period at all.
///
/// Without this, the range is **drawn from the operating system's
/// random source at launch**, so it is a property of this run rather
/// than a number compiled into every copy of VeilVoice. Pass
/// `--reseed-range fixed` to use `--reseed-secs` instead.
///
/// A value that is not a usable range is **refused with the reason**,
/// never adjusted to fit.
#[arg(long)]
reseed_range: Option<String>,
/// Also write a self-contained HTML player beside the audio.
///
/// The waveform, a circle per speaker that lights when they speak, and
/// the subtitles. It reads the audio and the WebVTT track by name from
/// the same directory, so move all of them or none.
#[arg(long)]
page: bool,
/// Frame size: monitor, 720p, 1080p, 1440p, 4k, or 1920x1080.
///
/// Read whether or not `--page` was given, so a size that describes no
/// picture fails the same way with and without it.
#[arg(long, default_value = "monitor")]
size: String,
/// Margin around everything, in pixels.
#[arg(long, default_value_t = 48)]
padding: u32,
/// A `#rrggbb` colour, or the path to an image file.
#[arg(long)]
background: Option<String>,
/// Plain black behind everything. Overrides `--background`.
#[arg(long)]
black: bool,
/// Colour scheme, from the nine the website and the app offer.
///
/// Defaults to Tokyo Night. An unknown name is refused, and the error
/// lists every one it could have been.
#[arg(long)]
theme: Option<String>,
/// Give every speaker the **same** voice.
///
/// More private: the output then carries no trace of *which* speaker
/// somebody was, so two recordings of the same group cannot be lined up
/// by voice. The price is that only the names and the picture say who
/// is speaking -- by ear alone, nobody can. It also has no speaker
/// limit, because one voice cannot collide with itself.
#[arg(long)]
one_voice: bool,
},
/// Draw a still of the page, without rendering any audio.
///
/// The layout, the speaker circles and which voice each speaker becomes --
/// answered in a second rather than in the length of the recording. With
/// `--ffmpeg` it also prints the command that would turn frames into a
/// video file, and whether `ffmpeg` is on this machine. **It never runs
/// it**: this project ships no codec and starts no program you did not.
Preview {
/// The plan file.
plan: PathBuf,
/// A recording, so the waveform is real rather than flat.
#[arg(long)]
audio: Option<PathBuf>,
/// Which second of the conversation to draw.
#[arg(long, default_value_t = 0.0)]
at: f64,
/// Where to write the SVG. Defaults to the plan's name.
#[arg(short, long)]
output: Option<PathBuf>,
/// Print the ffmpeg command, and whether ffmpeg is installed.
#[arg(long)]
ffmpeg: bool,
/// Frame size: monitor, 720p, 1080p, 1440p, 4k, or 1920x1080.
///
/// This replaces the old `--width` and `--height`, which were two ways
/// to say one thing and could be set to a pair no video can be made
/// from. `monitor` matches this display where the platform will say
/// what it is, and says so and uses 1080p where it will not.
#[arg(long, default_value = "monitor")]
size: String,
/// Frames per second for the printed ffmpeg command, from 5 to 60.
#[arg(long, default_value_t = 30)]
fps: u32,
/// Margin around everything, in pixels.
#[arg(long, default_value_t = 48)]
padding: u32,
/// A `#rrggbb` colour, or the path to an image file.
#[arg(long)]
background: Option<String>,
/// Plain black behind everything. Overrides `--background`.
#[arg(long)]
black: bool,
/// Colour scheme, from the nine the website and the app offer.
#[arg(long)]
theme: Option<String>,
/// Draw it as if every speaker had the same voice.
#[arg(long)]
one_voice: bool,
},
}
/// What `veilvoice capture` can do.
#[derive(Subcommand, Debug)]
enum AppctlCommand {
/// Record what is running as ordinary.
Learn {
/// Close the baseline. Nothing joins it by running after this.
#[arg(long)]
finish: bool,
},
/// Compare what is running now against the baseline.
Check,
/// Allow a program that is not in the baseline.
Allow {
/// The executable name, as it appears in `check`.
program: String,
/// For this many hours. Without it, permanently.
#[arg(long)]
hours: Option<u64>,
},
/// Withdraw a grant.
Revoke {
/// The executable name.
program: String,
},
/// Every decision this baseline has made.
Log,
}
#[derive(Subcommand, Debug)]
enum InputCommand {
/// What is running now that could see input. The default.
Look,
/// Everything this build can recognise, running or not.
///
/// So that an empty result can be checked rather than trusted: a reader
/// who is told nothing was found should be able to see what was looked
/// for.
Known,
}
#[derive(Subcommand)]
enum CaptureCommand {
/// What is running, what is allowed, and what this cannot see.
Status,
/// Every program this build knows how to notice.
List,
/// Where to point Discord, Signal, Telegram, Element and the rest so your
/// voice goes through VeilVoice first.
///
/// Prints the route, the menu to change in each program found, and the two
/// things this does **not** do: it changes only what you send, and it never
/// reaches inside any of those programs.
Calls,
/// Stop notifying about one program.
///
/// Allowed means muted, not hidden: it still appears in `status`.
Allow {
/// The program's key, as `list` prints it.
key: String,
},
/// Start notifying about one program again.
Deny {
/// The program's key, as `list` prints it.
key: String,
},
/// Look now, and exit non-zero if something unallowed is running.
///
/// For a script that should not start recording something sensitive while
/// a screen recorder is open. A listing that failed prints the reason and
/// still exits zero: a check that could not see is not a check that
/// passed, and it is not a reason to fail somebody's script either.
Check,
}
/// What `veilvoice mandate` can do.
#[derive(Subcommand)]
enum MandateCommand {
/// What is required now, and where it is written down.
Status,
/// Stop insisting on a requirement. Asks before it does it.
Relax {
/// Stop requiring a password for VeilVoice itself.
#[arg(long)]
app_lock: bool,
/// Stop requiring recordings to be encrypted where they are stored.
#[arg(long)]
encryption: bool,
/// Proceed rather than explaining what would happen.
#[arg(long)]
yes: bool,
},
/// Insist on a requirement again.
Insist {
/// Require a password for VeilVoice itself.
#[arg(long)]
app_lock: bool,
/// Require recordings to be encrypted where they are stored.
#[arg(long)]
encryption: bool,
},
/// Go back to insisting on both.
Reset,
/// Every change that has been made, oldest first.
History,
}
/// What `veilvoice policy` can do.
#[derive(Subcommand)]
enum PolicyCommand {
/// What is in force, and what is known about the seal.
Status,
/// Write a policy and seal a copy of it under a passphrase.
///
/// Name at least one requirement. Each one fixes a setting on; none of
/// them can fix one off.
Seal {
/// Recordings must be encrypted at rest.
#[arg(long)]
encrypt_recordings: bool,
/// Metadata must be stripped from what VeilVoice writes.
#[arg(long)]
clean_metadata: bool,
/// Accent neutralisation must stay on.
#[arg(long)]
neutralise_accent: bool,
/// The app lock must be set before VeilVoice can be used.
#[arg(long)]
app_lock: bool,
/// A floor for the de-identification intensity, from 0 to 100.
#[arg(long, value_name = "0-100")]
minimum_intensity: Option<u8>,
/// A line shown beside every control the policy has fixed.
#[arg(long)]
note: Option<String>,
/// Write over a policy that is already in force.
#[arg(long)]
replace: bool,
},
/// Check the policy in force against its sealed copy.
///
/// The only thing here that needs the passphrase, and nothing calls it at
/// launch. Exits non-zero if the seal does not match, is missing, or the
/// plain file has gone.
Verify,
/// Delete both policy files.
///
/// Deliberately does not ask for the passphrase, because it could not
/// usefully: anybody who can run this can delete the same two files with a
/// file manager.
Remove {
/// Do not ask for confirmation.
#[arg(long)]
yes: bool,
},
}
/// What `veilvoice sentry` can do.
#[derive(Subcommand)]
enum SentryCommand {
/// What is planted, what is watched, and what this is worth.
Status,
/// Write a canary into a directory and start watching it.
Plant {
/// The directory to put it in.
dir: PathBuf,
/// A different filename. The default says what the file is, which a
/// reader can therefore skip; a name of your own does not.
#[arg(long)]
name: Option<String>,
},
/// Stop watching a canary, and delete it.
///
/// Use this rather than deleting the file, or the deletion is itself
/// reported as a change.
PullUp {
/// The canary's path, as `status` prints it.
path: PathBuf,
},
/// Record what a directory holds now, to compare against later.
///
/// Running it again replaces the record for that directory. Do that after
/// a change you know about, or every later check reports it again.
Baseline {
/// The directory to record.
dir: PathBuf,
/// Stop after this many files, and say the record is partial.
#[arg(long, default_value_t = Limits::default().max_files)]
max_files: usize,
/// How many directories deep to descend.
#[arg(long, default_value_t = Limits::default().max_depth)]
max_depth: usize,
},
/// Look at every canary and every baseline.
///
/// Exits non-zero only if a **canary** tripped, which is a fact. Churn is a
/// question at any level, so it never fails the command -- a check that
/// fails every time somebody copies a folder is a check somebody removes
/// from their scheduled task.
Check {
/// Files touched per minute, above which this is worth mentioning.
#[arg(long, default_value_t = Threshold::default().files_per_minute)]
files_per_minute: f64,
/// Proportion of the watched files touched, from 0.0 to 1.0.
#[arg(long, default_value_t = Threshold::default().share)]
share: f32,
/// Stop after this many files.
#[arg(long, default_value_t = Limits::default().max_files)]
max_files: usize,
/// How many directories deep to descend.
#[arg(long, default_value_t = Limits::default().max_depth)]
max_depth: usize,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CleanPolicy {
/// Remove every tag.
Strip,
/// Replace tags with plausible, non-identifying values.
Realistic,
}
impl From<CleanPolicy> for Policy {
fn from(p: CleanPolicy) -> Self {
match p {
CleanPolicy::Strip => Policy::Strip,
CleanPolicy::Realistic => Policy::Realistic,
}
}
}
/// The verification script's spelling for a system, in one place.
///
/// **F-167.** This mapping existed twice, both times as a `match` with a
/// catch-all, and both catch-alls sent the BSDs to the Linux script. Written
/// once, exhaustively, so a system added to one enumeration and not the other
/// is a compile error rather than a wrong instruction.
fn flavour_for(
system: veilvoice_verify::check::reproduce::System,
) -> veilvoice_verify::gnupg::script::Flavour {
use veilvoice_verify::check::reproduce::System;
use veilvoice_verify::gnupg::script::Flavour;
match system {
System::MacOs => Flavour::MacOs,
System::Bsd => Flavour::Bsd,
// Windows readers are given the `.cmd` reproduce script and the
// desktop application's Verify tab; the shell script they would be
// running here is the WSL one, which is Linux.
System::Linux | System::Windows => Flavour::Linux,
}
}
/// What checking a release actually involves, and who does which part.
///
/// Written out here rather than left to the website, because somebody on a
/// machine with no browser is exactly the person most likely to be checking a
/// download by hand.
fn explain_verification(
flavour: veilvoice_verify::gnupg::script::Flavour,
system: veilvoice_verify::check::reproduce::System,
) {
let survey = veilvoice_verify::gnupg::backend::look();
println!("Checking a VeilVoice release");
println!();
println!(" Three files, all published together on the releases page:");
println!(" the archive the thing you downloaded");
println!(" SHA256SUMS a hash for every file in the release");
println!(" SHA256SUMS.asc a signature over that list");
println!();
println!(" https://github.com/tilas01/veilvoice/releases/latest");
println!();
println!(" The signing key is published beside them as");
println!(" `veilvoice-signing-key.asc`, and is in the repository at");
println!(" website/assets/veilvoice-signing-key.asc, so it can be fetched");
println!(" from somewhere other than the release being checked.");
println!();
println!(" fingerprint {}", veilvoice_verify::check::FINGERPRINT);
println!();
println!(" Compare that against the fingerprint on the website and in");
println!(" README.md. It is the one step nothing can do for you.");
println!();
println!("What is already in this binary");
println!();
println!(" The signature check itself, in Rust, with the key above compiled");
println!(" in. Nothing needs installing for it, on any platform. It is what");
println!(" `veilvoice verify` uses, and it also checks every file extracted");
println!(" out of the archive against a signed contents list.");
println!();
println!(" What it cannot do is vouch for itself. This program came out of");
println!(" the download it would be checking, so a tampered release ships a");
println!(" tampered checker. That is not a bug to fix; it is why the second");
println!(" opinion below is worth having.");
println!();
println!("GnuPG, which is not part of VeilVoice");
match &survey.native {
Some(path) => println!(" found at {}", path.display()),
None => println!(" not on PATH. `veilvoice companions --install gnupg` prints the"),
}
if survey.native.is_none() {
println!(" command that installs it; VeilVoice does not run installers.");
}
if let Some(wsl) = &survey.wsl {
println!();
println!(" WSL is on this machine, at {}.", wsl.program.display());
println!(" A `gpg` inside it works just as well, and `wsl gpg ...` is how");
println!(" the commands are spelled. VeilVoice does not start WSL to find");
println!(" out what is in it unless you ask, because starting it starts a");
println!(" Linux distribution.");
}
println!();
println!(" Nothing here runs GnuPG on its own. An implementation other than");
println!(" this one is used when you choose it, in the desktop application");
println!(" under Verify, and not because one happened to be on PATH.");
println!();
println!("The script");
println!();
println!(" veilvoice verify --script > {}", flavour.file_name());
println!();
println!(" Sixty lines of shell using gpg and the system hash tool. Read it");
println!(" before running it: the reason to use it rather than this program");
println!(" is that it is not this program.");
println!();
println!("The stronger check");
println!();
println!(" veilvoice verify --build-script > {}", system.file_name());
println!();
println!(" A hash proves the file is the one whose hash was signed. It says");
println!(" nothing about what is inside it, because the same person signed");
println!(" both. That script rebuilds the release from source and compares");
println!(" the result, so a match means the published binary is what this");
println!(" source compiles to. It needs git and a Rust toolchain, and it");
println!(" installs neither.");
}
/// Parse the command line and turn a failure into an exit code and a message.
fn main() -> ExitCode {
let cli = Cli::parse();
// The verifier is answered here rather than in `run` because it does not
// fit that function's shape, and bending it to fit would lose the part
// that matters. `run` returns ok-or-a-message, which collapses to exit 0
// or 1; the verifier has a documented table of statuses -- 2 means a check
// failed, 5 means a build differed, and they are deliberately not the same
// number -- that scripts and CI already branch on. Flattening those into 1
// would silently break every one of them.
if let Command::Verify {
how,
script,
build_script,
ref args,
..
} = cli.command
{
// `--how`, `--script` and `--build-script` are this command's own and
// are handled in `run`. Everything else is the verifier's, including
// the no-argument case: `veilvoice verify` on its own finds a release
// nearby and checks it, which is what the standalone program did when
// somebody double-clicked it.
if !how && !script && !build_script {
return veilvoice_verify::run(args.clone());
}
}
match run(cli.command) {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("{}", err(&message));
ExitCode::FAILURE
}
}
}
/// Carry out one subcommand.
///
/// Everything the command line can ask for arrives here already parsed and
/// validated, so this is a dispatch rather than a place where input is
/// checked.
fn run(command: Command) -> Result<(), String> {
match command {
Command::Anonymise {
input,
output,
intensity,
keep_accent,
reseed_secs,
reseed_range,
clean_metadata,
encrypt,
encrypt_to,
yes,
} => anonymise(
input,
output,
Tuning {
intensity,
keep_accent,
reseed_secs,
reseed_range: reseed_range_from(reseed_range.as_deref())?,
},
clean_metadata,
AtRest {
encrypt,
to: encrypt_to,
yes,
},
),
#[cfg(feature = "live")]
Command::Live {
input,
output,
intensity,
keep_accent,
reseed_secs,
reseed_range,
preview,
no_monitor,
} => live(
input,
output,
preview,
!no_monitor,
Tuning {
intensity,
keep_accent,
reseed_secs,
reseed_range: reseed_range_from(reseed_range.as_deref())?,
},
),
#[cfg(feature = "live")]
Command::Record {
input,
output,
seconds,
to,
to_public_key,
plaintext,
yes,
intensity,
keep_accent,
reseed_secs,
reseed_range,
no_monitor,
} => record::run(
input,
output,
seconds,
to,
!no_monitor,
record::Sealing {
public_key: to_public_key,
plaintext,
yes,
},
Tuning {
intensity,
keep_accent,
reseed_secs,
reseed_range: reseed_range_from(reseed_range.as_deref())?,
},
),
#[cfg(feature = "live")]
Command::Devices => list_devices(),
Command::Clean { file, policy } => clean(file, policy.into()),
Command::Encrypt { input, output, to } => encrypt(input, output, to),
Command::Decrypt { input, output, key } => decrypt(input, output, key),
Command::Keygen { public, secret } => keygen(public, secret),
Command::Guard { action, path } => guard::run(action, path),
Command::Lock { action, path } => lock::run(action, path),
Command::Shred { file, passes, yes } => shred(file, passes, yes),
Command::Watch { once, interval } => watch(once, interval),
Command::Info => {
info();
Ok(())
}
// The search lives in `gui`, which looks in three places rather than
// only beside this binary, and never starts anything by a bare name --
// Windows resolves those through the current directory first.
Command::Accel => accel::show(),
Command::Decoy => decoy::explain(),
Command::Gui { quiet } => gui::open(quiet),
Command::Install { status } => {
let state = install::status();
println!("{}", heading("Install"));
println!(
"{}",
field(
"prefix",
&state
.prefix
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "not resolvable on this system".into())
)
);
println!(
"{}",
field("installed", if state.installed { "yes" } else { "no" })
);
println!(
"{}",
field(
"on PATH",
if state.on_path {
"yes, in this terminal"
} else {
"no"
}
)
);
// "Installed" and "you are running the installed copy" are
// different facts, and confusing them is how somebody updates a
// portable folder and wonders why the installed one is unchanged.
println!(
"{}",
field(
"running",
&match (&state.running_from, state.running_installed) {
(Some(path), true) => format!("the installed copy ({})", path.display()),
(Some(path), false) => format!("a portable copy ({})", path.display()),
(None, _) => "unknown".to_string(),
}
)
);
if status {
return Ok(());
}
match install::install() {
Ok(report) => {
println!();
for line in report {
println!("{}", ok(&line));
}
println!();
println!(" Open a new terminal for the PATH change to take effect.");
println!();
// Said plainly, once, where somebody installing will read
// it: this program will never tell them an update exists.
println!(
"{}",
warn(
"VeilVoice never checks for updates and cannot tell you when \
one exists -- it has no network code at all. Watch the \
releases page, and verify what you download."
)
);
Ok(())
}
Err(error) => {
println!("{}", err(&error));
Err("install failed".to_string())
}
}
}
Command::Uninstall { yes } => {
println!("{}", heading("Uninstall"));
let state = install::status();
if !state.installed {
println!("{}", warn("nothing is installed for this user"));
}
if !yes {
println!();
println!(" This removes the installed copy, the PATH entry and the");
println!(" uninstall entry. Your recordings, keys and settings are");
println!(" not touched -- they live elsewhere and are not this");
println!(" command's business.");
println!();
println!(" Re-run with --yes to proceed.");
return Ok(());
}
match install::uninstall() {
Ok(report) => {
for line in report {
println!("{}", ok(&line));
}
Ok(())
}
Err(error) => {
println!("{}", err(&error));
Err("uninstall failed".to_string())
}
}
}
Command::Conversation { what } => match what {
ConversationCommand::Inspect { plan } => conversation::inspect(&plan),
ConversationCommand::Fix { plan, what } => conversation::fix(&plan, what.into()),
ConversationCommand::Render {
plan,
input,
output,
intensity,
keep_accent,
reseed_secs,
reseed_range,
page,
size,
padding,
background,
black,
theme,
one_voice,
} => {
// The picture flags are read whether or not `--page` was given,
// so `--size 40x40 --page` and `--size 40x40` fail the same
// way. Accepting a size that describes nothing, silently,
// because the page happened not to be asked for, is how a flag
// comes to mean two different things.
let render = video_plan(&size, 30)?;
let look = conversation::look_from(
render.size.width(),
render.size.height(),
padding,
background,
black,
theme,
)?;
conversation::run(
&plan,
&input,
output,
config(Tuning {
intensity,
keep_accent,
reseed_secs,
reseed_range: reseed_range_from(reseed_range.as_deref())?,
}),
page.then_some(look),
one_voice,
)
}
ConversationCommand::Preview {
plan,
audio,
at,
output,
ffmpeg,
size,
fps,
padding,
background,
black,
theme,
one_voice,
} => {
let render = video_plan(&size, fps)?;
conversation::preview(
&plan,
audio,
at,
conversation::look_from(
render.size.width(),
render.size.height(),
padding,
background,
black,
theme,
)?,
output,
ffmpeg.then_some(render),
one_voice,
)
}
},
Command::Failsafe { veiling } => failsafe::show(veiling.as_deref()),
Command::Privilege => priv_mode::show(),
Command::Appctl { what } => match what {
AppctlCommand::Learn { finish } => appctl::learn(finish),
AppctlCommand::Check => appctl::check(),
AppctlCommand::Allow { program, hours } => appctl::allow(&program, hours),
AppctlCommand::Revoke { program } => appctl::revoke(&program),
AppctlCommand::Log => appctl::log(),
},
Command::Input { what } => match what.unwrap_or(InputCommand::Look) {
InputCommand::Look => input::look(),
InputCommand::Known => input::known(),
},
Command::Capture { what } => match what {
CaptureCommand::Status => capture::status(),
CaptureCommand::List => capture::list(),
CaptureCommand::Calls => capture::calls(),
CaptureCommand::Allow { key } => capture::allow(&key),
CaptureCommand::Deny { key } => capture::deny(&key),
CaptureCommand::Check => {
if capture::check()? {
Err("a screen recorder you have not allowed is running".to_string())
} else {
Ok(())
}
}
},
Command::Mandate { what } => match what {
MandateCommand::Status => mandate::status(),
MandateCommand::Relax {
app_lock,
encryption,
yes,
} => mandate::relax(app_lock, encryption, yes),
MandateCommand::Insist {
app_lock,
encryption,
} => mandate::insist(app_lock, encryption),
MandateCommand::Reset => mandate::reset(),
MandateCommand::History => mandate::history(),
},
Command::Policy { what } => match what {
PolicyCommand::Status => policy::status(),
PolicyCommand::Seal {
encrypt_recordings,
clean_metadata,
neutralise_accent,
app_lock,
minimum_intensity,
note,
replace,
} => {
let mut wanted = Vec::new();
if encrypt_recordings {
wanted.push(Requirement::EncryptRecordings);
}
if clean_metadata {
wanted.push(Requirement::CleanMetadata);
}
if neutralise_accent {
wanted.push(Requirement::NeutraliseAccent);
}
if app_lock {
wanted.push(Requirement::AppLock);
}
if let Some(hundredths) = minimum_intensity {
// Refused rather than clamped, for the reason the whole
// project refuses rather than clamps: a user who typed 150
// meant something, and quietly turning it into 100 makes
// the policy say something they did not write.
if hundredths > 100 {
return Err(format!(
"--minimum-intensity is a percentage from 0 to 100, not {hundredths}"
));
}
wanted.push(Requirement::MinimumIntensity(hundredths));
}
policy::seal(wanted, note, replace)
}
PolicyCommand::Verify => {
if policy::verify()? {
Err("the policy does not match its seal".to_string())
} else {
Ok(())
}
}
PolicyCommand::Remove { yes } => policy::remove(yes),
},
Command::Sentry { what } => match what {
SentryCommand::Status => sentry::status(),
SentryCommand::Plant { dir, name } => sentry::plant(&dir, name.as_deref()),
SentryCommand::PullUp { path } => sentry::pull_up(&path),
SentryCommand::Baseline {
dir,
max_files,
max_depth,
} => sentry::baseline(
&dir,
Limits {
max_files,
max_depth,
},
),
SentryCommand::Check {
files_per_minute,
share,
max_files,
max_depth,
} => {
if !(0.0..=1.0).contains(&share) {
// Refused rather than clamped. A share of 2.0 can never be
// met, so clamping it to 1.0 would silently turn a typo
// into a threshold that fires on every full rewrite -- and
// the user would believe they had asked for something else.
return Err(format!(
"--share is a proportion from 0.0 to 1.0, not {share}"
));
}
if !(files_per_minute.is_finite() && files_per_minute >= 0.0) {
return Err(format!(
"--files-per-minute must be zero or more, not {files_per_minute}"
));
}
let tripped = sentry::check(
Threshold {
files_per_minute,
share,
},
Limits {
max_files,
max_depth,
},
)?;
if tripped {
Err("a canary tripped".to_string())
} else {
Ok(())
}
}
},
Command::Verify {
how,
script,
build_script,
system,
macos,
args,
} => {
// The script flags and `--how` are this command's own. Anything
// else -- a verifier subcommand, or nothing at all -- belongs to
// the verifier, and is dispatched in `main` so its exit statuses
// survive. Reaching here with either means the caller asked for a
// script and a check in one breath, which is two answers to one
// question.
if !args.is_empty() {
return Err(format!(
"`{}` is a check, and --script and --how only describe one. Run them separately.",
args.join(" ")
));
}
if how {
// Named, then the shorthand, then this machine. F-167 was
// this falling through to Linux for a BSD reader, who was then
// told to run a command their system does not have.
let flavour = match system.as_deref() {
Some("macos") => veilvoice_verify::gnupg::script::Flavour::MacOs,
Some("bsd") => veilvoice_verify::gnupg::script::Flavour::Bsd,
_ if macos => veilvoice_verify::gnupg::script::Flavour::MacOs,
_ => flavour_for(veilvoice_verify::check::reproduce::System::here()),
};
explain_verification(flavour, veilvoice_verify::check::reproduce::System::here());
return Ok(());
}
let named = system.as_deref().map(|name| {
veilvoice_verify::check::reproduce::System::from_key(name).ok_or_else(|| {
format!(
"unknown system {name:?}. One of: {}",
veilvoice_verify::check::reproduce::System::ALL
.iter()
.map(|s| s.key())
.collect::<Vec<_>>()
.join(", ")
)
})
});
let system = match named {
Some(Ok(system)) => system,
Some(Err(why)) => return Err(why),
None if macos => veilvoice_verify::check::reproduce::System::MacOs,
None => veilvoice_verify::check::reproduce::System::here(),
};
if build_script {
print!("{}", veilvoice_verify::check::reproduce::script(system));
return Ok(());
}
let flavour = flavour_for(system);
if script {
print!("{}", veilvoice_verify::gnupg::script::shell(flavour));
return Ok(());
}
explain_verification(flavour, system);
Ok(())
}
Command::Companions { install: wanted } => match wanted {
None => {
list_companions();
Ok(())
}
Some(name) => install_companion(&name),
},
Command::Volumes => {
list_volumes();
Ok(())
}
Command::Video {
audio,
output,
size,
fps,
dry_run,
} => {
let out = output.unwrap_or_else(|| audio.with_extension("mp4"));
let plan = video_plan(&size, fps)?;
let argv = veilvoice_video::ffmpeg::black_command(
&audio,
&out,
veilvoice_video::ffmpeg::Encoding {
plan,
..Default::default()
},
);
run_ffmpeg("Video", &argv, &out, dry_run)
}
Command::Import {
source,
output,
dry_run,
} => {
if !veilvoice_video::ffmpeg::is_container(&source) {
println!(
"{}",
warn(&format!(
"{} is not a container VeilVoice knows how to open; trying anyway",
source.display()
))
);
}
let out = output.unwrap_or_else(|| source.with_extension("wav"));
let argv = veilvoice_video::ffmpeg::extract_command(&source, &out);
run_ffmpeg("Import", &argv, &out, dry_run)
}
}
}
/// Roadmap items 87 and 88. Run an ffmpeg command, or print it when there is no
/// ffmpeg to run.
///
/// The same bargain the rest of this project makes about other people's
/// software: VeilVoice does not ship a codec, does not install one, and does
/// not fail silently when one is missing. It says what it would have run, so
/// somebody can run it themselves or install the tool and try again.
///
/// The command is printed either way, before it runs. A tool that shells out
/// and shows only the result leaves nobody able to check what it did.
fn run_ffmpeg(
what: &str,
argv: &[String],
output: &std::path::Path,
dry_run: bool,
) -> Result<(), String> {
println!("{}", heading(what));
println!("{}", field("Writing", &output.display().to_string()));
println!();
println!(
"{}",
paint(
colour::MUTED,
&format!(" {}", veilvoice_video::ffmpeg::command_line(argv))
)
);
println!();
if dry_run {
println!("{}", ok("printed, not run"));
return Ok(());
}
let Some(ffmpeg) = veilvoice_video::ffmpeg::found() else {
println!(
"{}",
warn("ffmpeg is not on this machine, so nothing was run")
);
println!();
for line in veilvoice_video::ffmpeg::describe().lines() {
println!("{}", paint(colour::MUTED, &format!(" {line}")));
}
return Ok(());
};
let status = std::process::Command::new(&ffmpeg)
.args(&argv[1..])
.status()
.map_err(|e| format!("could not start {}: {e}", ffmpeg.display()))?;
if !status.success() {
return Err(format!(
"ffmpeg stopped with {status}. The command above is what it was asked to do."
));
}
// ffmpeg created the file, so it carries ffmpeg's umask, and for `import`
// that file is the *original* audio pulled out of a container: the
// untouched voiceprint, which is the most revealing thing this program
// ever writes. It was being left readable by every account on the machine.
//
// Tightened rather than written by us, because the writing is ffmpeg's
// job. The window between its creation and this line is real and is not
// closed here; `privatefile::tighten` says so where it is defined, and the
// line printed below does not overstate what happened.
let tightened = veilvoice_crypto::privatefile::tighten(output).is_ok();
println!("{}", ok(&format!("wrote {}", output.display())));
if tightened && cfg!(unix) {
println!(
"{}",
paint(
colour::MUTED,
" Set readable only by your account, after ffmpeg wrote it. That is a \n\
file permission and nothing more."
)
);
}
Ok(())
}
/// Report the encrypted volumes this machine is offering.
///
/// Roadmap items 81 to 85, from the command line. Reporting only: the same rule the
/// window follows, and for the same reason. A hidden-volume question cannot be
/// answered here because there is nothing to remember it against, so this
/// prints what a volume would need before the desktop application would write
/// to it rather than pretending to settle it.
fn list_volumes() {
use veilvoice_setup::volumes::{self, Tool};
println!("{}", heading("Encrypted volumes"));
println!(" None of these is part of VeilVoice and none of them is required.");
println!();
for tool in Tool::ALL {
println!("{}", paint(colour::BLUE, tool.name()));
println!("{}", field("key", tool.key()));
println!("{}", field("state", &volumes::installed(*tool).describe()));
println!("{}", field("home", tool.home_page()));
println!();
}
let mounted = volumes::mounted();
println!("{}", heading("Mounted now"));
if mounted.is_empty() {
println!(
"{}",
paint(
colour::MUTED,
" nothing recognisable is mounted. Unlock a volume in its own program \
first; VeilVoice will not do it for you."
)
);
}
for volume in &mounted {
println!(
"{}",
field(volume.tool.name(), &volume.path.display().to_string())
);
if let Some(why) = volume.blocked() {
println!("{}", warn(why));
}
}
println!();
println!("{}", paint(colour::MUTED, " What this is worth:"));
for line in crate::lock::wrap(volumes::DISK_ADVICE, 66) {
println!("{}", paint(colour::MUTED, &format!(" {line}")));
}
}
/// Report every companion that means anything on this platform.
///
/// Reporting is all this does. The licence and the author are printed beside
/// each one because somebody deciding whether to install software is entitled
/// to both before they decide, not afterwards in a manual.
fn list_companions() {
println!("{}", heading("Companions"));
println!(" None of these is part of VeilVoice and none of them is required.");
println!();
for companion in companions::for_this_platform() {
println!("{}", paint(colour::BLUE, companion.name));
println!("{}", field("key", companion.key));
println!("{}", field("made by", companion.vendor));
println!("{}", field("licence", companion.licence));
let presence = companion.detect();
let line = presence.describe();
println!(
"{}",
field(
"on this machine",
&if presence.is_present() {
line
} else {
format!("{line} (this says where VeilVoice looked, not what you have)")
}
)
);
println!(" {}", companion.what);
println!(" {}", companion.why);
if !presence.is_present() {
println!("{}", field("to install", &offer_line(&companion.offer())));
}
println!();
}
println!(" Install one with: veilvoice companions --install <key>");
}
/// One line describing what VeilVoice can do about a missing companion.
fn offer_line(offer: &companions::Offer) -> String {
match offer {
companions::Offer::Command {
via,
needs_privilege,
..
} => {
let command = offer.command_line().unwrap_or_default();
if *needs_privilege {
format!("{command} (via {via}; needs root, so run it yourself)")
} else {
format!("{command} (via {via})")
}
}
companions::Offer::Page(url) => {
format!("{url} (their installer, run by you, under their licence)")
}
companions::Offer::PartOfTheSystem(explanation) => explanation.to_string(),
companions::Offer::NotOnThisPlatform => "not applicable on this platform".to_string(),
companions::Offer::NoKnownRoute(reason) => reason.clone(),
}
}
/// Act on one named companion. The name is the explicit yes.
fn install_companion(name: &str) -> Result<(), String> {
let Some(companion) = companions::by_key(name) else {
let known: Vec<&str> = companions::for_this_platform()
.iter()
.map(|c| c.key)
.collect();
return Err(format!(
"no companion called '{name}'. On this platform: {}",
known.join(", ")
));
};
println!("{}", heading(companion.name));
println!("{}", field("made by", companion.vendor));
println!("{}", field("licence", companion.licence));
let presence = companion.detect();
if presence.is_present() {
println!(
"{}",
ok(&format!("already here -- {}", presence.describe()))
);
return Ok(());
}
println!("{}", field("looked", &presence.describe()));
let offer = companion.offer();
match &offer {
companions::Offer::Page(url) => {
// Never fetched and never executed. VB-CABLE is proprietary
// donationware with its own licence, and a program whose subject
// is verifying what you run has no business running an unverified
// third-party installer on somebody's behalf.
println!();
println!("{}", warn("VeilVoice does not install this for you."));
println!(" Get it from {url}, read their licence, and run their");
println!(" installer yourself. On Windows, reboot afterwards before");
println!(" using live mode.");
Ok(())
}
companions::Offer::Command {
via,
needs_privilege,
..
} => {
let command = offer.command_line().unwrap_or_default();
println!("{}", field("via", via));
println!("{}", field("command", &command));
if *needs_privilege {
println!();
println!(
"{}",
warn(
"this needs root, and VeilVoice does not ask for a password. \
Run that command yourself."
)
);
return Ok(());
}
println!();
match companions::run(&offer) {
Ok(report) => {
for line in report.lines() {
println!(" {line}");
}
println!("{}", ok("done"));
Ok(())
}
Err(error) => {
println!("{}", err(&error));
Err("the companion was not installed".to_string())
}
}
}
_ => {
println!();
println!(" {}", offer_line(&offer));
Ok(())
}
}
}
/// The engine settings a user can reach from the command line.
#[derive(Clone, Copy)]
struct Tuning {
intensity: f32,
keep_accent: bool,
reseed_secs: f32,
/// The randomised roll range, already parsed and already refused if it was
/// not usable. `None` means the fixed [`Tuning::reseed_secs`] interval.
reseed_range: Option<(f32, f32)>,
}
/// Turn `--reseed-range` into a range, or into the reason it was not one.
///
/// Three answers, and the middle one is the point of F-73:
///
/// * `Some(text)` -- parse it, and **refuse** anything unusable rather than
/// adjusting it to fit.
/// * `None` -- draw a range from the operating system's random source, so the
/// shipped interval is a property of this launch rather than of the binary.
/// * `"fixed"` -- the caller has asked for the old fixed interval by name,
/// which is the only way to get a predictable ratchet.
fn reseed_range_from(flag: Option<&str>) -> Result<Option<(f32, f32)>, String> {
match flag {
Some(text) if text.trim().eq_ignore_ascii_case("fixed") => Ok(None),
Some(text) => veilvoice_core::parse_reseed_range(text)
.map(Some)
.map_err(|why| format!("--reseed-range: {why}")),
// The default, and the whole of F-73. `with_random_reseed_range` was
// written, documented as what the front ends do at launch, and called
// by nothing but its own test for two releases.
None => Ok(DeidConfig::default()
.with_random_reseed_range()
.reseed_range_ms),
}
}
/// The de-identification settings a `Tuning` describes, with every figure
/// clamped.
///
/// Clamping here rather than at each flag is deliberate: these flags are the
/// only way in, so one place that cannot be bypassed beats a check per flag.
fn config(t: Tuning) -> DeidConfig {
DeidConfig {
intensity: t.intensity.clamp(0.0, 1.0),
accent: AccentConfig {
enabled: !t.keep_accent,
..AccentConfig::default()
},
reseed_secs: t.reseed_secs.max(0.0),
reseed_range_ms: t.reseed_range,
..DeidConfig::default()
}
}
/// How a randomised roll range reads in the output.
///
/// Reports [`DeidConfig::effective_reseed_range_ms`] rather than what was
/// asked for: the ratchet can only fire on a frame boundary, so the range that
/// takes effect is quantised, and printing the request would tell somebody
/// their interval varies over a span it does not.
fn describe_reseed_range(config: &DeidConfig) -> String {
match config.effective_reseed_range_ms() {
Some((lo, hi)) => {
format!("{lo:.0}-{hi:.0} ms, drawn fresh before every roll -- no period to observe")
}
None => describe_reseed(config.reseed_secs),
}
}
/// How the seed-rolling setting reads in the output.
fn describe_reseed(secs: f32) -> String {
if secs <= 0.0 {
"off, so one stream for the whole session".to_string()
} else {
format!("every {secs}s")
}
}
/// What to do with the result once it exists.
struct AtRest {
/// Seal the recording rather than writing it in the clear. Default on.
encrypt: bool,
/// Seal to this public key instead of a passphrase.
to: Option<PathBuf>,
/// Do not stop to confirm an unencrypted write.
yes: bool,
}
/// `veilvoice anonymise`: veil a recording and write it somewhere else.
fn anonymise(
input: PathBuf,
output: Option<PathBuf>,
tuning: Tuning,
clean_metadata: bool,
at_rest: AtRest,
) -> Result<(), String> {
if at_rest.to.is_some() && !at_rest.encrypt {
return Err("--encrypt-to and --encrypt false ask for opposite things".into());
}
let out_path = output.unwrap_or_else(|| {
let mut p = input.clone();
p.set_extension("veiled.wav");
p
});
let audio = audio_io::load(&input).map_err(|e| e.to_string())?;
println!("{}", heading("Input"));
println!("{}", field("File", &input.display().to_string()));
println!(
"{}",
field("Duration", &format!("{:.2} s", audio.duration_secs()))
);
println!(
"{}",
field("Sample rate", &format!("{} Hz", audio.sample_rate))
);
let started = std::time::Instant::now();
let veiled = veilvoice_audio::deidentify(&audio, config(tuning)).map_err(|e| e.to_string())?;
let elapsed = started.elapsed().as_secs_f32();
// Encoded in memory, so an encrypted result never exists on disk in the
// clear even for a moment.
let mut wav = audio_io::wav_bytes(&veiled).map_err(|e| e.to_string())?;
let mut removed = Vec::new();
if clean_metadata {
match veilvoice_meta::clean_wav_bytes(&wav, Policy::Strip) {
Ok((cleaned, report)) => {
wav = cleaned;
removed = report.removed;
}
Err(e) => println!("{}", warn(&format!("could not clean metadata: {e}"))),
}
}
if !removed.is_empty() {
println!("{}", field("Metadata removed", &removed.join(", ")));
}
let written = if at_rest.encrypt {
println!();
let recipient = match at_rest.to.as_deref() {
Some(key) => atrest::Recipient::PublicKey(key),
None => atrest::Recipient::Password,
};
atrest::seal_to_disk(&out_path, &wav, recipient)?
} else {
atrest::confirm_plaintext(at_rest.yes)?;
// An unencrypted recording is still a recording of everything that was
// said, which the warning just above says exactly, so at minimum it is
// not left readable by every other account on the machine. A file
// permission is a much weaker thing than the encryption being declined
// here, and the summary below says so rather than letting it read as a
// consolation.
veilvoice_crypto::privatefile::write_owner_only(&out_path, &wav)
.map_err(|e| format!("{}: {e}", out_path.display()))?;
// Moved rather than cloned: this is the last use of `out_path`, and
// the path can be long.
out_path
};
println!();
println!("{}", heading("Result"));
println!("{}", field("Written", &written.display().to_string()));
println!(
"{}",
field(
"Speed",
&format!("{:.1}x realtime", audio.duration_secs() / elapsed.max(1e-6))
)
);
println!(
"{}",
field(
"Accent",
if tuning.keep_accent {
"kept"
} else {
"neutralised"
}
)
);
println!(
"{}",
field("Seed rolls", &describe_reseed_range(&config(tuning)))
);
println!(
"{}",
field(
"At rest",
&match (at_rest.encrypt, at_rest.to.is_some()) {
(true, true) => "sealed to a public key (X25519 + ML-KEM-768)".to_string(),
(true, false) => "sealed with a passphrase (Argon2id)".to_string(),
(false, _) => "UNENCRYPTED".to_string(),
}
)
);
println!();
println!(
"{}",
ok("done, and the voiceprint in this file is not recoverable")
);
if at_rest.encrypt {
println!(
"{}",
paint(
colour::MUTED,
" Open it again with: veilvoice decrypt <file> -o out.wav"
)
);
} else {
println!(
"{}",
paint(
colour::MUTED,
" The words are still there; that is deliberate. To hide the"
)
);
println!(
"{}",
paint(
colour::MUTED,
" message as well, encrypt it: veilvoice encrypt"
)
);
}
Ok(())
}
#[cfg(feature = "live")]
/// `veilvoice live`: veil the microphone as it is heard, with an optional
/// preview.
fn live(
input: Option<String>,
output: Option<String>,
preview: bool,
monitor: bool,
tuning: Tuning,
) -> Result<(), String> {
let in_device =
devices::open(devices::Direction::Input, input.as_deref()).map_err(|e| e.to_string())?;
// With no explicit choice, prefer a virtual cable: routing into one is what
// makes the veiled voice usable by other applications.
//
// Except in preview, where the whole point is the opposite. `--preview` is
// for hearing yourself before anybody else does, so it goes to this
// machine's default output and to nothing else. Choosing the cable there
// would send the preview into whatever is listening on it, which is the
// one thing somebody checking their setup does not want.
let out_name = match (&output, preview) {
(Some(name), _) => Some(name.clone()),
(None, true) => None,
(None, false) => devices::find_virtual_cable().map(|d| d.name),
};
let out_device = devices::open(devices::Direction::Output, out_name.as_deref())
.map_err(|e| e.to_string())?;
println!(
"{}",
heading(if preview {
"Live scramble - preview"
} else {
"Live scramble"
})
);
println!("{}", field("Input", &devices::name_of(&in_device)));
println!("{}", field("Output", &devices::name_of(&out_device)));
if preview {
// **F-84.** Printed after the device, and it names the device rather
// than claiming something about it.
//
// The first version said "goes to this machine's output and nowhere
// else" before the output was even named, and that is not always true:
// `--preview --output <a cable>` keeps the cable, and a machine whose
// *default* output is a cable does the same thing without being asked.
// Whatever is listening on that cable then hears the preview.
//
// A false reassurance in the one place somebody is checking their
// setup is worse than no reassurance, because checking is what they
// came here to do. So the claim is scoped to the named device, and
// when that device is a cable it is said outright.
let cable = devices::find_virtual_cable().map(|d| d.name);
let chosen = devices::name_of(&out_device);
let into_cable = cable.as_deref() == Some(chosen.as_str());
println!();
if into_cable {
println!(
"{}",
warn("that output is a virtual cable, so whatever is listening on it hears this")
);
println!(
"{}",
paint(
colour::MUTED,
" For a preview only you can hear, run --preview with no --output."
)
);
} else {
println!(
"{}",
paint(
colour::YELLOW,
" Preview. The veiled voice goes to the output named above and nowhere else."
)
);
}
println!(
"{}",
paint(
colour::MUTED,
" Use headphones: speakers plus a microphone is a feedback loop."
)
);
println!(
"{}",
paint(
colour::MUTED,
" Listen for a voice that is not yours. That is the check the meters cannot make."
)
);
println!();
}
println!(
"{}",
field(
"Accent",
if tuning.keep_accent {
"kept"
} else {
"neutralised"
}
)
);
println!(
"{}",
field("Seed rolls", &describe_reseed_range(&config(tuning)))
);
if out_name.is_none() && !preview {
println!(
"{}",
warn("no virtual audio cable found, so routing to the default output")
);
println!(
"{}",
paint(
colour::MUTED,
" Install VB-CABLE (Windows) or BlackHole (macOS) so other"
),
);
println!(
"{}",
paint(
colour::MUTED,
" applications can receive the veiled voice."
)
);
}
let session = veilvoice_audio::LiveSession::start(&in_device, &out_device, config(tuning))
.map_err(|e| e.to_string())?;
println!();
if !monitor {
// Asked for silence, so say once that it is running and then be quiet.
// A quiet mode that still prints a bar sixty times a minute is not one.
println!("{}", ok("running. Ctrl-C to stop."));
println!(
"{}",
paint(
colour::MUTED,
" Meters are off. Run without --no-monitor to see the levels."
)
);
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
println!(
"{}",
paint(
colour::MUTED,
" dBFS, peak. 0 is full scale; speech usually sits near -12."
)
);
// The limit, printed where the meters are rather than left to be inferred.
// A level says sound arrived and sound left. It cannot say the voice was
// changed: a working meter and a bypassed engine draw the same bar.
println!(
"{}",
paint(
colour::MUTED,
" These show sound arriving and leaving, not that the voice has changed."
)
);
if !preview {
println!(
"{}",
paint(
colour::MUTED,
" To hear what you sound like first, stop and run with --preview."
)
);
}
println!("{}", paint(colour::MUTED, " Ctrl-C to stop."));
println!();
const WIDTH: usize = 20;
let mut in_meter = meter::Channel::default();
let mut out_meter = meter::Channel::default();
// Sixty times a second would be smoother and would also be sixty terminal
// writes a second for a bar twenty characters wide. Twenty is fast enough
// that a syllable moves the bar, and the peak hold is what catches what
// falls between two frames -- nothing is missed, because the audio thread
// keeps the maximum and resets it on read.
loop {
std::thread::sleep(std::time::Duration::from_millis(50));
let s = session.stats();
let glitches = if s.dropped > 0 || s.starved > 0 {
paint(
colour::YELLOW,
&format!(" drops {} / starves {}", s.dropped, s.starved),
)
} else {
String::new()
};
// Sticky, because clipping is destructive and is over in a
// millisecond: a warning that has gone before the person looks up was
// never given.
let clipped = if in_meter.has_clipped() || out_meter.has_clipped() {
paint(colour::RED, " CLIPPED")
} else {
String::new()
};
// **Roadmap item 132.** The platform's own report about the streams, on the
// line somebody is already watching. It is also printed in full, on
// its own line, by the error callback; this is the part that stays on
// screen, because a line that scrolled past is a line that was not
// read.
let interfered = if s.interfered > 0 {
paint(colour::RED, &format!(" INTERRUPTED x{}", s.interfered))
} else {
String::new()
};
print!(
"\r {} {} {} {} {} {:.1} ms{}{}{} ",
paint(colour::MUTED, " in"),
in_meter.update(s.input_peak, WIDTH),
paint(colour::MUTED, "out"),
out_meter.update(s.output_peak, WIDTH),
paint(colour::MUTED, "cpu"),
s.process.ema_block_ms(),
glitches,
clipped,
interfered
);
use std::io::Write;
let _ = std::io::stdout().flush();
}
}
#[cfg(feature = "live")]
/// `veilvoice devices`: every input and output this machine reports.
fn list_devices() -> Result<(), String> {
for (label, direction) in [
("Inputs", devices::Direction::Input),
("Outputs", devices::Direction::Output),
] {
println!("{}", heading(label));
match devices::list(direction) {
Ok(list) if list.is_empty() => println!(" {}", paint(colour::MUTED, "none found")),
Ok(list) => {
for d in list {
let mut marks = Vec::new();
if d.is_default {
marks.push(paint(colour::GREEN, "default"));
}
if d.is_virtual_cable {
marks.push(paint(colour::PURPLE, "virtual cable"));
}
let suffix = if marks.is_empty() {
String::new()
} else {
format!(" ({})", marks.join(", "))
};
println!(" {}{}", d.name, suffix);
}
}
Err(e) => println!(" {}", warn(&e.to_string())),
}
println!();
}
Ok(())
}
/// Read a file, naming it if that fails.
///
/// `std::io::Error` carries no path. `e.to_string()` on a missing file is the
/// bare sentence "No such file or directory (os error 2)", and that is what
/// three of these commands printed:
///
/// ```text
/// $ veilvoice encrypt clip.wav --to nokey.pub
/// ✗ No such file or directory (os error 2)
/// ```
///
/// Which file? The command names two. `anonymise` answered that question,
/// because `atrest.rs` formats the path in, and `encrypt` and `decrypt` did
/// not, because they did not. The same bug as the verifier's unnamed verdict,
/// in a place where the person can at least see their own command line, which
/// is the only reason it is smaller.
///
/// A function rather than a fourth copy of the format string, because that is
/// how the copies got out of step in the first place.
pub(crate) fn read_named(path: &std::path::Path) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))
}
/// Read a `--size` and an `--fps` into a render plan, saying what was decided.
///
/// **The note is printed rather than swallowed.** `monitor` on a machine with
/// no display server silently became 1080p otherwise, which reads as a
/// detection and is a fallback. Somebody rendering on a server should be told
/// that the size they got is a default and not a measurement of anything.
///
/// The display is not asked here at all: a command line has no window, and the
/// platform interfaces that answer this question want one. The window passes
/// its own display size to the same resolver, so both front ends share the
/// arithmetic and differ only in whether there is a screen to ask.
pub(crate) fn video_plan(size: &str, fps: u32) -> Result<veilvoice_video::size::Plan, String> {
use veilvoice_video::size::{Choice, FrameRate, Plan};
let choice = Choice::parse(size).map_err(|e| e.to_string())?;
let fps = FrameRate::new(fps).map_err(|e| e.to_string())?;
let resolved = choice.resolve(None);
if let Some(note) = &resolved.note {
println!("{}", crate::theme::warn(note));
}
Ok(Plan::new(resolved.size, fps))
}
/// Write a file, naming it if that fails.
///
/// The failures here are the ones a person can act on: a directory that does
/// not exist, a read-only disk, a name they cannot write to. All of them need
/// the path to be actionable at all.
pub(crate) fn write_named(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// `veilvoice clean`: strip the metadata a file carries, in place.
fn clean(file: PathBuf, policy: Policy) -> Result<(), String> {
let bytes = read_named(&file)?;
let report = if veilvoice_meta::ImageKind::sniff(&bytes).is_some() {
veilvoice_meta::clean_image_file(&file, policy).map_err(|e| e.to_string())?
} else {
veilvoice_meta::clean_audio_file(&file, policy).map_err(|e| e.to_string())?
};
if report.changed {
println!("{}", ok(&format!("cleaned {}", file.display())));
println!("{}", field("Removed", &report.removed.join(", ")));
} else {
println!("{}", ok(&format!("{} was already clean", file.display())));
}
Ok(())
}
/// `veilvoice encrypt`: seal a file, to a passphrase or to a public key.
fn encrypt(input: PathBuf, output: Option<PathBuf>, to: Option<PathBuf>) -> Result<(), String> {
let out_path = output.unwrap_or_else(|| container::veil_path(&input));
let plaintext = read_named(&input)?;
let sealed = match to {
Some(key_path) => {
let encoded = read_named(&key_path)?;
let pk = hybrid::PublicKey::from_bytes(&encoded).map_err(|e| e.to_string())?;
container::seal_to_public_key(&pk, &plaintext).map_err(|e| e.to_string())?
}
None => {
let password = read_new_password()?;
println!(
"{}",
paint(
colour::MUTED,
" Deriving key (Argon2id, this is meant to be slow)..."
)
);
container::seal_with_password(password.expose(), &plaintext, kdf::KdfParams::default())
.map_err(|e| e.to_string())?
}
};
write_named(&out_path, &sealed)?;
println!("{}", ok(&format!("encrypted to {}", out_path.display())));
Ok(())
}
/// `veilvoice decrypt`: open a sealed file, given the passphrase or the secret
/// key.
fn decrypt(input: PathBuf, output: PathBuf, key: Option<PathBuf>) -> Result<(), String> {
let sealed = read_named(&input)?;
let plaintext = match key {
Some(key_path) => {
let sk = load_secret_key(&key_path)?;
container::open_with_secret_key(&sk, &sealed).map_err(|e| e.to_string())?
}
None => {
let password = prompt_secret("Passphrase: ")?;
container::open_with_password(password.expose(), &sealed).map_err(|e| e.to_string())?
}
};
// Owner-only from the moment it exists. This is the *decrypted* contents of
// something the user chose to encrypt; writing it out world-readable, even
// for the instant before a chmod, would undo the point of having sealed it.
veilvoice_crypto::privatefile::write_owner_only(&output, &plaintext)
.map_err(|e| format!("{}: {e}", output.display()))?;
println!("{}", ok(&format!("decrypted to {}", output.display())));
println!(
"{}",
paint(
colour::MUTED,
" Written so only your account can read it. That is a file permission, \
not disk encryption."
)
);
Ok(())
}
/// Load a private key file, which is itself a password-locked container.
fn load_secret_key(path: &std::path::Path) -> Result<hybrid::SecretKey, String> {
let sealed = read_named(path)?;
let password = prompt_secret("Key passphrase: ")?;
let encoded =
container::open_with_password(password.expose(), &sealed).map_err(|e| e.to_string())?;
hybrid::SecretKey::from_bytes(&encoded).map_err(|e| e.to_string())
}
/// `veilvoice keygen`: write a new key pair, refusing to overwrite either
/// file.
fn keygen(public: PathBuf, secret: PathBuf) -> Result<(), String> {
// Reported early so the user is not asked for a passphrase before being
// told the file is in the way. The *refusal* that matters is not this one
// though. It is `write_owner_only_new` below, which asks the kernel to
// fail if anything is already there. Checking `exists()` and then writing
// is a race, and it follows a symbolic link planted at the path.
for path in [&public, &secret] {
if path.exists() {
return Err(format!(
"{} already exists, so refusing to overwrite a key file",
path.display()
));
}
}
let (sk, pk) = hybrid::SecretKey::generate().map_err(|e| e.to_string())?;
// The private key is never written in the clear: it is sealed with the same
// container format everything else uses, so a stolen key file is worth
// nothing without the passphrase.
println!(
"{}",
paint(
colour::MUTED,
" Choose a passphrase to protect the private key."
)
);
let password = read_new_password()?;
println!(
"{}",
paint(
colour::MUTED,
" Deriving key (Argon2id, deliberately slow)..."
)
);
let encoded = sk.to_bytes();
let sealed = container::seal_with_password(
password.expose(),
encoded.expose(),
kdf::KdfParams::default(),
)
.map_err(|e| e.to_string())?;
// The public key is meant to be shared, so it is written normally. The
// private key is created owner-only and *exclusively*: the permission is
// applied by the creation rather than by a chmod afterwards, and the
// creation fails rather than overwriting anything already at the path.
std::fs::write(&public, pk.to_bytes()).map_err(|e| format!("{}: {e}", public.display()))?;
veilvoice_crypto::privatefile::write_owner_only_new(&secret, &sealed)
.map_err(|e| format!("{}: {e}", secret.display()))?;
println!("{}", ok(&format!("public key {}", public.display())));
println!(
"{}",
ok(&format!("private key {} (encrypted)", secret.display()))
);
println!();
println!("{}", field("Algorithm", "X25519 + ML-KEM-768 hybrid"));
println!(
"{}",
paint(
colour::MUTED,
" Share the public key freely. Anyone holding it can"
)
);
println!(
"{}",
paint(
colour::MUTED,
" encrypt to you; only the private key can open it."
)
);
Ok(())
}
/// Report, and keep reporting, what is using the microphone and camera.
fn watch(once: bool, interval: f32) -> Result<(), String> {
use veilvoice_watch::{Change, DeviceKind, Monitor};
let support = veilvoice_watch::support();
println!("{}", heading("Microphone and camera monitor"));
println!(
"{}",
field(
"Detection",
if support.microphone && support.camera {
"microphone and camera"
} else if support.microphone {
"microphone only"
} else {
"unavailable on this platform"
}
)
);
println!(
"{}",
paint(colour::MUTED, &format!(" {}", support.explanation))
);
// An empty list from a platform that cannot see is not good news, and must
// never be presented as though it were.
if !support.microphone && !support.camera {
println!();
return Err("nothing can be detected here, so nothing is reported".into());
}
println!();
let mut monitor = Monitor::new();
let sleep = std::time::Duration::from_secs_f32(interval.clamp(0.2, 60.0));
loop {
let changes = monitor.poll().map_err(|e| e.to_string())?;
for change in &changes {
let (mark, shade) = match change {
Change::Started(u) if u.kind == DeviceKind::Camera => ("●", colour::RED),
Change::Started(_) => ("●", colour::YELLOW),
Change::Stopped(_) => ("○", colour::GREEN),
};
println!(" {} {}", paint(shade, mark), change.alert());
}
if once {
let active = monitor.current();
if active.is_empty() {
println!("{}", ok("nothing is using the microphone or camera"));
} else {
for entry in active {
println!(
"{}",
field(
&entry.kind.to_string(),
&format!(
"{}{}",
entry.describe(),
entry
.device
.as_deref()
.map(|d| format!(" [{d}]"))
.unwrap_or_default()
)
)
);
if let Some(path) = &entry.path {
println!("{}", paint(colour::MUTED, &format!(" {path}")));
}
}
}
return Ok(());
}
if changes.is_empty() && monitor.current().is_empty() {
print!(
"\r {} ",
paint(colour::MUTED, "watching - nothing active")
);
use std::io::Write;
let _ = std::io::stdout().flush();
}
std::thread::sleep(sleep);
}
}
/// Destroy a file's contents, then delete it.
///
/// Gated behind a typed confirmation rather than a y/n prompt. There is no
/// undo, and a reflexive "y" is exactly the mistake this is guarding against.
fn shred(file: PathBuf, passes: u8, yes: bool) -> Result<(), String> {
let metadata = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?;
if !metadata.is_file() {
return Err(format!("{} is not a file", file.display()));
}
println!("{}", heading("Self-destruct"));
println!("{}", field("File", &file.display().to_string()));
println!(
"{}",
field(
"Size",
&format!("{:.1} KiB", metadata.len() as f64 / 1024.0)
)
);
println!("{}", field("Passes", &passes.to_string()));
println!();
println!("{}", err("THIS CANNOT BE UNDONE."));
println!(
"{}",
paint(
colour::MUTED,
" On an SSD, SD card or USB stick, wear levelling may leave the
original blocks in flash where no software can reach them.
Full-disk encryption is the reliable answer. Destroy the key
and the data goes with it, wherever the drive put it."
)
);
println!();
if !yes {
print!(" Type DESTROY to continue: ");
use std::io::Write;
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|e| e.to_string())?;
if answer.trim() != "DESTROY" {
return Err("cancelled, and nothing was touched".into());
}
}
let report = veilvoice_crypto::shred_file(&file, veilvoice_crypto::Passes::Custom(passes))
.map_err(|e| e.to_string())?;
println!();
println!(
"{}",
ok(&format!(
"overwrote {} bytes in {} passes, then deleted it",
report.bytes, report.passes
))
);
if !report.synced {
println!(
"{}",
warn("the OS did not confirm the overwrite reached the device")
);
}
println!();
println!("{}", paint(colour::MUTED, " What this does not cover:"));
for note in &report.caveats {
println!("{}", paint(colour::MUTED, &format!(" - {note}")));
}
Ok(())
}
/// `veilvoice info`: the versions, and what this build was compiled to do.
fn info() {
println!("{}", heading("VeilVoice"));
println!("{}", field("Version", env!("CARGO_PKG_VERSION")));
println!("{}", field("Engine", veilvoice_core::VERSION));
println!("{}", field("Crypto", veilvoice_crypto::VERSION));
println!("{}", field("Audio", veilvoice_audio::VERSION));
println!("{}", field("Metadata", veilvoice_meta::VERSION));
println!("{}", field("Monitor", veilvoice_watch::VERSION));
// "Licence" the noun, to match the desktop app and the website. "License"
// stays only where it is part of the proper name "GNU General Public
// License" or an SPDX identifier.
println!("{}", field("Licence", "GPL-3.0-or-later"));
println!("{}", field("Network access", "none, by construction"));
println!(
"{}",
field(
"Live audio",
if cfg!(feature = "live") {
"available"
} else {
"not built in (no device backend for this platform)"
}
)
);
println!();
println!(
"{}",
paint(
colour::MUTED,
" VeilVoice destroys the voiceprint, not the words."
)
);
println!(
"{}",
paint(
colour::MUTED,
" See docs/WHITEPAPER.md for what that does and does not"
)
);
println!("{}", paint(colour::MUTED, " protect against."));
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// Every command the documentation shows is a command this program has.
///
/// # The class this exists to close
///
/// F-110 was an example plan in `docs/USER_GUIDE.md` that the parser
/// refused. F-101 was a download page linking two files that were never
/// published. F-103 was a screenshot compared against a file written by
/// the same command, so neither could catch the other. F-71 was two
/// hand-typed copies of a number that drifted together.
///
/// All the same shape: a document describing the program, and nothing
/// comparing the two. Prose about behaviour goes stale silently, because
/// the compiler never reads it and the reader who would notice is the one
/// who has already been misled.
///
/// So the documentation is read here and checked against clap's own tree.
/// Not against a list kept beside it, which would be another copy to
/// drift: against the definition the program is built from.
///
/// # Telling a command from a sentence
///
/// The word "veilvoice" appears in these documents as prose, as sample
/// output and as a command, and only the last is checkable. The first
/// version of this test walked every occurrence and quietly skipped
/// anything it could not resolve, which made it blind to exactly the
/// defect it was written for: `veilvoice frobnicate` was added to the
/// guide and the test passed.
///
/// An invocation is therefore taken to be one that a reader could copy:
/// the whole of an inline code span, or a line inside a fenced block,
/// beginning with `veilvoice`. That excludes the alert
/// `● veilvoice is now using your microphone`, which is in a fence and is
/// output rather than something to type.
///
/// # What it can and cannot say
///
/// It checks that a documented subcommand exists and that a documented
/// long flag belongs to the subcommand it is shown with. That is the half
/// a machine can settle. Whether the example *works* is not checkable
/// here and is what running it is for: F-110 needed somebody to type it.
#[test]
fn every_command_the_documentation_shows_exists() {
let docs = [
("README.md", include_str!("../../../README.md")),
(
"docs/USER_GUIDE.md",
include_str!("../../../docs/USER_GUIDE.md"),
),
("docs/INSTALL.md", include_str!("../../../docs/INSTALL.md")),
];
let root = Cli::command();
let mut problems: Vec<String> = Vec::new();
let mut checked = 0_usize;
for (name, text) in docs {
let text = text.replace("\r\n", "\n");
let mut fenced = false;
for (number, line) in text.lines().enumerate() {
if line.trim_start().starts_with("```") {
fenced = !fenced;
continue;
}
// Everything a reader could copy and run, from this line.
let mut candidates: Vec<&str> = Vec::new();
if fenced {
let bare = line.trim_start().trim_start_matches("$ ").trim_start();
candidates.push(bare);
} else {
let mut rest = line;
while let Some(open) = rest.find('`') {
let after = &rest[open + 1..];
match after.find('`') {
Some(close) => {
candidates.push(&after[..close]);
rest = &after[close + 1..];
}
None => break,
}
}
}
for candidate in candidates {
let Some(after) = candidate.strip_prefix("veilvoice") else {
continue;
};
if !after.starts_with(' ') {
// `veilvoice-verify` and `veilvoice-gui` are other
// programs, with arguments of their own.
continue;
}
let mut words = after.split_whitespace().peekable();
let mut node = &root;
let mut path: Vec<String> = Vec::new();
// `verify` hands everything after it to the verifier,
// which has its own parser, so clap cannot answer for the
// words that follow. Checking them against clap would
// report every one of them missing; skipping them would
// leave the most safety-critical commands in the whole
// documentation unchecked. So they are checked against the
// verifier's own help, which is the thing that defines
// them.
if words.peek().copied() == Some("verify") {
words.next();
checked += 1;
let help = veilvoice_verify::help_text();
// A `#` starts a trailing comment in every shell
// example here, so the comment is cut off first --
// reading past one turns English prose into a list of
// commands nobody wrote. Of what is left, only the
// first word is a command; the rest are its arguments,
// and the flags among them are checked below.
let real: Vec<&str> = words.take_while(|w| !w.starts_with('#')).collect();
let vetted: Vec<&str> = real
.iter()
.copied()
.enumerate()
.filter(|(i, w)| *i == 0 || w.starts_with("--"))
.map(|(_, w)| w)
.collect();
for word in vetted {
let known = if let Some(flag) = word.strip_prefix("--") {
let flag: String = flag
.chars()
.take_while(|c| c.is_ascii_lowercase() || *c == '-')
.collect();
flag.is_empty()
|| flag == "help"
|| flag == "version"
// This command's own flags, which clap owns.
|| root
.find_subcommand("verify")
.map(|v| {
v.get_arguments()
.any(|a| a.get_long() == Some(flag.as_str()))
})
.unwrap_or(false)
|| help.contains(&format!("--{flag}"))
} else if word.chars().all(|c| c.is_ascii_lowercase() || c == '-')
&& !word.is_empty()
{
help.contains(&format!("veilvoice verify {word}"))
} else {
// A path, a hash, a placeholder: an argument
// rather than something to look up.
true
};
if !known {
problems.push(format!(
"{name}:{}: `veilvoice verify {word}` is documented, \
and the verifier's own help does not mention it",
number + 1,
));
}
}
continue;
}
// The leading words are subcommands until one is not.
while let Some(word) = words.peek().copied() {
if word.starts_with('-')
|| word.contains('.')
|| word.contains('/')
|| word.contains('<')
|| !word.chars().all(|c| c.is_ascii_lowercase() || c == '-')
{
break;
}
match node.find_subcommand(word) {
Some(next) => {
path.push(word.to_string());
node = next;
words.next();
}
None => {
problems.push(format!(
"{name}:{}: `veilvoice {}{word}` is documented, and \
there is no such command",
number + 1,
path.iter().map(|p| format!("{p} ")).collect::<String>()
));
break;
}
}
}
if path.is_empty() {
continue;
}
checked += 1;
for word in words {
if !word.starts_with("--") {
continue;
}
let flag: String = word
.trim_start_matches("--")
.chars()
.take_while(|c| c.is_ascii_lowercase() || *c == '-')
.collect();
if flag.is_empty() || flag == "help" || flag == "version" {
continue;
}
let known = node.get_arguments().any(|a| {
a.get_long() == Some(flag.as_str())
|| a.get_all_aliases()
.map(|all| all.iter().any(|x| *x == flag))
.unwrap_or(false)
});
if !known {
problems.push(format!(
"{name}:{}: `veilvoice {} --{flag}` is documented, and \
`{}` has no `--{flag}`",
number + 1,
path.join(" "),
path.join(" ")
));
}
}
}
}
}
assert!(
checked > 20,
"only {checked} documented invocations were found, which means this \
test has stopped reading the documentation rather than that the \
documentation stopped showing commands"
);
assert!(
problems.is_empty(),
"the documentation shows commands this program does not have:\n {}",
problems.join("\n ")
);
}
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
fn tuning(intensity: f32, keep_accent: bool, reseed_secs: f32) -> Tuning {
Tuning {
intensity,
keep_accent,
reseed_secs,
// The fixed interval, so these tests stay deterministic. The
// randomised range has its own tests in veilvoice-core.
reseed_range: None,
}
}
#[test]
fn intensity_is_clamped_into_range() {
assert_eq!(config(tuning(5.0, false, 2.0)).intensity, 1.0);
assert_eq!(config(tuning(-1.0, false, 2.0)).intensity, 0.0);
assert_eq!(config(tuning(0.5, false, 2.0)).intensity, 0.5);
}
#[test]
fn keep_accent_disables_neutralisation() {
assert!(!config(tuning(1.0, true, 2.0)).accent.enabled);
assert!(config(tuning(1.0, false, 2.0)).accent.enabled);
}
#[test]
fn reseed_interval_reaches_the_engine_and_cannot_go_negative() {
assert_eq!(config(tuning(1.0, false, 0.5)).reseed_secs, 0.5);
assert_eq!(config(tuning(1.0, false, 0.0)).reseed_secs, 0.0);
assert_eq!(config(tuning(1.0, false, -3.0)).reseed_secs, 0.0);
}
/// Encryption at rest is a *default*, not a flag the careful user has to
/// find. If someone ever flips this, this test is what stops it shipping.
#[test]
fn recordings_are_encrypted_at_rest_by_default() {
let cli = Cli::try_parse_from(["veilvoice", "anonymise", "in.wav"]).unwrap();
let Command::Anonymise {
encrypt,
encrypt_to,
yes,
..
} = cli.command
else {
panic!("expected anonymise");
};
assert!(encrypt, "at-rest encryption must default on");
assert!(encrypt_to.is_none());
assert!(!yes, "the confirmation must not be pre-answered");
let off = Cli::try_parse_from(["veilvoice", "anonymise", "in.wav", "--encrypt", "false"])
.unwrap();
let Command::Anonymise { encrypt, .. } = off.command else {
panic!("expected anonymise");
};
assert!(!encrypt, "it must still be possible to opt out");
}
/// Refused before anything is read or written, so a contradictory command
/// line cannot half-happen.
#[test]
fn asking_for_a_recipient_and_for_plaintext_at_once_is_refused() {
let result = anonymise(
PathBuf::from("does-not-need-to-exist.wav"),
None,
tuning(1.0, false, 2.0),
true,
AtRest {
encrypt: false,
to: Some(PathBuf::from("someone.pub")),
yes: true,
},
);
assert!(result.is_err());
}
#[test]
fn reseed_setting_reads_clearly() {
assert!(describe_reseed(0.0).contains("off"));
assert!(describe_reseed(2.0).contains("2"));
}
#[cfg(feature = "live")]
#[test]
fn meter_scales_and_never_panics() {
// The meter itself, its scale and its edge cases, are tested in
// `meter.rs` beside the code. What is worth checking from here is that
// the two are still connected: a level a person would call loud must
// not draw as an empty bar, which is what the linear meter this
// replaced did to ordinary speech.
for peak in [-1.0f32, 0.0, 0.25, 0.5, 1.0, 4.0] {
let bar = meter::render(peak, 0.0, 12);
assert!(bar.chars().count() >= 12);
}
assert!(meter::render(0.0, 0.0, 12).contains('·'));
assert!(meter::render(1.0, 0.0, 12).contains('█'));
assert!(
meter::render(0.251, 0.0, 12).matches('█').count() >= 8,
"speech at -12 dBFS must fill most of the bar"
);
}
}