Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion linux/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ What has to hold before the first release, for a user on GNOME 50 with Ubuntu 26
| 6 | Trusts the app with credentials: TLS, SSH tunnels, no password in plain text | Verify full by default, the TLS conformance suite, the Secret Service, and statement logging silenced | An outside security review |
| 7 | Recovers when the network drops or the database restarts | Health monitoring, backoff and replay rules with their tests, a stopped container in the liveness tests, and a session test that restarts PostgreSQL: the read in flight runs again, the save in flight does not and leaves no row | Nothing |
| 8 | Works with a screen reader, the keyboard alone and larger text | Label audits of the picker, the connect pages, the workspace, the connect dialog and preferences, labelled cells and real-key tests. Sorting from the cell menu, named filter fields and cell editors, focus handed on when a control hides, and announced query results and errors. Columns resize from the keyboard | A column resized from the keyboard comes back with expand on (`crates/app/src/ui/grid/column_factory.rs`), so it can draw wider than its saved width; the manual pass in the backlog |
| 9 | Sees no message that cannot be translated, even while only English ships | Every string the app writes is in the template. Core errors carry values the app words itself: CSV import, cell and JSON parsing, type and default syntax, the keyring, certificate files and unsupported features | The SSH host, username and jump host checks, and a failed write of the saved connections, still show the core crates' English detail |
| 9 | Sees no message that cannot be translated, even while only English ships | Every string the app writes is in the template. Core errors carry values the app words itself: CSV import, cell and JSON parsing, type and default syntax, the keyring, certificate files, unsupported features, the SSH host, username and jump host checks, and a failed write of the saved connections. A panic, an export encoder failure and an SSH failure with no sentence of its own show the app's words and log the detail | The ssh crate still words a few SSH details in English: the runtime directory checks, the exit status it puts before ssh's last line, a failed `ssh -G`, an ssh that would not start, and a master that exited without a line on stderr. A TLS failure the app does not classify shows the TLS library's text |
| 10 | Uses the app daily for a week without an unrecoverable failure | Nothing yet | A dated week of daily use |

## Out of scope
Expand Down
1 change: 1 addition & 0 deletions linux/changelog.d/3586.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Translatable error details for the SSH host, username and jump host fields, and when saving connections fails.
76 changes: 74 additions & 2 deletions linux/crates/app/src/ui/error_text/files.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
use std::io::ErrorKind;
use std::path::Path;

use gtk4::glib;
use tablepro_core::CertificateFileProblem;
use tablepro_storage::StorageError;

use crate::i18n::{gettext, gettext_f, ngettext_f};

/// A database file the file system refused, for a reason other than
/// permission or a missing file, which have their own variants.
/// A file the file system refused to write. The error's own text is
/// std's or GLib's, so only its kind is used.
pub(super) fn file_io_message(path: &Path, kind: ErrorKind) -> String {
let path = path.display().to_string();
let args = [("path", path.as_str())];
Expand All @@ -16,10 +18,32 @@ pub(super) fn file_io_message(path: &Path, kind: ErrorKind) -> String {
}
ErrorKind::ReadOnlyFilesystem => gettext_f("Could not write {path}: the disk is read-only.", &args),
ErrorKind::FileTooLarge => gettext_f("Could not write {path}: the file is too large.", &args),
ErrorKind::PermissionDenied => gettext_f("TablePro is not allowed to write {path}.", &args),
_ => gettext_f("Could not write {path}.", &args),
}
}

/// Why one of the app's own files could not be written, or `None` when
/// the failure was not the file system's.
pub fn storage_io_message(error: &StorageError) -> Option<String> {
match error {
StorageError::Io { path, source } | StorageError::Permissions { path, source } => {
Some(file_io_message(path, source.kind()))
}
StorageError::Write { path, source } => Some(file_io_message(path, glib_file_error_kind(source))),
_ => None,
}
}

fn glib_file_error_kind(error: &glib::Error) -> ErrorKind {
match error.kind::<glib::FileError>() {
Some(glib::FileError::Nospc) => ErrorKind::StorageFull,
Some(glib::FileError::Rofs) => ErrorKind::ReadOnlyFilesystem,
Some(glib::FileError::Acces | glib::FileError::Perm) => ErrorKind::PermissionDenied,
_ => ErrorKind::Other,
}
}

/// What is wrong with a CA, certificate or key file, as the part after
/// "cannot be used:".
pub(super) fn certificate_problem_text(problem: &CertificateFileProblem) -> String {
Expand Down Expand Up @@ -55,6 +79,7 @@ fn size_text(bytes: usize) -> String {
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::path::PathBuf;

use super::*;

Expand All @@ -72,6 +97,53 @@ mod tests {
assert!(!full.contains(&ErrorKind::StorageFull.to_string()), "{full}");
}

#[test]
fn a_refused_write_of_the_apps_own_file_is_worded_by_its_kind() {
let path = PathBuf::from("/home/ada/.local/share/tablepro/connections.json");
let io = |kind: ErrorKind| StorageError::Io {
path: path.clone(),
source: std::io::Error::from(kind),
};
let glib_write = |code: glib::FileError| StorageError::Write {
path: path.clone(),
source: glib::Error::new(code, "Failed to create file: No space left on device"),
};
let cases = [
(io(ErrorKind::StorageFull), "Could not write {path}: the disk is full."),
(
io(ErrorKind::PermissionDenied),
"TablePro is not allowed to write {path}.",
),
(io(ErrorKind::Interrupted), "Could not write {path}."),
(
glib_write(glib::FileError::Nospc),
"Could not write {path}: the disk is full.",
),
(
glib_write(glib::FileError::Rofs),
"Could not write {path}: the disk is read-only.",
),
(
glib_write(glib::FileError::Acces),
"TablePro is not allowed to write {path}.",
),
(glib_write(glib::FileError::Io), "Could not write {path}."),
];

for (error, expected) in cases {
let expected = expected.replace("{path}", &path.display().to_string());
assert_eq!(
storage_io_message(&error).as_deref(),
Some(expected.as_str()),
"{error:?}"
);
}
assert_eq!(
storage_io_message(&StorageError::Schema("the disk is full".to_owned())),
None
);
}

#[test]
fn every_certificate_problem_has_its_own_words() {
let problems = [
Expand Down
2 changes: 2 additions & 0 deletions linux/crates/app/src/ui/error_text/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ mod unsupported;
pub use ddl::{build_error_text, refusal_text, save_blocker_text};
pub use driver::{config_message, driver_message};
pub use edit::edit_parse_message;
pub use files::storage_io_message;
pub use operation::{connect_error_message, operation_error_message, task_failure_message};
pub use saved_connection::saved_connection_error_message;
pub use script::script_diagnostic_message;
pub use ssh::ssh_destination_problem_text;
pub use syntax::{expression_syntax_message, type_syntax_message};
pub use tls_remedy::{TlsRemedy, connect_remedy};
24 changes: 11 additions & 13 deletions linux/crates/app/src/ui/error_text/operation.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use tablepro_core::browse::BrowseSqlError;
use tablepro_core::dml::BuildSqlError;
use tablepro_core::statement::SqlBuildError;
use tablepro_session::runtime::TaskFailure;
use tablepro_session::runtime::{TaskFailure, TaskPanic};
use tablepro_session::{ConnectError, OperationError};
use tablepro_storage::KeyringError;

Expand All @@ -17,10 +17,7 @@ pub fn operation_error_message(error: &OperationError) -> String {
OperationError::Sql(error) => sql_build_message(error),
OperationError::Browse(error) => browse_message(error),
OperationError::Build(error) => build_message(error),
OperationError::Panicked(panic) => crate::i18n::gettext_f(
"TablePro hit an internal error: {detail}",
&[("detail", panic.message())],
),
OperationError::Panicked(panic) => panic_message(panic),
OperationError::Cancelled => crate::i18n::gettext("Stopped."),
OperationError::Closed => crate::i18n::gettext("The connection is closed."),
}
Expand All @@ -38,24 +35,25 @@ pub fn connect_error_message(error: &ConnectError) -> String {
crate::i18n::gettext_f("This build of TablePro has no {driver} driver.", &[("driver", driver)])
}
ConnectError::Cancelled => crate::i18n::gettext("The connection was cancelled."),
ConnectError::Panicked(panic) => crate::i18n::gettext_f(
"TablePro hit an internal error: {detail}",
&[("detail", panic.message())],
),
ConnectError::Panicked(panic) => panic_message(panic),
}
}

/// What to tell the user when the work itself died rather than failed.
pub fn task_failure_message(failure: &TaskFailure) -> String {
match failure {
TaskFailure::Panicked(panic) => crate::i18n::gettext_f(
"TablePro hit an internal error: {detail}",
&[("detail", panic.message())],
),
TaskFailure::Panicked(panic) => panic_message(panic),
TaskFailure::Aborted => crate::i18n::gettext("Stopped."),
}
}

/// The panic's text is about the code, in English, and the panic hook
/// has already written it to the journal.
fn panic_message(panic: &TaskPanic) -> String {
tracing::debug!(%panic, "shown as an internal error");
crate::i18n::gettext("TablePro hit an internal error.")
}

fn sql_build_message(error: &SqlBuildError) -> String {
match error {
SqlBuildError::Dml(bind) => crate::i18n::gettext_f(
Expand Down
24 changes: 16 additions & 8 deletions linux/crates/app/src/ui/error_text/saved_connection.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use tablepro_storage::StorageError;

use super::files::storage_io_message;
use super::operation::task_failure_message;
use super::secret::secret_message;
use crate::services::saved_connection_error::SavedConnectionError;
Expand All @@ -14,10 +15,8 @@ pub fn saved_connection_error_message(error: &SavedConnectionError) -> String {
SavedConnectionError::Storage(StorageError::DocumentUnavailable(_)) => {
crate::i18n::gettext("The saved connections could not be read, so nothing was changed.")
}
SavedConnectionError::Storage(error) => crate::i18n::gettext_f(
"The saved connections could not be written: {reason}",
&[("reason", &error.to_string())],
),
SavedConnectionError::Storage(error) => storage_io_message(error)
.unwrap_or_else(|| crate::i18n::gettext("The saved connections could not be written.")),
SavedConnectionError::Task(failure) => task_failure_message(failure),
}
}
Expand All @@ -39,9 +38,18 @@ mod tests {
saved_connection_error_message(&SavedConnectionError::NotFound(Uuid::nil())),
"The connection was deleted."
);
let written = saved_connection_error_message(&SavedConnectionError::Storage(StorageError::Schema(
"the disk is full".to_owned(),
)));
assert!(written.contains("the disk is full"), "{written}");
assert_eq!(
saved_connection_error_message(&SavedConnectionError::Storage(StorageError::Io {
path: "/home/ada/.local/share/tablepro/connections.json".into(),
source: std::io::Error::from(std::io::ErrorKind::StorageFull),
})),
"Could not write /home/ada/.local/share/tablepro/connections.json: the disk is full."
);
assert_eq!(
saved_connection_error_message(&SavedConnectionError::Storage(StorageError::Schema(
"the connections write task failed".to_owned(),
))),
"The saved connections could not be written."
);
}
}
43 changes: 39 additions & 4 deletions linux/crates/app/src/ui/error_text/ssh.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use tablepro_core::SshFailure;
use tablepro_core::{SshDestinationPart, SshDestinationProblem, SshFailure};

/// One sentence per way OpenSSH can fail, each naming what to change.
pub fn ssh_failure_message(failure: &SshFailure) -> String {
Expand All @@ -11,9 +11,9 @@ pub fn ssh_failure_message(failure: &SshFailure) -> String {
"TablePro's SSH helper is missing at {program}. Reinstall TablePro to connect over SSH.",
&[("program", &program.display().to_string())],
),
SshFailure::InvalidDestination { detail } => crate::i18n::gettext_f(
SshFailure::InvalidDestination { problem } => crate::i18n::gettext_f(
"The SSH host or jump host is not valid: {detail}",
&[("detail", detail)],
&[("detail", &ssh_destination_problem_text(problem))],
),
SshFailure::ConfigRejected { detail } => crate::i18n::gettext_f(
"ssh refused the configuration. Check ~/.ssh/config for this host: {detail}",
Expand Down Expand Up @@ -92,6 +92,41 @@ pub fn ssh_failure_message(failure: &SshFailure) -> String {
SshFailure::Protocol { detail } => {
crate::i18n::gettext_f("ssh printed something unexpected: {detail}", &[("detail", detail)])
}
other => crate::i18n::gettext_f("SSH failed: {detail}", &[("detail", &other.to_string())]),
other => {
tracing::warn!(%other, "shown as an SSH failure with no sentence of its own");
crate::i18n::gettext("The SSH connection failed.")
}
}
}

/// What is wrong with a host, username or jump host as typed, as the
/// part after "is not valid:" or "cannot be used:".
pub fn ssh_destination_problem_text(problem: &SshDestinationProblem) -> String {
use SshDestinationPart::{Host, User};

match problem {
SshDestinationProblem::Empty { part: Host } => crate::i18n::gettext("the host name is empty"),
SshDestinationProblem::Empty { part: User } => crate::i18n::gettext("the username is empty"),
SshDestinationProblem::LeadingDash { part: Host } => crate::i18n::gettext("the host name starts with “-”"),
SshDestinationProblem::LeadingDash { part: User } => crate::i18n::gettext("the username starts with “-”"),
SshDestinationProblem::Whitespace { part: Host } => {
crate::i18n::gettext("the host name contains a space or a control character")
}
SshDestinationProblem::Whitespace { part: User } => {
crate::i18n::gettext("the username contains a space or a control character")
}
SshDestinationProblem::Character { part: Host, character } => crate::i18n::gettext_f(
"the host name contains “{character}”",
&[("character", &character.to_string())],
),
SshDestinationProblem::Character { part: User, character } => crate::i18n::gettext_f(
"the username contains “{character}”",
&[("character", &character.to_string())],
),
SshDestinationProblem::PortZero => crate::i18n::gettext("the port cannot be 0"),
SshDestinationProblem::NotAPort { text } => {
crate::i18n::gettext_f("“{text}” is not a port number", &[("text", text)])
}
SshDestinationProblem::UnclosedBracket => crate::i18n::gettext("an IPv6 address is missing its closing “]”"),
}
}
83 changes: 81 additions & 2 deletions linux/crates/app/src/ui/error_text/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ use tablepro_core::credentials::{SecretError, SecretKind};
use tablepro_core::sql_syntax::script::{OpenConstruct, ScriptDiagnostic};
use tablepro_core::{
ConfigError, DriverError, ErrorCategory, LossPhase, NetworkEndpoint, ReadOnlyRefusal, ServerCode,
ServerDiagnostics, SshFailure, StatementOutcome, TimeoutPhase, TlsFailure, TransportError,
ServerDiagnostics, SshDestinationPart, SshDestinationProblem, SshFailure, StatementOutcome, TimeoutPhase,
TlsFailure, TransportError,
};
use tablepro_session::runtime::TaskPanic;
use tablepro_session::runtime::{TaskFailure, TaskPanic};
use tablepro_session::{ConnectError, OperationError};
use tablepro_storage::KeyringError;

Expand Down Expand Up @@ -339,6 +340,84 @@ fn an_unknown_key_says_what_the_configuration_allows() {
assert!(reviewable.contains("review"), "{reviewable}");
}

#[test]
fn an_invalid_ssh_destination_is_worded_by_the_app() {
let message = ssh_failure_message(&SshFailure::InvalidDestination {
problem: SshDestinationProblem::Character {
part: SshDestinationPart::Host,
character: '$',
},
});

assert_eq!(
message,
"The SSH host or jump host is not valid: the host name contains “$”"
);
}

#[test]
fn every_ssh_destination_problem_has_its_own_words() {
let problems = [
SshDestinationProblem::Empty {
part: SshDestinationPart::Host,
},
SshDestinationProblem::Empty {
part: SshDestinationPart::User,
},
SshDestinationProblem::LeadingDash {
part: SshDestinationPart::Host,
},
SshDestinationProblem::LeadingDash {
part: SshDestinationPart::User,
},
SshDestinationProblem::Whitespace {
part: SshDestinationPart::Host,
},
SshDestinationProblem::Whitespace {
part: SshDestinationPart::User,
},
SshDestinationProblem::Character {
part: SshDestinationPart::Host,
character: ';',
},
SshDestinationProblem::Character {
part: SshDestinationPart::User,
character: ';',
},
SshDestinationProblem::PortZero,
SshDestinationProblem::NotAPort { text: "ssh".to_owned() },
SshDestinationProblem::UnclosedBracket,
];

let texts: Vec<String> = problems.iter().map(ssh_destination_problem_text).collect();

for (problem, text) in problems.iter().zip(&texts) {
assert_ne!(text, &problem.to_string(), "{problem:?} shows its log text");
}
assert_eq!(
texts.iter().collect::<std::collections::HashSet<_>>().len(),
problems.len(),
"{texts:?}"
);
assert_eq!(texts[7], "the username contains “;”");
assert_eq!(texts[9], "“ssh” is not a port number");
}

#[test]
fn a_panic_shows_the_apps_sentence_and_never_the_panic_text() {
let panic = TaskPanic::from_payload(Box::new("index out of bounds: the len is 0".to_owned()));

let messages = [
operation_error_message(&OperationError::Panicked(panic.clone())),
connect_error_message(&ConnectError::Panicked(panic.clone())),
task_failure_message(&TaskFailure::Panicked(panic)),
];

for message in messages {
assert_eq!(message, "TablePro hit an internal error.");
}
}

#[test]
fn connect_error_text_covers_every_variant() {
let errors = [
Expand Down
Loading
Loading