diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index cdb4376..6e0c93c 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -22,12 +22,12 @@ jobs: toolchain: 1.45.0 override: true - name: cargo build - run: cargo build --features unstable + run: cargo build --release --features unstable working-directory: ./rage - uses: actions/upload-artifact@v1 with: name: rage - path: target/debug/rage + path: target/release/rage - name: Update FiloSottile/age status with result if: github.event.action == 'age-interop-request' diff --git a/i18n.toml b/i18n.toml new file mode 100644 index 0000000..402a0b5 --- /dev/null +++ b/i18n.toml @@ -0,0 +1 @@ +fallback_language = "en-US" diff --git a/rage/CHANGELOG.md b/rage/CHANGELOG.md index 868847f..625a073 100644 --- a/rage/CHANGELOG.md +++ b/rage/CHANGELOG.md @@ -10,6 +10,7 @@ to 1.0.0 are beta releases. ## [Unreleased] ### Added +- Internationalization (i18n) support! - `ssh` feature flag, enabled by default. It can be disabled to remove support for `ssh-rsa` and `ssh-ed25519` recipients and identities. diff --git a/rage/i18n.toml b/rage/i18n.toml new file mode 100644 index 0000000..40d065c --- /dev/null +++ b/rage/i18n.toml @@ -0,0 +1,4 @@ +fallback_language = "en-US" + +[fluent] +assets_dir = "i18n" diff --git a/rage/i18n/en-US/rage.ftl b/rage/i18n/en-US/rage.ftl new file mode 100644 index 0000000..216d5d2 --- /dev/null +++ b/rage/i18n/en-US/rage.ftl @@ -0,0 +1,83 @@ +# Copyright 2020 Jack Grigg +# +# Licensed under the Apache License, Version 2.0 or the MIT license +# , at your +# option. This file may not be copied, modified, or distributed +# except according to those terms. + +### Localization for strings in the rage CLI tool + +## CLI flags + +-flag-armor = -a/--armor +-flag-decrypt = -d/--decrypt +-flag-identity = -i/--identity +-flag-recipient = -r/--recipient +-flag-passphrase = -p/--passphrase +-flag-max-work-factor = --max-work-factor +-flag-unstable = --features unstable + +## Encryption messages + +autogenerated-passphrase = Using an autogenerated passphrase: +type-passphrase = Type passphrase +prompt-passphrase = Passphrase + +## General errors + +err-passphrase-timed-out = Timed out waiting for passphrase input. + +err-ux-A = Did rage not do what you expected? Could an error be more useful? +err-ux-B = Tell us +# Put (len(A) - len(B) - 32) spaces here. +err-ux-C = {" "} + +## Encryption errors + +err-enc-broken-stdout = Could not write to stdout: {$err} +rec-enc-broken-stdout = Are you piping to a program that isn't reading from stdin? + +err-enc-broken-file = Could not write to file: {$err} + +err-enc-identity = {-flag-identity} can't be used in encryption mode. +rec-enc-identity = Did you forget to specify {-flag-decrypt}? + +err-enc-invalid-recipient = Invalid recipient '{$recipient}' + +err-enc-missing-recipients = Missing recipients. +rec-enc-missing-recipients = Did you forget to specify {-flag-recipient}? + +err-enc-mixed-recipient-passphrase = {-flag-recipient} can't be used with {-flag-passphrase} +err-enc-passphrase-without-file = File to encrypt must be passed as an argument when using {-flag-passphrase} +err-enc-unknown-alias = Unknown {$alias} + +## Decryption errors + +rec-dec-excessive-work = To decrypt, retry with {-flag-max-work-factor} {$wf} + +err-dec-armor-flag = {-flag-armor} can't be used with {-flag-decrypt}. +rec-dec-armor-flag = Note that armored files are detected automatically. + +err-dec-identity-not-found = Identity file not found: {$filename} + +err-dec-missing-identities = Missing identities. +rec-dec-missing-identities-1 = Did you forget to specify {-flag-identity}? +rec-dec-missing-identities-2 = You can also store default identities in this file: + +err-dec-passphrase-flag = {-flag-passphrase} can't be used with {-flag-decrypt}. +rec-dec-passphrase-flag = Note that passphrase-encrypted files are detected automatically. + +err-dec-passphrase-without-file-win = + This file requires a passphrase, and on Windows the + file to decrypt must be passed as a positional argument + when decrypting with a passphrase. + +err-dec-recipient-flag = {-flag-recipient} can't be used with {-flag-decrypt}. +rec-dec-recipient-flag = Did you mean to use {-flag-identity} to specify a private key? + +## Unstable features + +unstable-aliases = Aliases are unstable. +unstable-github = GitHub lookups are unstable, ignoring recipient. +test-unstable = To test this, build rage with {-flag-unstable}. diff --git a/rage/src/bin/rage/error.rs b/rage/src/bin/rage/error.rs index 355a086..53a568b 100644 --- a/rage/src/bin/rage/error.rs +++ b/rage/src/bin/rage/error.rs @@ -1,6 +1,19 @@ +use i18n_embed_fl::fl; use std::fmt; use std::io; +macro_rules! wfl { + ($f:ident, $message_id:literal) => { + write!($f, "{}", $crate::fl!($message_id)) + }; +} + +macro_rules! wlnfl { + ($f:ident, $message_id:literal) => { + writeln!($f, "{}", $crate::fl!($message_id)) + }; +} + pub(crate) enum EncryptError { BrokenPipe { is_stdout: bool, source: io::Error }, IdentityFlag, @@ -9,8 +22,8 @@ pub(crate) enum EncryptError { Minreq(minreq::Error), MissingRecipients, MixedRecipientAndPassphrase, + PassphraseTimedOut, PassphraseWithoutFileArgument, - TimedOut(String), UnknownAlias(String), } @@ -39,35 +52,63 @@ impl fmt::Display for EncryptError { match self { EncryptError::BrokenPipe { is_stdout, source } => { if *is_stdout { - writeln!(f, "Could not write to stdout: {}", source)?; + writeln!( + f, + "{}", + fl!( + crate::LANGUAGE_LOADER, + "err-enc-broken-stdout", + err = source.to_string() + ) + )?; + wfl!(f, "rec-enc-broken-stdout") + } else { write!( f, - "Are you piping to a program that isn't reading from stdin?" + "{}", + fl!( + crate::LANGUAGE_LOADER, + "err-enc-broken-file", + err = source.to_string() + ) ) - } else { - write!(f, "Could not write to file: {}", source) } } EncryptError::IdentityFlag => { - writeln!(f, "-i/--identity can't be used in encryption mode.")?; - write!(f, "Did you forget to specify -d/--decrypt?") + wlnfl!(f, "err-enc-identity")?; + wfl!(f, "rec-enc-identity") } - EncryptError::InvalidRecipient(r) => write!(f, "Invalid recipient '{}'", r), + EncryptError::InvalidRecipient(recipient) => write!( + f, + "{}", + fl!( + crate::LANGUAGE_LOADER, + "err-enc-invalid-recipient", + recipient = recipient.as_str() + ) + ), EncryptError::Io(e) => write!(f, "{}", e), EncryptError::Minreq(e) => write!(f, "{}", e), EncryptError::MissingRecipients => { - writeln!(f, "Missing recipients.")?; - write!(f, "Did you forget to specify -r/--recipient?") + wlnfl!(f, "err-enc-missing-recipients")?; + wfl!(f, "rec-enc-missing-recipients") } EncryptError::MixedRecipientAndPassphrase => { - write!(f, "-r/--recipient can't be used with -p/--passphrase") + wfl!(f, "err-enc-mixed-recipient-passphrase") } - EncryptError::PassphraseWithoutFileArgument => write!( + EncryptError::PassphraseTimedOut => wfl!(f, "err-passphrase-timed-out"), + EncryptError::PassphraseWithoutFileArgument => { + wfl!(f, "err-enc-passphrase-without-file") + } + EncryptError::UnknownAlias(alias) => write!( f, - "File to encrypt must be passed as an argument when using -p/--passphrase" + "{}", + fl!( + crate::LANGUAGE_LOADER, + "err-enc-unknown-alias", + alias = alias.as_str() + ) ), - EncryptError::TimedOut(source) => write!(f, "Timed out waiting for {}", source), - EncryptError::UnknownAlias(alias) => write!(f, "Unknown {}", alias), } } } @@ -79,10 +120,10 @@ pub(crate) enum DecryptError { Io(io::Error), MissingIdentities(String), PassphraseFlag, + PassphraseTimedOut, #[cfg(not(unix))] PassphraseWithoutFileArgument, RecipientFlag, - TimedOut(String), #[cfg(feature = "ssh")] UnsupportedKey(String, age::ssh::UnsupportedKey), } @@ -105,45 +146,51 @@ impl fmt::Display for DecryptError { DecryptError::Age(e) => match e { age::DecryptError::ExcessiveWork { required, .. } => { writeln!(f, "{}", e)?; - write!(f, "To decrypt, retry with --max-work-factor {}", required) + write!( + f, + "{}", + fl!( + crate::LANGUAGE_LOADER, + "rec-dec-excessive-work", + wf = required + ) + ) } _ => write!(f, "{}", e), }, DecryptError::ArmorFlag => { - writeln!(f, "-a/--armor can't be used with -d/--decrypt.")?; - write!(f, "Note that armored files are detected automatically.") - } - DecryptError::IdentityNotFound(filename) => { - write!(f, "Identity file not found: {}", filename) + wlnfl!(f, "err-dec-armor-flag")?; + wfl!(f, "rec-dec-armor-flag") } + DecryptError::IdentityNotFound(filename) => write!( + f, + "{}", + fl!( + crate::LANGUAGE_LOADER, + "err-dec-identity-not-found", + filename = filename.as_str() + ) + ), DecryptError::Io(e) => write!(f, "{}", e), DecryptError::MissingIdentities(default_filename) => { - writeln!(f, "Missing identities.")?; - writeln!(f, "Did you forget to specify -i/--identity?")?; - writeln!(f, "You can also store default identities in this file:")?; + wlnfl!(f, "err-dec-missing-identities")?; + wlnfl!(f, "rec-dec-missing-identities-1")?; + wlnfl!(f, "rec-dec-missing-identities-2")?; write!(f, " {}", default_filename) } DecryptError::PassphraseFlag => { - writeln!(f, "-p/--passphrase can't be used with -d/--decrypt.")?; - write!( - f, - "Note that passphrase-encrypted files are detected automatically." - ) + wlnfl!(f, "err-dec-passphrase-flag")?; + wfl!(f, "rec-dec-passphrase-flag") } + DecryptError::PassphraseTimedOut => wfl!(f, "err-passphrase-timed-out"), #[cfg(not(unix))] DecryptError::PassphraseWithoutFileArgument => { - writeln!(f, "This file requires a passphrase, and on Windows the")?; - writeln!(f, "file to decrypt must be passed as a positional argument")?; - write!(f, "when decrypting with a passphrase.") + wfl!(f, "err-dec-passphrase-without-file-win") } DecryptError::RecipientFlag => { - writeln!(f, "-r/--recipient can't be used with -d/--decrypt.")?; - write!( - f, - "Did you mean to use -i/--identity to specify a private key?" - ) + wlnfl!(f, "err-dec-recipient-flag")?; + wfl!(f, "rec-dec-recipient-flag") } - DecryptError::TimedOut(source) => write!(f, "Timed out waiting for {}", source), #[cfg(feature = "ssh")] DecryptError::UnsupportedKey(filename, k) => k.display(f, Some(filename.as_str())), } @@ -176,13 +223,12 @@ impl fmt::Debug for Error { Error::Encryption(e) => writeln!(f, "{}", e)?, } writeln!(f)?; - writeln!( - f, - "[ Did rage not do what you expected? Could an error be more useful? ]" - )?; + writeln!(f, "[ {} ]", crate::fl!("err-ux-A"))?; write!( f, - "[ Tell us: https://str4d.xyz/rage/report ]" + "[ {}: https://str4d.xyz/rage/report {} ]", + crate::fl!("err-ux-B"), + crate::fl!("err-ux-C") ) } } diff --git a/rage/src/bin/rage/main.rs b/rage/src/bin/rage/main.rs index 41c1780..372add3 100644 --- a/rage/src/bin/rage/main.rs +++ b/rage/src/bin/rage/main.rs @@ -9,7 +9,13 @@ use age::{ Recipient, }; use gumdrop::{Options, ParsingStyle}; +use i18n_embed::{ + fluent::{fluent_language_loader, FluentLanguageLoader}, + DesktopLanguageRequester, +}; +use lazy_static::lazy_static; use log::{error, warn}; +use rust_embed::RustEmbed; use secrecy::ExposeSecret; use std::collections::HashMap; use std::fs::{read_to_string, File}; @@ -20,6 +26,23 @@ mod error; const ALIAS_PREFIX: &str = "alias:"; const GITHUB_PREFIX: &str = "github:"; +#[derive(RustEmbed)] +#[folder = "i18n"] +struct Translations; + +const TRANSLATIONS: Translations = Translations {}; + +lazy_static! { + static ref LANGUAGE_LOADER: FluentLanguageLoader = fluent_language_loader!(); +} + +#[macro_export] +macro_rules! fl { + ($message_id:literal) => {{ + i18n_embed_fl::fl!($crate::LANGUAGE_LOADER, $message_id) + }}; +} + /// Load map of aliases from the given file, or the default system location /// otherwise. /// @@ -118,8 +141,8 @@ fn read_recipients( } else if arg.starts_with(ALIAS_PREFIX) { #[cfg(not(feature = "unstable"))] { - eprintln!("Aliases are unstable."); - eprintln!("To test this, build rage with --features unstable"); + eprintln!("{}", fl!("unstable-aliases")); + eprintln!("{}", fl!("test-unstable")); } if seen_aliases.contains(&arg) { @@ -136,8 +159,8 @@ fn read_recipients( } else if arg.starts_with(GITHUB_PREFIX) { #[cfg(not(feature = "unstable"))] { - eprintln!("GitHub lookups are unstable, ignoring recipient."); - eprintln!("To test this, build rage with --features unstable"); + eprintln!("{}", fl!("unstable-github")); + eprintln!("{}", fl!("test-unstable")); continue; } @@ -235,14 +258,12 @@ fn encrypt(opts: AgeOptions) -> Result<(), error::EncryptError> { match read_or_generate_passphrase() { Ok(Passphrase::Typed(passphrase)) => age::Encryptor::with_user_passphrase(passphrase), Ok(Passphrase::Generated(new_passphrase)) => { - eprintln!("Using an autogenerated passphrase:"); + eprintln!("{}", fl!("autogenerated-passphrase")); eprintln!(" {}", new_passphrase.expose_secret()); age::Encryptor::with_user_passphrase(new_passphrase) } Err(pinentry::Error::Cancelled) => return Ok(()), - Err(pinentry::Error::Timeout) => { - return Err(error::EncryptError::TimedOut("passphrase input".to_owned())) - } + Err(pinentry::Error::Timeout) => return Err(error::EncryptError::PassphraseTimedOut), Err(pinentry::Error::Encoding(e)) => { // Pretend it is an I/O error return Err(error::EncryptError::Io(io::Error::new( @@ -347,15 +368,13 @@ fn decrypt(opts: AgeOptions) -> Result<(), error::DecryptError> { } } - match read_secret("Type passphrase", "Passphrase", None) { + match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) { Ok(passphrase) => decryptor .decrypt(&passphrase, opts.max_work_factor) .map_err(|e| e.into()) .and_then(|input| write_output(input, output)), Err(pinentry::Error::Cancelled) => Ok(()), - Err(pinentry::Error::Timeout) => { - Err(error::DecryptError::TimedOut("passphrase input".to_owned())) - } + Err(pinentry::Error::Timeout) => Err(error::DecryptError::PassphraseTimedOut), Err(pinentry::Error::Encoding(e)) => { // Pretend it is an I/O error Err(error::DecryptError::Io(io::Error::new( @@ -397,6 +416,9 @@ fn main() -> Result<(), error::Error> { env_logger::builder().format_timestamp(None).init(); + let requested_languages = DesktopLanguageRequester::requested_languages(); + i18n_embed::select(&*LANGUAGE_LOADER, &TRANSLATIONS, &requested_languages).unwrap(); + let args = args().collect::>(); let opts = AgeOptions::parse_args(&args[1..], ParsingStyle::default()).unwrap_or_else(|e| {