use anyhow::{Context, Result, bail};
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs, UdpSocket};
use std::path::{Path, PathBuf};
use std::time::Duration;
pub trait ObjectSink: Send {
fn put(&mut self, relative: &str, data: &[u8]) -> Result<()>;
fn root(&self) -> String;
}
#[derive(Debug, Clone)]
pub struct FileSink {
pub dir: PathBuf,
}
impl FileSink {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
}
impl ObjectSink for FileSink {
fn put(&mut self, relative: &str, data: &[u8]) -> Result<()> {
let path = self.dir.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&path, data).with_context(|| format!("writing {}", path.display()))
}
fn root(&self) -> String {
self.dir.display().to_string()
}
}
#[derive(Debug, Clone)]
pub struct HttpPushSink {
pub base_url: String,
pub authorization: Option<String>,
}
impl HttpPushSink {
pub fn new(base_url: impl Into<String>) -> Self {
let mut base = base_url.into();
while base.ends_with('/') {
base.pop();
}
Self { base_url: base, authorization: None }
}
}
impl ObjectSink for HttpPushSink {
fn put(&mut self, relative: &str, data: &[u8]) -> Result<()> {
let url = format!("{}/{}", self.base_url, relative.trim_start_matches('/'));
crate::tls::http_put(&url, data, &crate::tls::HttpOptions::default())
.with_context(|| format!("HTTP PUT {url}"))
}
fn root(&self) -> String {
self.base_url.clone()
}
}
#[allow(dead_code)]
fn http_put_plain(url: &str, body: &[u8], authorization: Option<&str>) -> Result<()> {
let rest = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.context("only http:// and https:// push URLs are supported")?;
if url.starts_with("https://") {
return crate::tls::http_put(url, body, &crate::tls::HttpOptions::default());
}
let (hostport, path) =
rest.split_once('/').map(|(h, p)| (h, format!("/{p}"))).unwrap_or((rest, "/".into()));
let host = hostport.split(':').next().unwrap_or(hostport);
let addr = hostport
.to_socket_addrs()
.with_context(|| format!("resolving {hostport}"))?
.next()
.context("no addresses for push host")?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(10))
.with_context(|| format!("connecting to {hostport}"))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let mut req = format!(
"PUT {path} HTTP/1.1\r\nHost: {host}\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
);
if let Some(auth) = authorization {
req.push_str(&format!("Authorization: {auth}\r\n"));
}
req.push_str("Content-Type: application/octet-stream\r\n\r\n");
stream.write_all(req.as_bytes())?;
stream.write_all(body)?;
stream.flush()?;
let mut resp = Vec::new();
stream.read_to_end(&mut resp).ok();
let text = String::from_utf8_lossy(&resp);
let status = text.lines().next().unwrap_or("");
if !(status.contains(" 200 ")
|| status.contains(" 201 ")
|| status.contains(" 204 ")
|| status.contains(" 100 "))
{
let ok = text.contains(" 2") && status.starts_with("HTTP/");
if !ok {
bail!("push rejected: {status}");
}
}
Ok(())
}
pub fn read_input(spec: &str) -> Result<(String, Vec<u8>)> {
if let Some(addr) = spec.strip_prefix("udp://") {
let data = udp_ingest(addr, Duration::from_secs(3), 4 * 1024 * 1024)?;
return Ok((spec.to_string(), data));
}
let path = Path::new(spec);
let data = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
Ok((spec.to_string(), data))
}
fn udp_ingest(addr: &str, timeout: Duration, max_bytes: usize) -> Result<Vec<u8>> {
let sock = UdpSocket::bind(addr).with_context(|| format!("binding UDP {addr}"))?;
sock.set_read_timeout(Some(Duration::from_millis(500)))?;
let mut buf = vec![0u8; 65535];
let mut out = Vec::new();
let start = std::time::Instant::now();
let mut got_any = false;
while out.len() < max_bytes && start.elapsed() < timeout {
match sock.recv_from(&mut buf) {
Ok((n, _)) => {
out.extend_from_slice(&buf[..n]);
got_any = true;
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
if got_any {
break;
}
}
Err(e) => return Err(e).context("UDP recv"),
}
}
anyhow::ensure!(got_any, "UDP ingest on {addr}: no datagrams received within {timeout:?}");
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::thread;
#[test]
fn file_sink_writes_relative() {
let dir = std::env::temp_dir().join(format!("sheathe-io-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut sink = FileSink::new(&dir);
sink.put("a/b.txt", b"hello").unwrap();
assert_eq!(fs::read(dir.join("a/b.txt")).unwrap(), b"hello");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn http_put_round_trip() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match sock.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.windows(4).any(|w| w == b"\r\n\r\n")
&& buf.windows(9).any(|w| w == b"hello-seg")
{
break;
}
}
Err(_) => break,
}
}
let req = String::from_utf8_lossy(&buf);
assert!(req.contains("PUT /live/seg.m4s "), "req={req}");
assert!(req.contains("hello-seg"), "req={req}");
sock.write_all(
b"HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.unwrap();
});
let mut sink = HttpPushSink::new(format!("http://127.0.0.1:{port}/live"));
sink.put("seg.m4s", b"hello-seg").unwrap();
handle.join().unwrap();
}
}