Skip to main content

mesofact_dev/
s3.rs

1//! Dev-tier S3 surface (R490-F7).
2//!
3//! An FS-backed [s3s-fs] endpoint that `mesofact-dev` hosts so a workload's
4//! `@mesofact/runtime` `R2Adapter` can resolve against a *local* bucket during
5//! `bun run dev` instead of real Cloudflare R2. This fills the
6//! "build→PUT→read" contract that the R255-S5 spike explicitly left
7//! unexercised at tier 1 — that spike only ruled on the static-asset
8//! browser-GET path (still serve-off-disk); the runtime `r2` adapter is a
9//! separate consumer that *does* speak the S3 API.
10//!
11//! Design notes:
12//! - **Anonymous.** Bound to `127.0.0.1` on a dynamic port, this is a
13//!   single-tenant loopback dev appliance — no SigV4 verification. The
14//!   `R2Adapter` still signs with `aws4fetch` using whatever dummy creds the
15//!   workload injects; s3s ignores the signature when no auth is configured.
16//! - **Bucket = a pre-created dir under the state root.** s3s-fs treats
17//!   top-level dirs under its root as buckets, so creating `<root>/<bucket>/`
18//!   up front is enough for `PutObject` to land.
19//! - **Out of scope here (handed off):** seeding the bucket from `dist/`, and
20//!   injecting coords into the *in-process V8 SSR runtime* (which can't inherit
21//!   `process.env` the way the build subprocess does). See R490-F7.
22//!
23//! [s3s-fs]: https://docs.rs/s3s-fs
24
25use std::net::Ipv4Addr;
26use std::path::{Path, PathBuf};
27
28use anyhow::{Context, Result};
29use tokio::net::TcpListener;
30
31/// Default dev bucket name. Workloads point `[sources.r2] bucket` here in dev.
32pub const DEFAULT_BUCKET: &str = "dev";
33
34/// Dummy credentials the surface accepts. SigV4-signing clients (the publisher's
35/// `S3Store`, the JS `R2Adapter`) sign with these; s3s verifies the signature
36/// against them. Handed to consumers verbatim via [`DevS3::env_vars`].
37const DEV_ACCESS_KEY: &str = "dev";
38const DEV_SECRET_KEY: &str = "dev";
39
40/// Coordinates of a running dev S3 surface, handed to consumers (build-child
41/// env, discovery file) so they can point an S3 client at it.
42#[derive(Debug, Clone)]
43pub struct DevS3 {
44    /// e.g. `http://127.0.0.1:54321` — no trailing slash, path-style.
45    pub endpoint: String,
46    /// The single dev bucket, pre-created on disk.
47    pub bucket: String,
48    /// On-disk root backing the surface (`<state_dir>/s3`).
49    pub root: PathBuf,
50}
51
52impl DevS3 {
53    /// Start the surface: create `<root>/<bucket>/`, bind `127.0.0.1:0`, and
54    /// spawn the serve loop on the current tokio runtime. Returns the bound
55    /// coordinates; the server runs until the process exits.
56    pub async fn start(root: impl Into<PathBuf>, bucket: &str) -> Result<DevS3> {
57        let root = root.into();
58        let bucket_dir = root.join(bucket);
59        tokio::fs::create_dir_all(&bucket_dir)
60            .await
61            .with_context(|| format!("creating dev S3 bucket dir {}", bucket_dir.display()))?;
62
63        let service = build_service(&root)?;
64
65        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
66            .await
67            .context("binding dev S3 listener")?;
68        let addr = listener.local_addr().context("dev S3 local_addr")?;
69        let endpoint = format!("http://{addr}");
70
71        tokio::spawn(serve_loop(listener, service));
72
73        Ok(DevS3 {
74            endpoint,
75            bucket: bucket.to_string(),
76            root,
77        })
78    }
79
80    /// Conventional env vars mesofact-dev injects so a workload's `[sources.r2]`
81    /// can resolve in dev: `R2_ENDPOINT`, `R2_BUCKET`, plus dummy credentials
82    /// (the surface is anonymous, but the adapter's config still requires the
83    /// key/secret env vars to be present to register the source).
84    pub fn env_vars(&self) -> Vec<(String, String)> {
85        vec![
86            ("R2_ENDPOINT".to_string(), self.endpoint.clone()),
87            ("R2_BUCKET".to_string(), self.bucket.clone()),
88            ("R2_ACCESS_KEY_ID".to_string(), DEV_ACCESS_KEY.to_string()),
89            ("R2_SECRET_ACCESS_KEY".to_string(), DEV_SECRET_KEY.to_string()),
90        ]
91    }
92}
93
94/// Permissive access layer: allow every request, authenticated or not. s3s only
95/// consults `S3Access` when an auth provider is configured, so pairing this with
96/// [`SimpleAuth`](s3s::auth::SimpleAuth) means signed `dev/dev` requests verify
97/// AND unsigned loopback requests still pass — preserving the anonymous
98/// dev-appliance contract while unblocking SigV4 clients.
99struct AllowAllAccess;
100
101#[async_trait::async_trait]
102impl s3s::access::S3Access for AllowAllAccess {
103    async fn check(&self, _cx: &mut s3s::access::S3AccessContext<'_>) -> s3s::S3Result<()> {
104        Ok(())
105    }
106}
107
108fn build_service(root: &Path) -> Result<s3s::service::S3Service> {
109    use s3s::auth::SimpleAuth;
110    use s3s::service::S3ServiceBuilder;
111    // s3s_fs::Error doesn't impl std::error::Error, so map it by Display.
112    let fs = s3s_fs::FileSystem::new(root)
113        .map_err(|e| anyhow::anyhow!("opening s3s-fs at {}: {e:?}", root.display()))?;
114    let mut builder = S3ServiceBuilder::new(fs);
115    // Accept SigV4-signed requests. Without ANY auth provider s3s answers 501
116    // ("no authentication provider") to every *signed* request — which breaks
117    // the `S3Store`-based pointer/content reads the local publish→view loop
118    // needs (W270 §9), and the R2Adapter signs too. SimpleAuth verifies the
119    // dev/dev signature; AllowAllAccess keeps anonymous loopback access working,
120    // so the surface stays the single-tenant dev appliance it was.
121    builder.set_auth(SimpleAuth::from_single(DEV_ACCESS_KEY, DEV_SECRET_KEY));
122    builder.set_access(AllowAllAccess);
123    Ok(builder.build())
124}
125
126async fn serve_loop(listener: TcpListener, service: s3s::service::S3Service) {
127    use hyper_util::rt::{TokioExecutor, TokioIo};
128    use hyper_util::server::conn::auto::Builder as ConnBuilder;
129
130    let http = ConnBuilder::new(TokioExecutor::new());
131    loop {
132        let socket = match listener.accept().await {
133            Ok((socket, _)) => socket,
134            Err(e) => {
135                tracing::warn!(error = %e, "dev S3: accept failed");
136                continue;
137            }
138        };
139        // `.into_owned()` detaches the connection future from the borrowed
140        // builder so it can be spawned with a `'static` lifetime.
141        let conn = http
142            .serve_connection(TokioIo::new(socket), service.clone())
143            .into_owned();
144        tokio::spawn(async move {
145            if let Err(e) = conn.await {
146                tracing::debug!(error = %e, "dev S3: connection ended");
147            }
148        });
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[tokio::test]
157    async fn start_creates_bucket_dir() {
158        let tmp = tempfile::tempdir().unwrap();
159        let s3 = DevS3::start(tmp.path().join("s3"), DEFAULT_BUCKET)
160            .await
161            .unwrap();
162        assert!(s3.root.join(DEFAULT_BUCKET).is_dir());
163        assert!(s3.endpoint.starts_with("http://127.0.0.1:"));
164        assert_eq!(s3.bucket, DEFAULT_BUCKET);
165    }
166
167    #[tokio::test]
168    async fn put_then_get_round_trips() {
169        let tmp = tempfile::tempdir().unwrap();
170        let s3 = DevS3::start(tmp.path().join("s3"), DEFAULT_BUCKET)
171            .await
172            .unwrap();
173
174        let url = format!("{}/{}/hello.txt", s3.endpoint, s3.bucket);
175        let client = reqwest::Client::new();
176
177        // Anonymous PUT (s3s skips signature checks with no auth configured).
178        let put = client
179            .put(&url)
180            .body("WORLD")
181            .send()
182            .await
183            .expect("PUT request");
184        assert!(
185            put.status().is_success(),
186            "PUT status: {} body: {:?}",
187            put.status(),
188            put.text().await
189        );
190
191        let get = client.get(&url).send().await.expect("GET request");
192        assert!(get.status().is_success(), "GET status: {}", get.status());
193        assert_eq!(get.text().await.unwrap(), "WORLD");
194
195        // And it actually landed on disk under the bucket dir.
196        assert!(s3.root.join(DEFAULT_BUCKET).join("hello.txt").is_file());
197    }
198}