Skip to main content

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 `&#x0;`, 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}