fix(qbzd): mask secret values in import summary and distinguish reload refusal

cc
This commit is contained in:
vicrodh committed 2026-07-14 19:58:31 -06:00
1 parent 0b7cd6c5e3
commit 8021393925
4 files changed
+274 -25

No files matched your search

+69 -2
View File
@@ -115,6 +115,9 @@ pub struct PlanLine {
#[derive(Debug, Clone)]
pub struct DevicePick {
pub wanted: String,
/// The backend the options were enumerated for — the §5.4 prompt names it
/// ("Available on Alsa:").
pub backend: String,
pub options: Vec<(String, String)>,
}
@@ -209,6 +212,37 @@ impl Bundle {
serde_json::to_string_pretty(self).map_err(|e| BundleError::Io(e.to_string()))
}
/// True when the document actually CARRIES a secret value: an `auth` token,
/// or a non-blank scrobbler secret. The export-side §3 warning keys on this
/// — not on the auth domain alone (a bundle whose only secrets are scrobbler
/// tokens still needs the warning).
pub fn contains_secrets(&self) -> bool {
let auth_token = self
.domains
.get("auth")
.and_then(Value::as_object)
.and_then(|a| a.get("user_auth_token"))
.and_then(Value::as_str)
.map(|t| !t.is_empty())
.unwrap_or(false);
if auth_token {
return true;
}
self.domains
.get("integrations")
.and_then(|i| i.get("scrobblers"))
.and_then(Value::as_object)
.map(|s| {
["lastfm_session_key", "listenbrainz_token"].iter().any(|k| {
s.get(*k)
.and_then(Value::as_str)
.map(|v| !v.is_empty())
.unwrap_or(false)
})
})
.unwrap_or(false)
}
/// Parse a bundle from JSON text (import step 1). The version gate (step 2)
/// runs in [`plan`]; here we only require a JSON object with an integer
/// `schema_version`, so a malformed version is caught early.
@@ -555,6 +589,24 @@ fn applied_line(plan: &mut ImportPlan, key: &str, value: &Value, why: &str) {
plan.writes.push((key.to_string(), value.clone()));
}
/// SECRET-class applied line (§5.4): the VALUE COLUMN IS MASKED — the real
/// value goes only into the write list, never into the rendered summary
/// (terminal scrollback and CI logs are not a place for bearer tokens).
/// Non-empty → `(secret, applied)`; empty → `(empty)`, matching the §5.4 example.
fn applied_secret_line(plan: &mut ImportPlan, key: &str, value: &Value) {
let masked = match value.as_str() {
Some(s) if !s.is_empty() => "(secret, applied)",
_ => "(empty)",
};
plan.applied.push(PlanLine {
key: key.to_string(),
old: None,
new: masked.to_string(),
why: String::new(),
});
plan.writes.push((key.to_string(), value.clone()));
}
fn adapted_line(plan: &mut ImportPlan, key: &str, old: &Value, new: &Value, why: &str) {
plan.adapted.push(PlanLine {
key: key.to_string(),
@@ -761,6 +813,7 @@ fn plan_audio_machine(
if !opts.non_tty {
plan.device_pick = Some(DevicePick {
wanted: bundle_device.clone().unwrap_or_default(),
backend: pick_backend_name(map, current),
options: live.devices.clone(),
});
}
@@ -897,7 +950,7 @@ fn plan_audio_machine(
"audio.dsd_mode",
v,
&Value::String("convert".into()),
"pass --trust-dsd to keep DoP/native",
"pass --trust-dsd to keep DoP",
);
} else {
applied_line(plan, "audio.dsd_mode", v, "");
@@ -905,6 +958,20 @@ fn plan_audio_machine(
}
}
/// The backend the device options belong to: the bundle's backend when present,
/// else the target's current, else system default (for the §5.4 prompt line).
fn pick_backend_name(map: &Map<String, Value>, current: &AudioSettings) -> String {
let bundle_backend: Option<AudioBackendType> = map
.get("backend_type")
.and_then(|v| serde_json::from_value(v.clone()).ok());
backend_name(
bundle_backend
.or(current.backend_type)
.unwrap_or(AudioBackendType::SystemDefault),
)
.to_string()
}
fn backend_name(b: AudioBackendType) -> &'static str {
match b {
AudioBackendType::PipeWire => "PipeWire",
@@ -1027,7 +1094,7 @@ fn plan_integrations(value: &Value, opts: &ImportOptions, uid_will_exist: bool,
applied_line(plan, &full, v, "");
} else if SCROBBLER_SECRET.contains(&k.as_str()) {
if opts.include_auth {
applied_line(plan, &full, v, "secret, applied");
applied_secret_line(plan, &full, v);
} else {
plan.skipped
.push(skip_line(&full, "secrets require --include-auth"));
@@ -503,3 +503,74 @@ fn parse_rejects_missing_version() {
let err = Bundle::parse(r#"{ "audio": {} }"#).unwrap_err();
assert!(matches!(err, BundleError::VersionMalformed));
}
#[test]
fn secret_values_never_render_in_summary() {
// §5.4: SECRET-class values are MASKED in the summary lines — the raw value
// rides only the write list (terminal scrollback / CI logs must never see it).
let p = scratch("mask");
std::fs::create_dir_all(&p.data_root).unwrap();
write_last_user_id(&p.data_root, 1).unwrap();
let bundle = bundle_with(json!({
"integrations": { "scrobblers": {
"lastfm_session_key": "d580REALSECRET",
"listenbrainz_token": ""
} }
}));
let opts = ImportOptions { include_auth: true, ..Default::default() };
let plan = plan(&bundle, &p, &opts, &live()).expect("plan");
let key_line = find(&plan.applied, "integrations.scrobblers.lastfm_session_key").unwrap();
assert_eq!(key_line.new, "(secret, applied)");
let empty_line = find(&plan.applied, "integrations.scrobblers.listenbrainz_token").unwrap();
assert_eq!(empty_line.new, "(empty)");
// No rendered bucket line anywhere carries the raw secret.
let rendered = format!("{:?} {:?} {:?}", plan.applied, plan.adapted, plan.skipped);
assert!(!rendered.contains("d580REALSECRET"), "secret leaked into summary: {rendered}");
// The write list still carries the real value so apply works.
assert_eq!(
write_of(&plan, "integrations.scrobblers.lastfm_session_key"),
Some(&json!("d580REALSECRET"))
);
cleanup(&p);
}
#[test]
fn contains_secrets_keys_on_actual_secret_values() {
// §3 warning trigger: any secret VALUE present (auth token OR a non-blank
// scrobbler secret) — not the auth domain alone.
let none = bundle_with(json!({ "audio": { "gapless_enabled": true } }));
assert!(!none.contains_secrets());
let blank = bundle_with(json!({
"integrations": { "scrobblers": { "lastfm_session_key": "", "listenbrainz_token": "" } }
}));
assert!(!blank.contains_secrets(), "blank secrets are not secrets");
let scrob = bundle_with(json!({
"integrations": { "scrobblers": { "lastfm_session_key": "sk-live" } }
}));
assert!(scrob.contains_secrets());
let auth = bundle_with(json!({ "auth": { "user_auth_token": "tok" } }));
assert!(auth.contains_secrets());
}
#[test]
fn device_pick_names_the_backend() {
// §5.4 prompt: "Available on Alsa:" — the pick carries the backend name.
let p = scratch("pick-backend");
let bundle = bundle_with(json!({
"audio": { "output_device": "hw:9,9", "backend_type": "Alsa" }
}));
let plan = plan(&bundle, &p, &ImportOptions::default(), &live()).expect("plan");
let pick = plan.device_pick.expect("TTY plan must request a pick");
assert_eq!(pick.backend, "Alsa");
assert_eq!(pick.wanted, "hw:9,9");
cleanup(&p);
}