protocol v6: per-severity diagnostic underline colors (SGR 58)
M4.6 follow-up piece 2. `Style` gains `underline_color: Color` (Default = follow the text color) so a diagnostic squiggle can be red/yellow/cyan/gray without clobbering the syntax color of the text it underlines — exactly why error_style() left its 'red' unwired until now. The wire consequence: Style rides inside Cell / CellDelta / Snapshot / StyleSpans, so this is the protocol's first encoding-breaking change. PROTOCOL_VERSION 5 → 6 and SUPPORTED_PROTOCOL_VERSIONS narrows to [6]: postcard is not self-describing, so no per-session send gate can keep a v5 peer decoding v6 cells — a mismatched pair now fails the handshake with a clean VersionMismatch instead of garbling mid-session. Version policy tests rewritten to pin the new contract. Surface wiring: - diag.rs: per-severity underline_color (indexed 1/3/6/8). - frontend.rs: kitty-style CSI 4:N for Double/Curly/Dotted/Dashed (previously flattened to plain SGR 4) + SGR 58:5/58:2 emission. - ansi.rs: parse SGR 58/59 with the 38/48 extended-color grammar. - overlay.rs merge_styles: non-default-wins, like fg/bg/underline. - lua_bindings.rs: underline_color on Lua style tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a0bd4d7f6c
commit
72799f771a
|
|
@ -125,6 +125,11 @@ pub struct Style {
|
|||
pub underline: UnderlineStyle,
|
||||
/// Reverse video.
|
||||
pub reverse: bool,
|
||||
/// Underline color (SGR 58/59). `Color::Default` means "follow
|
||||
/// the text color": the underline draws in `fg`. Diagnostics set
|
||||
/// this per severity so the squiggle color can differ from the
|
||||
/// syntax-colored text it underlines (T M4.6, protocol v6).
|
||||
pub underline_color: Color,
|
||||
}
|
||||
|
||||
/// A non-text attachment carried in a cell (TUI ignores this).
|
||||
|
|
|
|||
|
|
@ -1020,7 +1020,18 @@ pub enum ResourceBody {
|
|||
/// frontend sends `Pointer` only when the instance's
|
||||
/// `Hello.protocol_version >= 5`, because an older instance would
|
||||
/// hard-error decoding the unknown variant.
|
||||
pub const PROTOCOL_VERSION: u32 = 5;
|
||||
///
|
||||
/// T M4.6 (diagnostic surface): bumped from 5 to 6 for
|
||||
/// `Style::underline_color`. Unlike every previous bump, this one
|
||||
/// changes the *encoding of an existing struct* — `Style` rides
|
||||
/// inside `Cell` / `CellDelta` / `Snapshot` / `StyleSpans`, messages
|
||||
/// every session receives — so per-session send gating cannot
|
||||
/// preserve compatibility (postcard is not self-describing; a v5
|
||||
/// decoder mis-reads any v6 `Style`). v6 binaries therefore accept
|
||||
/// only v6 peers: a version-mismatched pair fails the handshake with
|
||||
/// [`GoodbyeReason::VersionMismatch`] instead of garbling cell
|
||||
/// traffic mid-session.
|
||||
pub const PROTOCOL_VERSION: u32 = 6;
|
||||
|
||||
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
|
||||
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||||
|
|
@ -1044,7 +1055,15 @@ pub const PROTOCOL_VERSION: u32 = 5;
|
|||
/// Mouse framing Q#M1: extended to `[1, 2, 3, 4, 5]`. v5 peers may
|
||||
/// send `FrontendEvent::Pointer`; the frontend-side gate (see
|
||||
/// [`PROTOCOL_VERSION`]) keeps the variant off wires negotiated `< 5`.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2, 3, 4, 5];
|
||||
///
|
||||
/// T M4.6: narrowed to `[6]`. `Style::underline_color` changed the
|
||||
/// postcard encoding of every cell-carrying message, so v6 binaries
|
||||
/// cannot exchange cell traffic with any earlier wire. The v1–v5
|
||||
/// compat ladder (additive variants, per-session filtering) assumed
|
||||
/// shared-struct encodings never changed; this bump is the first
|
||||
/// that breaks that assumption, and slice membership is how the
|
||||
/// handshake communicates it.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
38
src/ansi.rs
38
src/ansi.rs
|
|
@ -931,6 +931,15 @@ impl AnsiParser {
|
|||
}
|
||||
}
|
||||
49 => self.current_style.bg = Color::Default,
|
||||
// 58/59 (underline color, kitty/mintty extension):
|
||||
// same extended-color grammar as 38/48 (T M4.6).
|
||||
58 => {
|
||||
if let Some((color, extra)) = parse_extended_color(p, ¶ms[i + 1..]) {
|
||||
self.current_style.underline_color = color;
|
||||
consumed_extra = extra;
|
||||
}
|
||||
}
|
||||
59 => self.current_style.underline_color = Color::Default,
|
||||
90..=97 => {
|
||||
self.current_style.fg =
|
||||
Color::Indexed(u8::try_from(p.main - 90 + 8).unwrap_or(0));
|
||||
|
|
@ -1266,6 +1275,35 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Underline color via SGR 58 (both colon-subparam and semicolon
|
||||
/// grammars, mirroring 38/48), reset via SGR 59 (T M4.6).
|
||||
#[test]
|
||||
fn m4_6_underline_color() {
|
||||
let cases: &[(&[u8], Color)] = &[
|
||||
(b"\x1b[58:5:1m", Color::Indexed(1)),
|
||||
(b"\x1b[58;5;124m", Color::Indexed(124)),
|
||||
(b"\x1b[58:2::255:0:0m", Color::Rgb(255, 0, 0)),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let mut p = AnsiParser::new();
|
||||
let evs = p.feed(input);
|
||||
let styles = collect_styles(&evs);
|
||||
assert_eq!(
|
||||
styles.first().map(|s| s.underline_color),
|
||||
Some(*expected),
|
||||
"input {input:?} should produce underline color {expected:?}; got {evs:?}"
|
||||
);
|
||||
}
|
||||
// 59 resets to follow-text-color.
|
||||
let mut p = AnsiParser::new();
|
||||
let evs = p.feed(b"\x1b[58:5:1m\x1b[59m");
|
||||
let styles = collect_styles(&evs);
|
||||
assert_eq!(
|
||||
styles.last().map(|s| s.underline_color),
|
||||
Some(Color::Default)
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Intra-line motion and erase
|
||||
// -----------------------------------------------------------------
|
||||
|
|
|
|||
45
src/diag.rs
45
src/diag.rs
|
|
@ -332,14 +332,14 @@ pub fn make_shared_store() -> SharedDiagStore {
|
|||
const TAB_WIDTH: u32 = 8;
|
||||
|
||||
/// Style applied to bytes covered by an `Error` diagnostic. Wavy
|
||||
/// underline in red so it composes with whatever the syntax view
|
||||
/// painted on top.
|
||||
/// underline colored via `underline_color` (not `fg`) so the
|
||||
/// squiggle reads red while the syntax view's text color survives
|
||||
/// underneath (T M4.6, protocol v6).
|
||||
fn error_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
// Red. Indexed 1 is portable across 8/16-color terminals.
|
||||
fg: Color::Default,
|
||||
bg: Color::Default,
|
||||
// Indexed 1 (red) is portable across 8/16-color terminals.
|
||||
underline_color: Color::Indexed(1),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -348,6 +348,8 @@ fn error_style() -> Style {
|
|||
fn warning_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
// Indexed 3: yellow.
|
||||
underline_color: Color::Indexed(3),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -356,6 +358,8 @@ fn warning_style() -> Style {
|
|||
fn info_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Single,
|
||||
// Indexed 6: cyan.
|
||||
underline_color: Color::Indexed(6),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -364,6 +368,9 @@ fn info_style() -> Style {
|
|||
fn hint_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Dotted,
|
||||
// Indexed 8: bright black ("gray") — present on 16-color
|
||||
// terminals, subtle by design for hints.
|
||||
underline_color: Color::Indexed(8),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -791,6 +798,33 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_styles_color_the_underline_not_the_text() {
|
||||
// T M4.6: each severity gets a distinct underline color via
|
||||
// `underline_color` (SGR 58); `fg` stays Default so the
|
||||
// syntax view's text color survives the merge.
|
||||
let mut seen = Vec::new();
|
||||
for s in [
|
||||
DiagnosticSeverity::Error,
|
||||
DiagnosticSeverity::Warning,
|
||||
DiagnosticSeverity::Information,
|
||||
DiagnosticSeverity::Hint,
|
||||
] {
|
||||
let style = style_for(s);
|
||||
assert_eq!(style.fg, Color::Default, "{s:?} must not set fg");
|
||||
assert_ne!(
|
||||
style.underline_color,
|
||||
Color::Default,
|
||||
"{s:?} must color its underline"
|
||||
);
|
||||
assert!(
|
||||
!seen.contains(&style.underline_color),
|
||||
"{s:?} reuses another severity's underline color"
|
||||
);
|
||||
seen.push(style.underline_color);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_advertises_diagnostic_kind() {
|
||||
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,
|
||||
|
|
@ -845,4 +879,5 @@ mod tests {
|
|||
"stale diagnostics must not underline shifted TUI bytes"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -634,16 +634,30 @@ fn apply_style<W: Write>(w: &mut W, style: &Style) -> io::Result<()> {
|
|||
}
|
||||
match style.underline {
|
||||
crate::cell::UnderlineStyle::None => {}
|
||||
crate::cell::UnderlineStyle::Single
|
||||
| crate::cell::UnderlineStyle::Double
|
||||
| crate::cell::UnderlineStyle::Curly
|
||||
| crate::cell::UnderlineStyle::Dotted
|
||||
| crate::cell::UnderlineStyle::Dashed => {
|
||||
// crossterm exposes Underlined; richer styles need a manual
|
||||
// CSI 4:N m emit, which most terminals fall back to plain
|
||||
// underline on. M2 can wire CSI 4:N m for kitty/iTerm.
|
||||
crate::cell::UnderlineStyle::Single => {
|
||||
queue!(w, SetAttribute(Attribute::Underlined))?;
|
||||
}
|
||||
// Kitty-style underline subparameters (CSI 4:N m). Terminals
|
||||
// that predate the extension treat the whole sequence as
|
||||
// SGR 4 (plain underline) or ignore the subparameter, so the
|
||||
// fallback is a straight underline — same as the previous
|
||||
// flatten-to-`Underlined` behavior (T M4.6).
|
||||
crate::cell::UnderlineStyle::Double => write!(w, "\x1b[4:2m")?,
|
||||
crate::cell::UnderlineStyle::Curly => write!(w, "\x1b[4:3m")?,
|
||||
crate::cell::UnderlineStyle::Dotted => write!(w, "\x1b[4:4m")?,
|
||||
crate::cell::UnderlineStyle::Dashed => write!(w, "\x1b[4:5m")?,
|
||||
}
|
||||
// Underline color (SGR 58, colon subparameter form). Emitted only
|
||||
// when set and an underline is present: the SGR 0 at the top of
|
||||
// this function already reset the underline color to
|
||||
// follow-text-color (SGR 59 state), which is what
|
||||
// `Color::Default` means.
|
||||
if style.underline != crate::cell::UnderlineStyle::None {
|
||||
match style.underline_color {
|
||||
Color::Default => {}
|
||||
Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m")?,
|
||||
Color::Indexed(n) => write!(w, "\x1b[58:5:{n}m")?,
|
||||
}
|
||||
}
|
||||
if style.reverse {
|
||||
queue!(w, SetAttribute(Attribute::Reverse))?;
|
||||
|
|
@ -771,6 +785,54 @@ mod tests {
|
|||
assert!(s.contains("\x1b[1m"), "missing bold-on in {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curly_underline_emits_csi_4_3_and_underline_color() {
|
||||
// A diagnostic-style cell: curly underline colored red via
|
||||
// SGR 58 (T M4.6). The text color must NOT be touched.
|
||||
let span = DiffSpan {
|
||||
start: CellCoord::new(0, 0),
|
||||
cells: vec![Cell {
|
||||
glyph: Glyph::Char('x'),
|
||||
style: Style {
|
||||
underline: crate::cell::UnderlineStyle::Curly,
|
||||
underline_color: crate::cell::Color::Indexed(1),
|
||||
..Style::default()
|
||||
},
|
||||
attachment: None,
|
||||
}],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
emit_span(&mut out, &span).unwrap();
|
||||
let s = String::from_utf8_lossy(&out);
|
||||
assert!(s.contains("\x1b[4:3m"), "missing curly underline in {s:?}");
|
||||
assert!(
|
||||
s.contains("\x1b[58:5:1m"),
|
||||
"missing underline color in {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_underline_color_emits_no_sgr_58() {
|
||||
// A plain single underline with follow-text color: no SGR 58
|
||||
// on the wire (the reset at the start of every style apply
|
||||
// already put the terminal in SGR 59 state).
|
||||
let span = DiffSpan {
|
||||
start: CellCoord::new(0, 0),
|
||||
cells: vec![Cell {
|
||||
glyph: Glyph::Char('x'),
|
||||
style: Style {
|
||||
underline: crate::cell::UnderlineStyle::Single,
|
||||
..Style::default()
|
||||
},
|
||||
attachment: None,
|
||||
}],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
emit_span(&mut out, &span).unwrap();
|
||||
let s = String::from_utf8_lossy(&out);
|
||||
assert!(!s.contains("\x1b[58"), "unexpected SGR 58 in {s:?}");
|
||||
}
|
||||
|
||||
// ---- status overlay (T M5.8) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -6248,6 +6248,7 @@ fn lua_to_style(t: &Table) -> mlua::Result<Style> {
|
|||
let fg: mlua::Value = t.get("fg").unwrap_or(mlua::Value::Nil);
|
||||
let bg: mlua::Value = t.get("bg").unwrap_or(mlua::Value::Nil);
|
||||
let underline: mlua::Value = t.get("underline").unwrap_or(mlua::Value::Nil);
|
||||
let underline_color: mlua::Value = t.get("underline_color").unwrap_or(mlua::Value::Nil);
|
||||
Ok(Style {
|
||||
fg: lua_to_color(&fg)?,
|
||||
bg: lua_to_color(&bg)?,
|
||||
|
|
@ -6255,17 +6256,19 @@ fn lua_to_style(t: &Table) -> mlua::Result<Style> {
|
|||
italic: t.get("italic").unwrap_or(false),
|
||||
underline: lua_to_underline(&underline)?,
|
||||
reverse: t.get("reverse").unwrap_or(false),
|
||||
underline_color: lua_to_color(&underline_color)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn style_to_lua(lua: &Lua, style: Style) -> mlua::Result<Table> {
|
||||
let t = lua.create_table_with_capacity(0, 6)?;
|
||||
let t = lua.create_table_with_capacity(0, 7)?;
|
||||
t.set("fg", color_to_lua(lua, style.fg)?)?;
|
||||
t.set("bg", color_to_lua(lua, style.bg)?)?;
|
||||
t.set("bold", style.bold)?;
|
||||
t.set("italic", style.italic)?;
|
||||
t.set("underline", underline_to_lua(style.underline))?;
|
||||
t.set("reverse", style.reverse)?;
|
||||
t.set("underline_color", color_to_lua(lua, style.underline_color)?)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,12 +125,12 @@ impl View for StyleSpanOverlay {
|
|||
|
||||
/// Merge `overlay` over `base`, producing a new [`Style`].
|
||||
///
|
||||
/// `fg`/`bg`/`underline` use "non-default wins" --- the overlay's
|
||||
/// value applies only when it is non-default; otherwise the base
|
||||
/// value is preserved. Boolean attributes (`bold`, `italic`,
|
||||
/// `reverse`) OR-blend so that two stacked overlays can each
|
||||
/// contribute. Pulled out of [`StyleSpanOverlay`] so
|
||||
/// [`crate::highlight::SyntaxHighlightView`] can reuse the same
|
||||
/// `fg`/`bg`/`underline`/`underline_color` use "non-default wins"
|
||||
/// --- the overlay's value applies only when it is non-default;
|
||||
/// otherwise the base value is preserved. Boolean attributes
|
||||
/// (`bold`, `italic`, `reverse`) OR-blend so that two stacked
|
||||
/// overlays can each contribute. Pulled out of [`StyleSpanOverlay`]
|
||||
/// so [`crate::highlight::SyntaxHighlightView`] can reuse the same
|
||||
/// composition rule (T M4.3).
|
||||
#[must_use]
|
||||
pub fn merge_styles(base: Style, overlay: Style) -> Style {
|
||||
|
|
@ -154,6 +154,11 @@ pub fn merge_styles(base: Style, overlay: Style) -> Style {
|
|||
overlay.underline
|
||||
},
|
||||
reverse: base.reverse || overlay.reverse,
|
||||
underline_color: if overlay.underline_color == Color::Default {
|
||||
base.underline_color
|
||||
} else {
|
||||
overlay.underline_color
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,6 +619,34 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_styles_underline_color_non_default_wins() {
|
||||
use crate::cell::Color;
|
||||
let base = Style {
|
||||
fg: Color::Indexed(2),
|
||||
underline: UnderlineStyle::Single,
|
||||
underline_color: Color::Indexed(6),
|
||||
..Default::default()
|
||||
};
|
||||
// Overlay sets its own underline color: it wins, while the
|
||||
// base's fg (syntax color) survives untouched.
|
||||
let diag_overlay = Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
underline_color: Color::Indexed(1),
|
||||
..Default::default()
|
||||
};
|
||||
let merged = merge_styles(base, diag_overlay);
|
||||
assert_eq!(merged.underline_color, Color::Indexed(1));
|
||||
assert_eq!(merged.fg, Color::Indexed(2));
|
||||
// Overlay with default underline color: base's is preserved.
|
||||
let plain_overlay = Style {
|
||||
bold: true,
|
||||
..Default::default()
|
||||
};
|
||||
let merged = merge_styles(base, plain_overlay);
|
||||
assert_eq!(merged.underline_color, Color::Indexed(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_viewport_spans_are_silently_skipped() {
|
||||
let buf = Buffer::from_bytes(BufferId::next(), "t", b"x\n");
|
||||
|
|
|
|||
135
src/protocol.rs
135
src/protocol.rs
|
|
@ -1683,34 +1683,34 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_five_for_pointer_events() {
|
||||
fn protocol_version_is_six_for_underline_color() {
|
||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
// bumped 3→4 (DispatchIdle for the optimistic-apply gate).
|
||||
// The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer,
|
||||
// byte-position gestures from semantic frontends). The
|
||||
// current binary serves v1..=v5 sessions — the slice-
|
||||
// membership handshake makes the relaxation symmetric.
|
||||
assert_eq!(PROTOCOL_VERSION, 5);
|
||||
// The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer).
|
||||
// T M4.6 bumped 5→6 (`Style::underline_color`) — the first
|
||||
// bump that changed an existing struct's postcard encoding,
|
||||
// so v6 binaries serve v6 sessions only.
|
||||
assert_eq!(PROTOCOL_VERSION, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_protocol_versions_includes_one_through_five() {
|
||||
// T M10.5: v1.0 binaries accept v1+v2. T M11.1: v1.1 binaries
|
||||
// accept v1+v2+v3. T M11.6: v4 binaries accept v1..=v4. Mouse
|
||||
// framing Q#M1: v5 binaries accept v1..=v5. The check is
|
||||
// slice membership, not strict equality, so older binaries
|
||||
// keep connecting to current binaries unchanged. v6+ is
|
||||
// rejected until the next bump.
|
||||
assert!(is_supported_protocol_version(1));
|
||||
assert!(is_supported_protocol_version(2));
|
||||
assert!(is_supported_protocol_version(3));
|
||||
assert!(is_supported_protocol_version(4));
|
||||
assert!(is_supported_protocol_version(5));
|
||||
assert!(!is_supported_protocol_version(0));
|
||||
assert!(!is_supported_protocol_version(6));
|
||||
assert!(!is_supported_protocol_version(u32::MAX));
|
||||
fn supported_protocol_versions_is_exactly_v6() {
|
||||
// T M4.6: `Style::underline_color` changed the encoding of
|
||||
// every cell-carrying message (`Cell` / `CellDelta` /
|
||||
// `Snapshot` / `StyleSpans`). The v1–v5 compat ladder relied
|
||||
// on shared-struct encodings never changing — additive enum
|
||||
// variants filtered per session — so the ladder ends here:
|
||||
// pre-v6 peers are refused at the handshake (a clean
|
||||
// VersionMismatch) rather than garbling postcard mid-session.
|
||||
assert!(is_supported_protocol_version(6));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 7, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v6 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2041,79 +2041,40 @@ mod tests {
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// T M10.5 — backward-compat handshake matrix tests.
|
||||
// Handshake version-policy tests.
|
||||
//
|
||||
// Four cases per the framing-pass handshake matrix:
|
||||
// 1. v1 daemon ↔ v1 frontend: pre-existing behavior; not retested.
|
||||
// 2. v1 daemon ↔ v2 frontend: rejected with VersionMismatch.
|
||||
// 3. v2 daemon ↔ v1 frontend: success; v1 session.
|
||||
// 4. v2 daemon ↔ v2 frontend: success; v2 session.
|
||||
//
|
||||
// These tests exercise `is_supported_protocol_version` directly
|
||||
// since the full daemon-attach path requires socket setup that's
|
||||
// in m5_5_acceptance.rs. The version-check predicate is the
|
||||
// load-bearing piece; daemon-level integration tests are in the
|
||||
// separate integration test file.
|
||||
// History: T M10.5 introduced the slice-membership relaxation
|
||||
// (`is_supported_protocol_version`) so v1.0 daemons could accept
|
||||
// v0.1 frontends, and the ladder grew through v5 (T M11.1, T
|
||||
// M11.6, mouse framing Q#M1) — all additive enum variants,
|
||||
// filtered per session, with shared-struct encodings untouched.
|
||||
// T M4.6 (`Style::underline_color`) changed a shared struct's
|
||||
// postcard encoding, ending the ladder: v6 binaries accept only
|
||||
// v6 peers. These tests exercise the predicate directly; the
|
||||
// daemon-level handshake integration lives in m5_5_acceptance.rs.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn m10_5_handshake_matrix_v2_daemon_accepts_v1_frontend() {
|
||||
// The relaxation that makes §sec:m10-backward-compat hold.
|
||||
assert!(
|
||||
is_supported_protocol_version(1),
|
||||
"v2 daemon must accept v1 frontend per §sec:m10-backward-compat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_5_handshake_matrix_v2_daemon_accepts_v2_frontend() {
|
||||
// The new case M10.5 enables.
|
||||
assert!(
|
||||
is_supported_protocol_version(2),
|
||||
"v2 daemon must accept v2 frontend (the v1.0 happy path)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_5_handshake_matrix_versions_outside_range_rejected() {
|
||||
// v1 daemon's strict-equality behavior is documented at the
|
||||
// v0.1 code level (different binary); the current daemon's
|
||||
// range check accepts v1..=v5 (T M11.1 added v3; T M11.6
|
||||
// added v4; the mouse framing Q#M1 added v5) and rejects v6+
|
||||
// until the next protocol bump.
|
||||
assert!(!is_supported_protocol_version(0));
|
||||
assert!(!is_supported_protocol_version(6));
|
||||
assert!(!is_supported_protocol_version(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_5_strict_equality_v1_frontend_simulation() {
|
||||
// T M10.5 framing-pass risk #5 verification: existing v1
|
||||
// frontends (the v0.1.0 release codebase, pre-M10.5) do
|
||||
// strict equality on Hello.protocol_version. Simulate that
|
||||
// check explicitly so the audit doc has empirical evidence
|
||||
// of the actual backward-compat surface.
|
||||
//
|
||||
// Before M10.5: `if hello.protocol_version != 1 { reject }`.
|
||||
// After M10.5: `if !is_supported_protocol_version(...) { reject }`.
|
||||
//
|
||||
// For a v1-strict-frontend connecting to a v2 daemon: the
|
||||
// daemon's Hello carries protocol_version=2; the v1-strict
|
||||
// frontend rejects with VersionMismatch.
|
||||
fn v1_strict_check(hello_version: u32) -> bool {
|
||||
hello_version == 1
|
||||
fn m4_6_handshake_rejects_every_pre_v6_wire() {
|
||||
// A v5-or-older peer cannot decode v6 cell traffic (postcard
|
||||
// is not self-describing), so the handshake must refuse the
|
||||
// session up front with VersionMismatch — slice membership is
|
||||
// how that policy is expressed.
|
||||
for old in 1..=5 {
|
||||
assert!(
|
||||
!is_supported_protocol_version(old),
|
||||
"v6 binary must refuse a v{old} peer: its Style encoding \
|
||||
predates underline_color and would mis-decode every CellDelta"
|
||||
);
|
||||
}
|
||||
// v1-strict frontend hitting v2 daemon's Hello: rejected.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m4_6_handshake_accepts_v6_peer() {
|
||||
assert!(
|
||||
!v1_strict_check(2),
|
||||
"v1-strict frontend rejects v2 daemon's Hello — pre-M10.5 binaries \
|
||||
can NOT connect to v2 daemons even though v2 daemons accept their requests"
|
||||
is_supported_protocol_version(PROTOCOL_VERSION),
|
||||
"the current wire version must accept itself"
|
||||
);
|
||||
// v1-strict frontend hitting v1 daemon's Hello: accepted.
|
||||
assert!(v1_strict_check(1));
|
||||
// For comparison, M10.5's relaxed check (v2 frontend after this milestone):
|
||||
assert!(is_supported_protocol_version(1));
|
||||
assert!(is_supported_protocol_version(2));
|
||||
}
|
||||
|
||||
// T M10.6 — PresenceUpdate wire shape tests.
|
||||
|
|
|
|||
Loading…
Reference in New Issue