Skip to main content

nex_protocol/
server.rs

1//! The nex server: read one selector line, answer with bytes, close.
2
3use std::future::Future;
4use std::net::SocketAddr;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9use percent_encoding::percent_decode_str;
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::{TcpListener, TcpStream};
12
13/// One nex request: the selector path, exactly as sent (CR/LF trimmed).
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Request {
16    pub path: String,
17    pub peer: SocketAddr,
18}
19
20/// The application seam: turn a [`Request`] into response bytes. Nex has no
21/// status codes, so "not found" is whatever text the handler chooses.
22pub trait Handler: Send + Sync + 'static {
23    fn handle(&self, request: Request) -> impl Future<Output = Vec<u8>> + Send;
24}
25
26impl<F, Fut> Handler for F
27where
28    F: Fn(Request) -> Fut + Send + Sync + 'static,
29    Fut: Future<Output = Vec<u8>> + Send,
30{
31    fn handle(&self, request: Request) -> impl Future<Output = Vec<u8>> + Send {
32        self(request)
33    }
34}
35
36/// Server limits and timeouts.
37#[derive(Debug, Clone)]
38pub struct ServerConfig {
39    /// Maximum selector-line length (the spec is silent; this is the server
40    /// protecting itself). Default 2048.
41    pub max_request_line: usize,
42    /// Per-connection IO timeout. Default 30s.
43    pub timeout: Duration,
44}
45
46impl Default for ServerConfig {
47    fn default() -> Self {
48        Self {
49            max_request_line: 2048,
50            timeout: Duration::from_secs(30),
51        }
52    }
53}
54
55/// Accept connections on `listener` and serve them through `handler` until
56/// `shutdown` resolves.
57pub async fn serve(
58    listener: TcpListener,
59    handler: impl Handler,
60    config: ServerConfig,
61    shutdown: impl Future<Output = ()>,
62) -> std::io::Result<()> {
63    let handler = Arc::new(handler);
64    let config = Arc::new(config);
65    tokio::pin!(shutdown);
66    loop {
67        tokio::select! {
68            _ = &mut shutdown => break,
69            accepted = listener.accept() => match accepted {
70                Ok((stream, peer)) => {
71                    let handler = handler.clone();
72                    let config = config.clone();
73                    tokio::spawn(async move {
74                        if let Err(error) = handle_connection(stream, peer, handler, &config).await {
75                            log::debug!("nex: connection from {peer} failed: {error}");
76                        }
77                    });
78                }
79                Err(error) => log::warn!("nex: accept failed: {error}"),
80            },
81        }
82    }
83    Ok(())
84}
85
86async fn handle_connection(
87    mut stream: TcpStream,
88    peer: SocketAddr,
89    handler: Arc<impl Handler>,
90    config: &ServerConfig,
91) -> std::io::Result<()> {
92    // Selector: bytes up to LF (the spec's example is a telnet session, so
93    // accept bare LF as well as CRLF), capped.
94    let mut line = Vec::with_capacity(64);
95    let mut byte = [0u8; 1];
96    loop {
97        let count = tokio::time::timeout(config.timeout, stream.read(&mut byte))
98            .await
99            .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "selector read"))??;
100        if count == 0 {
101            break;
102        }
103        if byte[0] == b'\n' {
104            break;
105        }
106        line.push(byte[0]);
107        if line.len() >= config.max_request_line {
108            // Over-long selector: nex has no error channel; just close.
109            return stream.shutdown().await;
110        }
111    }
112    let path = String::from_utf8_lossy(&line)
113        .trim_end_matches('\r')
114        .to_string();
115
116    let body = handler.handle(Request { path, peer }).await;
117    tokio::time::timeout(config.timeout, stream.write_all(&body))
118        .await
119        .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "response write"))??;
120    stream.shutdown().await
121}
122
123/// A static-directory [`Handler`]. Directory selectors (empty or trailing
124/// `/`) serve a generated listing, or the directory's `index.nex` file when
125/// present. Traversal out of the root is refused with a plain-text message.
126#[derive(Debug, Clone)]
127pub struct FileHandler {
128    root: PathBuf,
129}
130
131impl FileHandler {
132    pub fn new(root: impl Into<PathBuf>) -> Self {
133        Self { root: root.into() }
134    }
135
136    fn resolve(&self, request_path: &str) -> Option<PathBuf> {
137        let decoded = percent_decode_str(request_path).decode_utf8().ok()?;
138        let mut resolved = self.root.clone();
139        for segment in decoded.split('/') {
140            match segment {
141                "" | "." => continue,
142                ".." => return None,
143                segment if segment.contains(['\\', ':']) => return None,
144                segment => resolved.push(segment),
145            }
146        }
147        Some(resolved)
148    }
149
150    async fn listing_for(&self, dir: &PathBuf, request_path: &str) -> Vec<u8> {
151        let mut names = Vec::new();
152        if let Ok(mut entries) = tokio::fs::read_dir(dir).await {
153            while let Ok(Some(entry)) = entries.next_entry().await {
154                let mut name = entry.file_name().to_string_lossy().into_owned();
155                if entry.file_type().await.map(|kind| kind.is_dir()).unwrap_or(false) {
156                    name.push('/');
157                }
158                names.push(name);
159            }
160        }
161        names.sort();
162        let mut out = format!("{request_path}\n\n");
163        for name in names {
164            out.push_str("=> ");
165            out.push_str(&name);
166            out.push('\n');
167        }
168        out.into_bytes()
169    }
170}
171
172impl Handler for FileHandler {
173    async fn handle(&self, request: Request) -> Vec<u8> {
174        let Some(path) = self.resolve(&request.path) else {
175            return b"bad path\n".to_vec();
176        };
177        if crate::is_directory_path(&request.path) || path.is_dir() {
178            let index = path.join("index.nex");
179            if let Ok(body) = tokio::fs::read(&index).await {
180                return body;
181            }
182            return self
183                .listing_for(&path, if request.path.is_empty() { "/" } else { &request.path })
184                .await;
185        }
186        match tokio::fs::read(&path).await {
187            Ok(body) => body,
188            Err(_) => format!("{} not found\n", request.path).into_bytes(),
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::client::{FetchOptions, fetch};
197    use crate::{ListingLine, parse_listing};
198
199    async fn spawn(handler: impl Handler) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) {
200        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
201        let addr = listener.local_addr().unwrap();
202        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
203        tokio::spawn(async move {
204            let _ = serve(listener, handler, ServerConfig::default(), async {
205                let _ = rx.await;
206            })
207            .await;
208        });
209        (addr, tx)
210    }
211
212    fn options_for(addr: SocketAddr) -> FetchOptions {
213        FetchOptions {
214            connect_addr: Some(addr),
215            ..Default::default()
216        }
217    }
218
219    #[tokio::test]
220    async fn round_trip_echoes_the_selector() {
221        let (addr, _stop) = spawn(|request: Request| async move {
222            format!("you asked for {:?}\n", request.path).into_bytes()
223        })
224        .await;
225        let body = fetch("nex://example.test/hello.txt", &options_for(addr))
226            .await
227            .unwrap();
228        assert_eq!(body, b"you asked for \"/hello.txt\"\n");
229    }
230
231    #[tokio::test]
232    async fn file_handler_serves_files_listings_and_refuses_traversal() {
233        let dir = tempfile::TempDir::new().unwrap();
234        std::fs::write(dir.path().join("about.txt"), "hi\n").unwrap();
235        std::fs::create_dir(dir.path().join("nexlog")).unwrap();
236        std::fs::write(dir.path().join("nexlog").join("one.txt"), "post\n").unwrap();
237
238        let (addr, _stop) = spawn(FileHandler::new(dir.path())).await;
239
240        let file = fetch("nex://example.test/about.txt", &options_for(addr))
241            .await
242            .unwrap();
243        assert_eq!(file, b"hi\n");
244
245        let listing = fetch("nex://example.test/", &options_for(addr))
246            .await
247            .unwrap();
248        let lines = parse_listing(&String::from_utf8(listing).unwrap());
249        assert!(lines.contains(&ListingLine::Link {
250            url: "about.txt".to_string(),
251            label: None
252        }));
253        assert!(lines.contains(&ListingLine::Link {
254            url: "nexlog/".to_string(),
255            label: None
256        }));
257
258        // URL parsing normalizes dot segments away, so a hostile selector has
259        // to be sent raw — which is exactly what a hostile client would do.
260        let escape = crate::fetch_path("example.test", 1900, "/../secret", &options_for(addr))
261            .await
262            .unwrap();
263        assert_eq!(escape, b"bad path\n");
264    }
265
266    #[tokio::test]
267    async fn index_nex_takes_precedence_over_generated_listings() {
268        let dir = tempfile::TempDir::new().unwrap();
269        std::fs::write(dir.path().join("index.nex"), "welcome to my site\n").unwrap();
270        let (addr, _stop) = spawn(FileHandler::new(dir.path())).await;
271        let body = fetch("nex://example.test/", &options_for(addr))
272            .await
273            .unwrap();
274        assert_eq!(body, b"welcome to my site\n");
275    }
276}