netget/server/rss/mod.rs
1//! RSS (Really Simple Syndication) Feed Server
2//!
3//! Serves RSS 2.0 XML feeds over HTTP with LLM-controlled content.
4//! The LLM generates feed content dynamically for each request.
5
6pub mod actions;
7
8use crate::llm::action_helper::call_llm;
9use crate::llm::OllamaClient;
10use crate::protocol::Event;
11use crate::server::accept_bounded::{
12 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
13};
14use crate::server::connection::ConnectionId;
15use crate::server::rss::actions::{RssProtocol, RSS_FEED_REQUESTED_EVENT};
16use crate::state::app_state::AppState;
17use crate::state::server::ServerId;
18use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
19use crate::utils::WireFailure;
20use anyhow::Result;
21use hyper::body::Bytes;
22use hyper::server::conn::http1;
23use hyper::service::service_fn;
24use hyper::{Request, Response, StatusCode};
25use hyper_util::rt::TokioIo;
26use std::net::SocketAddr;
27use std::sync::Arc;
28use tokio::sync::mpsc::UnboundedSender;
29use tracing::{debug, error, warn};
30
31use crate::logging::emit::Log;
32
33/// How long a peer that has produced nothing at all may hold this connection.
34///
35/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
36/// feed reader sends it inside its dial path, so a peer that has connected and sent no byte
37/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
38/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
39///
40/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
41/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
42/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
43/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
44/// without consuming it, so the request line is still there for hyper afterwards.
45const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
46
47/// How long an established connection may sit **silent** between requests.
48///
49/// This bounds the silence and never a transfer. A request being answered holds
50/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
51/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
52/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
53/// ever runs on a connection with nothing in flight.
54///
55/// Two minutes, and shorter than the registries in this family on purpose: RSS has no session.
56/// Fetch and parse *is* the protocol, so a reader issues one request, reads the document and is
57/// done - there is no second request this connection is waiting for. What the bound covers is a
58/// poller that left a keep-alive connection open, and pollers run on a minutes-to-hours cycle
59/// and reconnect when they come back. Two minutes is still above nginx's `keepalive_timeout`
60/// default of 75s, so no conformant reader can be surprised by it.
61const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
62
63/// Concurrent connections this server admits.
64///
65/// Each admitted connection may hold one whole in-memory feed document, so the cap is what turns
66/// that per-connection bound into a total one. A feed is read by a handful of pollers, so 256 is
67/// far above any real reader population and far below what socket exhaustion needs.
68const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
69
70/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
71///
72/// `503 Service Unavailable` with `Retry-After`, in RSS's own vocabulary:
73/// every feed reader is an HTTP client and reads a 503 as an
74/// invitation to come back later, and a human with `curl` reads it directly.
75/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
76/// reason, under `decision=fail_closed_connection_cap`.
77const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
78 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
79
80/// RSS server - generates feeds dynamically via LLM
81pub struct RssServer;
82
83impl RssServer {
84 /// Spawn RSS server with LLM integration
85 pub async fn spawn_with_llm_actions(
86 listen_addr: SocketAddr,
87 llm_client: OllamaClient,
88 app_state: Arc<AppState>,
89 status_tx: UnboundedSender<String>,
90 server_id: ServerId,
91 ) -> Result<SocketAddr> {
92 // SO_REUSEADDR, like every other TCP server here: without it a restart on the same
93 // port fails with EADDRINUSE for the length of TIME_WAIT.
94 let listener =
95 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
96 let local_addr = listener.local_addr()?;
97
98 Log::new(Some(&status_tx)).info(format!("RSS server listening on {}", local_addr));
99
100 let llm_client = Arc::new(llm_client);
101 let protocol = Arc::new(RssProtocol::new());
102
103 // Spawn server loop
104 let task_registrar = app_state.clone();
105 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
106 let accept_handle = tokio::spawn(async move {
107 loop {
108 match accept_bounded(
109 &listener,
110 &limiter,
111 CONNECTION_CAP_REFUSAL,
112 "RSS",
113 Some(&status_tx),
114 )
115 .await
116 {
117 Ok((stream, peer_addr, permit)) => {
118 debug!("RSS connection from {}", peer_addr);
119
120 let connection_id =
121 ConnectionId::new(app_state.get_next_unified_id().await);
122 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
123
124 // Without this the connection never appears in the dashboard rail and
125 // its byte counters stay at zero for the life of the server.
126 let now = crate::utils::clock::Instant::now();
127 app_state
128 .add_connection_to_server(
129 server_id,
130 ConnectionState {
131 id: connection_id,
132 remote_addr: peer_addr,
133 local_addr: local_addr_conn,
134 bytes_sent: 0,
135 bytes_received: 0,
136 packets_sent: 0,
137 packets_received: 0,
138 last_activity: now,
139 status: ConnectionStatus::Active,
140 status_changed_at: now,
141 protocol_info: ProtocolConnectionInfo::empty(),
142 },
143 )
144 .await;
145 let _ = status_tx.send("__UPDATE_UI__".to_string());
146
147 let llm = Arc::clone(&llm_client);
148 let state = Arc::clone(&app_state);
149 let status = status_tx.clone();
150 let proto = Arc::clone(&protocol);
151
152 let task_owner = app_state.clone();
153 task_owner
154 .spawn_server_task(server_id, async move {
155 // Cloned before the first-byte bound below, because the close
156 // path needs it whether or not this peer ever spoke.
157 let conn_state = Arc::clone(&state);
158
159 // Held for the life of the connection, so the
160 // cap counts live clients, not accepts.
161 let _permit = permit;
162
163 // The first-byte bound, before hyper sees the
164 // socket. `peek` waits for data without
165 // consuming it, so the request line is still
166 // there for hyper afterwards, and it bounds
167 // exactly the case that needs bounding: a peer
168 // that connected and said nothing at all.
169 let spoke = matches!(
170 tokio::time::timeout(
171 FIRST_BYTE_READ_TIMEOUT,
172 stream.peek(&mut [0u8; 1]),
173 )
174 .await,
175 Ok(Ok(n)) if n > 0
176 );
177
178 if spoke {
179 let io = TokioIo::new(stream);
180 // Whether this connection is answering
181 // anything. The watchdog below reads it, so
182 // only genuine silence - never work in
183 // flight - can close a connection.
184 let activity = Arc::new(ConnectionActivity::new());
185 let activity_for_service = Arc::clone(&activity);
186 let service =
187 service_fn(move |req: Request<hyper::body::Incoming>| {
188 let llm = Arc::clone(&llm);
189 let state = Arc::clone(&state);
190 let status = status.clone();
191 let proto = Arc::clone(&proto);
192
193 // Busy for the whole of this request - the
194 // model round-trip included, and a `manual`
195 // rule parked for a human - so the idle
196 // watchdog can never close the connection an
197 // answer belongs to.
198 let activity = Arc::clone(&activity_for_service);
199 async move {
200 let _busy = activity.busy();
201 Self::handle_request(
202 req,
203 llm,
204 state,
205 status,
206 server_id,
207 connection_id,
208 proto,
209 )
210 .await
211 }
212 });
213
214 let conn = http1::Builder::new().serve_connection(io, service);
215 tokio::pin!(conn);
216 tokio::select! {
217 result = &mut conn => {
218 if let Err(e) = result {
219 error!("RSS connection error: {}", e);
220 }
221 }
222 // The idle bound, over `ConnectionActivity`
223 // rather than over a read: hyper owns every
224 // read once `serve_connection` starts, and
225 // keeps polling for frames while a request is
226 // being answered, so a deadline on those reads
227 // would fire mid-answer. A connection with work
228 // in flight is not idle at all.
229 _ = watch_idle(
230 Arc::clone(&activity),
231 IDLE_BETWEEN_REQUESTS_TIMEOUT,
232 ) => {
233 debug!(
234 "RSS connection {} idle for {}s; closing",
235 connection_id,
236 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
237 );
238 }
239 }
240 } else {
241 debug!(
242 "RSS peer {} sent no request within {}s; closing \
243 decision=fail_closed_first_byte_timeout",
244 peer_addr,
245 FIRST_BYTE_READ_TIMEOUT.as_secs()
246 );
247 }
248
249 conn_state
250 .remove_connection_from_server(server_id, connection_id)
251 .await;
252 })
253 .await;
254 }
255 Err(e) => {
256 // Break rather than continue: a persistent accept error (EMFILE, the
257 // socket closed under us) spins this loop at full CPU forever,
258 // logging on every iteration. The same fix `vnc` carries.
259 error!("RSS accept failed, stopping loop: {}", e);
260 Log::new(Some(&status_tx))
261 .error(format!("RSS accept failed, stopping loop: {}", e));
262 break;
263 }
264 }
265 }
266 });
267
268 // Register the accept loop so stop_server can abort it and release the port.
269 task_registrar
270 .register_server_task(server_id, accept_handle)
271 .await;
272
273 Ok(local_addr)
274 }
275
276 /// Handle HTTP request for RSS feed - calls LLM to generate content
277 #[allow(clippy::too_many_arguments)]
278 async fn handle_request(
279 req: Request<hyper::body::Incoming>,
280 llm_client: Arc<OllamaClient>,
281 app_state: Arc<AppState>,
282 status_tx: UnboundedSender<String>,
283 server_id: ServerId,
284 connection_id: ConnectionId,
285 protocol: Arc<RssProtocol>,
286 ) -> Result<Response<http_body_util::Full<Bytes>>, hyper::Error> {
287 let method = req.method().clone();
288 let uri = req.uri().clone();
289 let path = uri.path().to_string();
290 let headers_map = req.headers().clone();
291
292 // FileOnly: the rss_feed_requested event's own log_template already reports the
293 // request to the TUI at INFO.
294 Log::new(Some(&status_tx)).debug(format!("RSS request: {} {}", method, path));
295
296 // Approximate wire size of the request line and headers. hyper does not expose the
297 // exact framing; what the rail's `down` counter needs is a number that moves with
298 // traffic, not a byte-perfect one.
299 let bytes_in = method.as_str().len()
300 + uri.to_string().len()
301 + headers_map
302 .iter()
303 .map(|(k, v)| k.as_str().len() + v.len() + 4)
304 .sum::<usize>();
305 app_state
306 .update_connection_stats(
307 server_id,
308 connection_id,
309 Some(bytes_in as u64),
310 None,
311 Some(1),
312 None,
313 )
314 .await;
315
316 // Only support GET requests
317 if method != hyper::Method::GET {
318 return Ok(Self::counted(
319 &app_state,
320 server_id,
321 connection_id,
322 StatusCode::METHOD_NOT_ALLOWED,
323 None,
324 Bytes::from("Method Not Allowed"),
325 )
326 .await);
327 }
328
329 // Extract headers
330 let mut headers = std::collections::HashMap::new();
331 for (key, value) in headers_map.iter() {
332 if let Ok(value_str) = value.to_str() {
333 headers.insert(key.as_str().to_lowercase(), value_str.to_string());
334 }
335 }
336
337 // Create event for LLM
338 let event = Event::new(
339 &RSS_FEED_REQUESTED_EVENT,
340 serde_json::json!({
341 "path": path,
342 "headers": headers,
343 }),
344 );
345
346 Log::new(Some(&status_tx)).debug(format!("RSS calling LLM for feed: {}", path));
347
348 // Call LLM to generate RSS feed
349 match call_llm(
350 &llm_client,
351 &app_state,
352 server_id,
353 None,
354 &event,
355 protocol.as_ref(),
356 )
357 .await
358 {
359 Ok(execution_result) => {
360 // Log messages
361 let log = Log::new(Some(&status_tx));
362 for message in &execution_result.messages {
363 log.info(message);
364 }
365
366 // An action the executor refused never reaches `protocol_results` at all, so
367 // "no feed" and "the feed was rejected" look identical from there. That is the
368 // same collapse the 404 below exists to avoid, one layer up: without this, a
369 // `generate_rss_feed` missing its required title was answered 404 — the
370 // model's own way of saying the path has no feed — and the reason lived only
371 // in the log. `failures` is where the executor puts it.
372 let mut build_error: Option<String> = execution_result
373 .failures
374 .first()
375 .map(|f| format!("{}: {}", f.action, f.error));
376
377 // Process protocol results
378 for protocol_result in execution_result.protocol_results {
379 if let crate::llm::actions::protocol_trait::ActionResult::Custom {
380 name,
381 data,
382 } = protocol_result
383 {
384 if name == "generate_rss_feed" {
385 // Extract RSS feed data from LLM response
386 match Self::build_rss_from_llm_data(data) {
387 Ok(xml) => {
388 let xml_bytes = xml.into_bytes();
389 // FileOnly: the generate_rss_feed action's own
390 // log_template already reports "-> RSS feed: {title}"
391 // to the TUI at INFO.
392 Log::new(Some(&status_tx)).debug(format!(
393 "RSS {} decision=model_feed path={} ({} bytes)",
394 connection_id,
395 path,
396 xml_bytes.len()
397 ));
398
399 return Ok(Self::counted(
400 &app_state,
401 server_id,
402 connection_id,
403 StatusCode::OK,
404 Some("application/rss+xml; charset=utf-8"),
405 Bytes::from(xml_bytes),
406 )
407 .await);
408 }
409 Err(e) => {
410 // Keep looking: a later action may still carry a usable
411 // feed. If none does, this is why, and it is netget
412 // refusing the model's data rather than the model
413 // deciding this path has no feed.
414 build_error = Some(e.to_string());
415 }
416 }
417 }
418 }
419 }
420
421 if let Some(reason) = build_error {
422 // The model answered and the answer was unusable. That is netget
423 // refusing, not "this path has no feed", so it must not be a 404 - a
424 // reader told 404 stops polling. The reason goes to the log; the peer
425 // gets a category.
426 warn!(
427 "RSS {} decision=fail_closed_unusable_feed: {}",
428 connection_id, reason
429 );
430 Log::new(Some(&status_tx)).warn(format!(
431 "RSS {} decision=fail_closed_unusable_feed: {}",
432 connection_id, reason
433 ));
434 return Ok(Self::counted(
435 &app_state,
436 server_id,
437 connection_id,
438 StatusCode::INTERNAL_SERVER_ERROR,
439 Some("text/plain; charset=utf-8"),
440 Bytes::from(WireFailure::Unavailable.prefixed_text()),
441 )
442 .await);
443 }
444
445 // No generate_rss_feed at all. This is the model's own decision that the
446 // path carries no feed - the only way it has to say so - and 404 is the
447 // right answer. It is deliberately distinct from the unusable-feed path
448 // above and the backend-failure path below; the `decision=` tag is what
449 // tells the three apart in the log, because the status code cannot.
450 debug!("RSS {} decision=model_no_feed path={}", connection_id, path);
451 Log::new(Some(&status_tx)).debug(format!(
452 "RSS {} decision=model_no_feed path={}",
453 connection_id, path
454 ));
455 Ok(Self::counted(
456 &app_state,
457 server_id,
458 connection_id,
459 StatusCode::NOT_FOUND,
460 None,
461 Bytes::from("Feed Not Found"),
462 )
463 .await)
464 }
465 Err(e) => {
466 // The peer gets a category, the log gets the error. A saturated backend is
467 // transient and is answered 503 + Retry-After so a reader backs off rather
468 // than recording a permanent fault; anything else is 500.
469 let failure = WireFailure::classify(&e);
470 warn!(
471 "RSS {} decision=fail_closed_llm_error ({:?}): {}",
472 connection_id, failure, e
473 );
474 Log::new(Some(&status_tx)).warn(format!(
475 "RSS {} decision=fail_closed_llm_error: {}",
476 connection_id, e
477 ));
478
479 let (status, retry_after) = if failure.is_overloaded() {
480 (StatusCode::SERVICE_UNAVAILABLE, Some("5"))
481 } else {
482 (StatusCode::INTERNAL_SERVER_ERROR, None)
483 };
484 let body = Bytes::from(failure.prefixed_text());
485 let sent = body.len() as u64;
486 app_state
487 .update_connection_stats(
488 server_id,
489 connection_id,
490 None,
491 Some(sent),
492 None,
493 Some(1),
494 )
495 .await;
496 let mut builder = Response::builder()
497 .status(status)
498 .header("Content-Type", "text/plain; charset=utf-8");
499 if let Some(seconds) = retry_after {
500 builder = builder.header("Retry-After", seconds);
501 }
502 Ok(builder
503 .body(http_body_util::Full::new(body))
504 // Only fails on an invalid status or header, both literals here.
505 .unwrap_or_else(|_| {
506 Response::new(http_body_util::Full::new(Bytes::from_static(b"")))
507 }))
508 }
509 }
510 }
511
512 /// Build a response and record its bytes against the connection.
513 ///
514 /// Every exit from `handle_request` goes through here (or accounts for itself) so the
515 /// rail's `up` counter reflects what was actually written.
516 async fn counted(
517 app_state: &Arc<AppState>,
518 server_id: ServerId,
519 connection_id: ConnectionId,
520 status: StatusCode,
521 content_type: Option<&str>,
522 body: Bytes,
523 ) -> Response<http_body_util::Full<Bytes>> {
524 let sent = body.len() as u64;
525 app_state
526 .update_connection_stats(server_id, connection_id, None, Some(sent), None, Some(1))
527 .await;
528
529 let mut builder = Response::builder().status(status);
530 if let Some(ct) = content_type {
531 builder = builder.header("Content-Type", ct);
532 }
533 builder
534 .body(http_body_util::Full::new(body))
535 // Only fails on an invalid status or header value, and both are ours.
536 .unwrap_or_else(|_| Response::new(http_body_util::Full::new(Bytes::from_static(b""))))
537 }
538
539 /// Build RSS XML from LLM-generated data.
540 ///
541 /// Every string that reaches the document goes through [`xml_safe`] first. The `rss`
542 /// crate escapes text elements (`BytesText::new` escapes on write) and emits item
543 /// descriptions as CDATA split on `]]>` (`BytesCData::escaped`), so model content cannot
544 /// forge feed structure — `tests/server/rss/injection_test.rs` measures both. What it
545 /// does *not* do is filter the characters XML 1.0 forbids outright (NUL and most of C0),
546 /// which no escaping can represent: one of those in a title produces a well-formed-looking
547 /// document that every strict parser rejects. That is what `xml_safe` is for.
548 fn build_rss_from_llm_data(data: serde_json::Value) -> Result<String> {
549 use rss::{CategoryBuilder, ChannelBuilder, ItemBuilder};
550
551 // The three channel fields the action declares `required: true`. They used to be
552 // read with `unwrap_or("Untitled Feed")` / `unwrap_or("http://localhost")` /
553 // `unwrap_or("No description")`, so an answer that named none of them produced a
554 // plausible-looking feed instead of a failure, and the model was never told. A
555 // required field whose default asserts a result is the shape this codebase has been
556 // bitten by; refuse instead, and let the repair loop see the reason.
557 actions::validate_feed_data(&data)?;
558
559 let title = xml_safe(data["title"].as_str().unwrap_or_default());
560 let link = xml_safe(data["link"].as_str().unwrap_or_default());
561 let description = xml_safe(data["description"].as_str().unwrap_or_default());
562
563 let mut channel_builder = ChannelBuilder::default();
564 channel_builder
565 .title(title)
566 .link(link)
567 .description(description);
568
569 // Add optional channel fields
570 if let Some(language) = data["language"].as_str() {
571 channel_builder.language(Some(xml_safe(language)));
572 }
573 // Handle ttl as either string or number
574 if let Some(ttl_str) = data["ttl"].as_str() {
575 channel_builder.ttl(Some(xml_safe(ttl_str)));
576 } else if let Some(ttl_num) = data["ttl"].as_u64() {
577 channel_builder.ttl(Some(ttl_num.to_string()));
578 } else if let Some(ttl_num) = data["ttl"].as_i64() {
579 channel_builder.ttl(Some(ttl_num.to_string()));
580 }
581 if let Some(last_build_date) = data["last_build_date"].as_str() {
582 channel_builder.last_build_date(Some(xml_safe(last_build_date)));
583 }
584
585 // Extract items
586 let mut items = Vec::new();
587 if let Some(items_array) = data["items"].as_array() {
588 for item_data in items_array {
589 let mut item_builder = ItemBuilder::default();
590
591 if let Some(title) = item_data["title"].as_str() {
592 item_builder.title(Some(xml_safe(title)));
593 }
594 if let Some(link) = item_data["link"].as_str() {
595 item_builder.link(Some(xml_safe(link)));
596 }
597 if let Some(description) = item_data["description"].as_str() {
598 item_builder.description(Some(xml_safe(description)));
599 }
600 if let Some(author) = item_data["author"].as_str() {
601 item_builder.author(Some(xml_safe(author)));
602 }
603 if let Some(pub_date) = item_data["pub_date"].as_str() {
604 item_builder.pub_date(Some(xml_safe(pub_date)));
605 }
606 if let Some(guid) = item_data["guid"].as_str() {
607 item_builder.guid(Some(
608 rss::GuidBuilder::default().value(xml_safe(guid)).build(),
609 ));
610 }
611
612 // Add categories
613 if let Some(categories_array) = item_data["categories"].as_array() {
614 let categories: Vec<rss::Category> = categories_array
615 .iter()
616 .filter_map(|cat| {
617 if let Some(cat_str) = cat.as_str() {
618 Some(CategoryBuilder::default().name(xml_safe(cat_str)).build())
619 } else if let Some(cat_obj) = cat.as_object() {
620 let name = xml_safe(cat_obj.get("name")?.as_str()?);
621 let mut builder = CategoryBuilder::default();
622 builder.name(name);
623 if let Some(domain) = cat_obj.get("domain").and_then(|v| v.as_str())
624 {
625 // A category domain is an XML *attribute*. quick-xml
626 // escapes attribute values, so a quote cannot close it.
627 builder.domain(Some(xml_safe(domain)));
628 }
629 Some(builder.build())
630 } else {
631 None
632 }
633 })
634 .collect();
635 item_builder.categories(categories);
636 }
637
638 items.push(item_builder.build());
639 }
640 }
641
642 channel_builder.items(items);
643 let channel = channel_builder.build();
644
645 Ok(channel.to_string())
646 }
647}
648
649/// Drop the characters XML 1.0 §2.2 forbids in a document.
650///
651/// Escaping does not help here: there is no entity for `�`, and a raw NUL or C0 control in
652/// a title makes the whole feed unparseable to every conforming reader — the server would be
653/// answering 200 with a document `feed-rs`, `libxml2` and every browser reject. The legal
654/// control characters (tab, LF, CR) are kept, as are all of C1 and beyond, which XML 1.0
655/// permits in content.
656///
657/// Dropping rather than refusing is deliberate: a stray control byte inside model prose is not
658/// grounds to refuse the whole feed, and the alternative — passing it through — is the only
659/// option that produces a broken response.
660fn xml_safe(text: &str) -> String {
661 if text
662 .chars()
663 .all(|c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}'))
664 {
665 return text.to_string();
666 }
667 text.chars()
668 .filter(|c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}'))
669 .collect()
670}