From 9bed12b5a4f87fa497a3d91330c9a1e75a82ad44 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 22 Jul 2026 11:16:24 -0500 Subject: [PATCH 1/3] Add print.mx_client_config(): mask token and password An mx_client_config had no print method, so auto-printing one at the console fell through to the default list print and showed the access token and password verbatim. That is the first thing anyone does with mx_client_load(), and the values then live in scrollback, screenshots, and pasted bug reports. The method prints every field with token/password (plus access_token and refresh_token, defensively) shown as or . sync_token is not treated as a secret: it is a cursor, and seeing it helps when debugging a stuck sync. unclass() remains the escape hatch for the rare case that needs the raw values. --- NAMESPACE | 2 ++ R/config.R | 64 ++++++++++++++++++++++++++++++++++ inst/tinytest/test_mx.client.R | 30 ++++++++++++++++ man/print.mx_client_config.Rd | 31 ++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 man/print.mx_client_config.Rd diff --git a/NAMESPACE b/NAMESPACE index 0153a1d..e1043ca 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -39,3 +39,5 @@ export(mx_send_media) export(mx_send_text) export(mx_sync_update) export(mx_with_relogin) + +S3method(print,mx_client_config) diff --git a/R/config.R b/R/config.R index 6b6ee81..2f101fa 100644 --- a/R/config.R +++ b/R/config.R @@ -79,6 +79,70 @@ mx_client_plain_list <- function(client) { out } +# Config fields holding credentials. sync_token is deliberately absent: +# it is a cursor, not a secret, and seeing it is useful when debugging. +mx_client_secret_fields <- c("token", "access_token", "refresh_token", + "password") + +mx_client_field_text <- function(name, value) { + set <- length(value) > 0L && + !all(is.na(value)) && + (!is.character(value) || any(nzchar(value))) + if (name %in% mx_client_secret_fields) { + return(if (set) { + "" + } else { + "" + }) + } + if (!set) { + return("") + } + if (is.list(value) || length(value) > 1L) { + return(sprintf("<%s[%d]>", class(value)[1L], length(value))) + } + as.character(value) +} + +#' Print a Matrix client config +#' +#' Prints the config field by field with credentials masked, so that an +#' interactive session, a screenshot, or a pasted bug report does not +#' leak the access token or password. Secret fields show whether they +#' are set (\code{}) or empty (\code{}) without showing +#' the value. Use \code{unclass(x)} or \code{str(unclass(x))} when the +#' raw credentials are genuinely needed. +#' +#' @param x An \code{mx_client_config}, as returned by +#' \code{mx_client_load()} or \code{mx_client_from_config()}. +#' @param ... Ignored. +#' @return \code{x}, invisibly. +#' @examples +#' cfg <- mx_client_from_config(list(server = "https://matrix.example.org", +#' token = "syt_secret_value", +#' user_id = "@bot:example.org", +#' device_id = "DEVICEID")) +#' cfg +#' @export +print.mx_client_config <- function(x, ...) { + cat("\n") + fields <- names(x) + labels <- c(paste0(fields, ":"), if (!is.null(attr(x, "path"))) "path:") + if (!length(labels)) { + cat(" (no fields)\n") + return(invisible(x)) + } + width <- max(nchar(labels)) + for (f in fields) { + cat(sprintf(" %-*s %s\n", width, paste0(f, ":"), + mx_client_field_text(f, x[[f]]))) + } + if (!is.null(attr(x, "path"))) { + cat(sprintf(" %-*s %s\n", width, "path:", attr(x, "path"))) + } + invisible(x) +} + #' Load a Matrix client config #' #' Reads a JSON config. If \code{path} or the derived environment variable diff --git a/inst/tinytest/test_mx.client.R b/inst/tinytest/test_mx.client.R index faa525b..c707a7e 100644 --- a/inst/tinytest/test_mx.client.R +++ b/inst/tinytest/test_mx.client.R @@ -106,3 +106,33 @@ unlink(tmp, recursive = TRUE) expect_true(is.function(mx.client::mx_room_encrypted)) expect_equal(names(formals(mx.client::mx_room_encrypted)), c("client", "room", "room_cache")) + +# --- print.mx_client_config masks credentials --- + +secret_cfg <- mx.client::mx_client_from_config( + list(server = "https://matrix.example", token = "syt_supersecret", + password = "hunter2", user_id = "@bot:example", device_id = "DEV", + room_id = "!room:ex", sync_token = "s99_cursor", + tools_filter = list()), + path = "/tmp/matrix.json" +) +out <- paste(capture.output(print(secret_cfg)), collapse = "\n") +# credentials never appear, in any form +expect_false(grepl("syt_supersecret", out, fixed = TRUE)) +expect_false(grepl("hunter2", out, fixed = TRUE)) +expect_true(grepl("token:\\s+", out)) +expect_true(grepl("password:\\s+", out)) +# non-secret fields stay visible, including the sync cursor +expect_true(grepl("@bot:example", out, fixed = TRUE)) +expect_true(grepl("s99_cursor", out, fixed = TRUE)) +expect_true(grepl("/tmp/matrix.json", out, fixed = TRUE)) +# an empty credential reads as unset rather than hidden +empty_cfg <- mx.client::mx_client_from_config( + list(server = "https://matrix.example", token = "", user_id = "@b:e") +) +out_empty <- paste(capture.output(print(empty_cfg)), collapse = "\n") +expect_true(grepl("token:\\s+", out_empty)) +# printing returns the object unchanged, and invisibly +capture.output(vis <- withVisible(print(secret_cfg))) +expect_false(vis$visible) +expect_identical(vis$value, secret_cfg) diff --git a/man/print.mx_client_config.Rd b/man/print.mx_client_config.Rd new file mode 100644 index 0000000..9fdc376 --- /dev/null +++ b/man/print.mx_client_config.Rd @@ -0,0 +1,31 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{print.mx_client_config} +\alias{print.mx_client_config} +\title{Print a Matrix client config} +\usage{ +\method{print}{mx_client_config}(x, ...) +} +\arguments{ +\item{x}{An \code{mx_client_config}, as returned by +\code{mx_client_load()} or \code{mx_client_from_config()}.} + +\item{...}{Ignored.} +} +\value{ +\code{x}, invisibly. +} +\description{ +Prints the config field by field with credentials masked, so that an +interactive session, a screenshot, or a pasted bug report does not +leak the access token or password. Secret fields show whether they +are set (\code{}) or empty (\code{}) without showing +the value. Use \code{unclass(x)} or \code{str(unclass(x))} when the +raw credentials are genuinely needed. +} +\examples{ +cfg <- mx_client_from_config(list(server = "https://matrix.example.org", + token = "syt_secret_value", + user_id = "@bot:example.org", + device_id = "DEVICEID")) +cfg +} From b05c60f09a8e496c750c1fc50c309b5fcd440162 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 22 Jul 2026 11:38:58 -0500 Subject: [PATCH 2/3] rformat pass --- R/messages.R | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/R/messages.R b/R/messages.R index 72fef3a..1320865 100644 --- a/R/messages.R +++ b/R/messages.R @@ -213,19 +213,17 @@ mx_accept_invites <- function(client, invites) { #' target_event_id = "$msg") #' @export mx_extract_reaction_verdict <- function(sync_resp, room_id, self_id, - target_event_id, - approve_keys = NULL, + target_event_id, approve_keys = NULL, deny_keys = NULL) { # Emoji defaults are built here, not in the signature, so they don't # land as raw astral-plane glyphs in the .Rd \usage block -- LaTeX # can't typeset them and the PDF manual fails R CMD check --as-cran. if (is.null(approve_keys)) { - approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705), - "y", "yes", "ok") + approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705), "y", "yes", + "ok") } if (is.null(deny_keys)) { - deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C), - "n", "no", "nope") + deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C), "n", "no", "nope") } room <- sync_resp$rooms$join[[room_id]] if (is.null(room)) { From f45a23e99c897060dece6867fabeb48d54738668 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 22 Jul 2026 11:38:58 -0500 Subject: [PATCH 3/3] Bump version to 0.1.1.1 --- DESCRIPTION | 2 +- NEWS.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2cf3339..4f09ed4 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.1.1 +Version: 0.1.1.1 Date: 2026-06-13 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index b40ffaf..bb00fc8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,11 @@ +# mx.client 0.1.1.1 + +* `print()` method for `mx_client_config`: masks `token` and `password` + as `` (or `` when empty) instead of falling through to + the default list print, which showed credentials verbatim. `sync_token` + and every other field stay visible. `unclass()` still exposes the raw + values for the cases that need them. + # mx.client 0.1.1 * First release. Stateful client layer over 'mx.api': configuration