1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
//! The one C escape table — libCom `epicsString.c`.
//!
//! EPICS escapes a byte in exactly one way, and the callers that print a
//! terminator, a trace line, a record's TINP or an echo mismatch all reach the
//! same table:
//!
//! ```text
//! \a \b \f \n \r \t \v \\ \' \" the named escapes
//! the byte itself when isprint() in the C locale
//! \xNN everything else, lower-case hex
//! ```
//!
//! There are two C entry points, and in C they diverge on **the NUL byte**
//! (CBUG-D4): `epicsStrnEscapedFromRaw` has a deliberate `case '\0'` printing
//! `\0` (:145), while `epicsStrPrintEscaped` forgot it, so NUL falls through to
//! the `\xNN` default and prints `\x00` (:259). Under the clean-is-the-goal
//! strategy (`doc/strategy-2026-07-13.md §2`) the port **refuses** that
//! divergence: both forms render NUL as `\0` — C's deliberate escape, the one
//! its round-trippable decoder `epicsStrnRawFromEscaped` was written to accept
//! (`case '0'`). The refusal is structural: [`escape`] holds the single table
//! and there is no per-form NUL parameter to disagree over, so the two renderings
//! are unrepresentable by construction. The C bug was a *missing* named case; the
//! fix supplies it to both, rather than deleting the deliberate one.
//!
//! The two entry points still differ where C genuinely differs — but not on the
//! escape table:
//!
//! - [`escaped_from_raw`] — `epicsStrnEscapedFromRaw` (epicsString.c:120-160)
//! writes into a *sized* destination. `epicsStrSnPrintEscaped` is a macro alias
//! for it (epicsString.h:125). This is what `asynShowEos`
//! (asynShellCommands.c:305), the echo interpose (asynInterposeEcho.c:73-74),
//! the trace errlog branch (asynManager.c:3159) and every `asynRecord` field
//! (asynRecord.c:725,1629,2005) escape through — each with its own buffer size,
//! which the caller must state.
//! - [`print_escaped`] — `epicsStrPrintEscaped` (epicsString.c:230-262), the
//! `FILE *` form, has no destination bound, and keeps C's `strlen(s) == 0`
//! first-byte-NUL early-return quirk (R17-49). This is what
//! `asynPortDriver::report` prints the EOS pair with
//! (asynPortDriver.cpp:3687,3690).
//!
//! Every escaping caller in this crate names one of the two, so the table exists
//! once. A private per-caller table is how the report came to escape `\r` and
//! `\n` and write a `\x03` terminator raw into stdout (R16-48).
/// C `epicsStrnEscapedFromRaw(dst, dstlen, src, srclen)` (epicsString.c:120-160)
/// — NUL escapes as `\0` (C's deliberate `case '\0'`), and **the destination
/// bound is part of the call**.
///
/// C's `OUT` macro is `ndst++; if (--rem > 0) *dst++ = chr` with `rem = dstlen`:
/// at most `dstlen - 1` escaped characters reach the buffer before the closing
/// NUL, and the cut falls wherever the count runs out — *inside* an escape pair
/// if that is where it lands, leaving a dangling backslash. Compiled libCom
/// (`epicsStrnEscapedFromRaw`, 200 raw CRLF bytes, `dstlen = 40`) returns 400
/// and leaves a 39-char string ending `\r\n\r\`. Every caller therefore states
/// the size of the C buffer it is writing into; there is no unbounded form of
/// this function in C.
pub
/// C `epicsStrPrintEscaped` (epicsString.c:230-262) — the `FILE *` form. C's
/// `switch` forgot the NUL case, so C prints `\x00`; the port **refuses that
/// CBUG-D4 divergence** and renders NUL as `\0`, identically to
/// [`escaped_from_raw`] (both reach the one [`escape`] table). It writes to a
/// stream, so it has no destination bound.
///
/// It takes an explicit `len`, yet guards on `strlen(s) == 0` (`:236-237`):
/// a buffer whose **first byte** is NUL is "no work to do" and prints
/// *nothing*, however many bytes follow it. A NUL anywhere later is escaped
/// normally — `strlen` only looks at the first one. Compiled libCom:
/// `epicsStrPrintEscaped(fp, "\0a", 2)` returns 0 and prints nothing, while
/// `("a\0b", 3)` prints an escaped interior NUL (C `a\x00b`; the port refuses
/// CBUG-D4 and renders `a\0b`). This first-byte-NUL early-return is a C quirk,
/// not a Rust convenience: the ESCAPE trace form on a `FILE *` sink prints an
/// empty data line for a payload that starts with NUL. It is a *separate*
/// behaviour from the NUL-rendering divergence, and is kept.
pub
/// The shared table. `max` is the number of escaped characters that fit in the
/// destination — C's `dstlen - 1`, or `usize::MAX` for the stream form. The cut
/// is per *character*, not per escape pair, which is what produces C's dangling
/// backslash.
///
/// NUL renders as `\0` for *both* entry points — the CBUG-D4 refusal is
/// structural: there is no NUL parameter for the two forms to disagree over.