Skip to main content

max / makenotwork

bento: cover secret() resolution, the /retry route, and the /events WS Item (4) of the bento test-coverage task. Three previously-untested seams: - engine: secret() reads under secrets_root, trims the trailing newline, and refuses traversal/absolute/empty keys before any read. Nested relative keys stay allowed; a missing key surfaces the fs error, not a panic. - routes: the /retry handler itself (distinct from the release-view aggregation already covered) -- accepts a shipped target with a build id, and 400s on unshipped/malformed target and unknown app. - routes: /events HTTP->WS wiring end to end -- a real ws client on /events receives an emitted event as the flat kind-tagged JSON the TUI parses. The channel merge and lagged framing stay covered in crate::events. New dev-deps tokio-tungstenite (no TLS) + futures-util for the WS client. Non-vacuity checked by mutation (broke the trim and the WS send, both tests failed, reverted). Full lib suite 117 pass, clippy clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 16:22 UTC
Signed with PGP, not checked
Commit: 3c0a41a4d0056612f2c7bebac11118412ee99df8
Parent: 395c654
4 files changed, +205 insertions, -0 deletions
@@ -182,6 +182,7 @@
182 182 "async-trait",
183 183 "axum",
184 184 "chrono",
185 + "futures-util",
185 186 "http-body-util",
186 187 "metrics",
187 188 "metrics-exporter-prometheus",
@@ -199,6 +200,7 @@
199 200 "thiserror",
200 201 "tokio",
201 202 "tokio-stream",
203 + "tokio-tungstenite",
202 204 "toml",
203 205 "tower",
204 206 "tracing",
@@ -37,6 +37,11 @@
37 37 tower = { version = "0.5", features = ["util"] }
38 38 http-body-util = "0.1"
39 39 reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
40 + # WS client for the /events end-to-end test. No TLS backend (the test dials
41 + # plaintext ws://127.0.0.1), matching the transitive copy axum's `ws` feature
42 + # already compiles.
43 + tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] }
44 + futures-util = "0.3"
40 45
41 46 [lints.rust]
42 47 unused = "warn"
@@ -1833,6 +1833,78 @@
1833 1833 );
1834 1834 }
1835 1835
1836 + /// `secret(key)` reads a file under `secrets_root`, trims its trailing
1837 + /// newline (the shape of a here-doc'd token file), and refuses any key that
1838 + /// could escape the root. Covers the host-fn registered in `build_engine`.
1839 + #[tokio::test]
1840 + async fn secret_reads_under_root_and_blocks_traversal() {
1841 + let dir = tempfile::tempdir().unwrap();
1842 + let cfg = Config::for_tests(dir.path());
1843 + // Seed a secret and one in a nested subdir; a trailing newline that the
1844 + // read must strip.
1845 + std::fs::create_dir_all(&cfg.secrets_root).unwrap();
1846 + std::fs::write(cfg.secrets_root.join("token"), "s3cr3t\n").unwrap();
1847 + std::fs::create_dir_all(cfg.secrets_root.join("app")).unwrap();
1848 + std::fs::write(cfg.secrets_root.join("app").join("key"), "nested").unwrap();
1849 + // Plant a file OUTSIDE the root that a traversal key would reach.
1850 + std::fs::write(dir.path().join("outside"), "leak").unwrap();
1851 +
1852 + let cfg = Arc::new(cfg);
1853 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
1854 + let ctx = Arc::new(RecipeCtx::new(
1855 + AppId::new("demo"),
1856 + Version::parse("0.1.0").unwrap(),
1857 + "linux/x86_64".parse().unwrap(),
1858 + "fw13".into(),
1859 + "/tmp".into(),
1860 + vec![],
1861 + Kind::App,
1862 + 1,
1863 + Arc::new(std::collections::HashMap::new()),
1864 + Arc::new(std::collections::HashMap::new()),
1865 + pool,
1866 + crate::events::channel(),
1867 + cfg,
1868 + Arc::new(OtaRegistry::standard("https://makenot.work")),
1869 + tokio::runtime::Handle::current(),
1870 + Arc::new(AtomicBool::new(false)),
1871 + None,
1872 + ));
1873 + let engine = build_engine(&ctx);
1874 +
1875 + // Happy path: read + trim.
1876 + assert_eq!(
1877 + engine.eval::<String>(r#"secret("token")"#).unwrap(),
1878 + "s3cr3t"
1879 + );
1880 + // A multi-segment relative key is allowed.
1881 + assert_eq!(
1882 + engine.eval::<String>(r#"secret("app/key")"#).unwrap(),
1883 + "nested"
1884 + );
1885 +
1886 + // Traversal, absolute paths, and empty keys are refused BEFORE any read,
1887 + // so the file one `..` above the root is never disclosed.
1888 + for bad in [
1889 + r#"secret("../outside")"#,
1890 + r#"secret("/etc/passwd")"#,
1891 + r#"secret("")"#,
1892 + ] {
1893 + let err = engine.eval::<String>(bad).unwrap_err().to_string();
1894 + assert!(
1895 + err.contains("relative path under secrets_root"),
1896 + "`{bad}` should hit the traversal guard, got: {err}"
1897 + );
1898 + }
1899 + // A missing key surfaces the filesystem error, not a panic, and does not
1900 + // trip the traversal guard (it is a legitimate relative path).
1901 + let err = engine
1902 + .eval::<String>(r#"secret("nope")"#)
1903 + .unwrap_err()
1904 + .to_string();
1905 + assert!(err.contains("secret `nope`"), "got: {err}");
1906 + }
1907 +
1836 1908 /// The two failures that actually shipped, as regression cases.
1837 1909 #[test]
1838 1910 fn preflight_catches_a_dead_repository_url() {
@@ -920,4 +920,130 @@
920 920 .unwrap();
921 921 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
922 922 }
923 +
924 + // ---- /retry handler (the route itself, distinct from the release-view
925 + // aggregation a retry produces above) ----
926 +
927 + fn retry_req(json: &str) -> Request<Body> {
928 + Request::builder()
929 + .method("POST")
930 + .uri("/retry")
931 + .header("content-type", "application/json")
932 + .body(Body::from(json.to_string()))
933 + .unwrap()
934 + }
935 +
936 + #[tokio::test]
937 + async fn retry_accepts_a_single_shipped_target() {
938 + let tmp = tempfile::tempdir().unwrap();
939 + let app = router(test_state(tmp.path()).await);
940 + // Explicit version so the handler doesn't read a (nonexistent) version
941 + // file; the point here is the accept path, not version resolution.
942 + let resp = app
943 + .oneshot(retry_req(
944 + r#"{"app":"goingson","target":"linux/x86_64","version":"0.4.1"}"#,
945 + ))
946 + .await
947 + .unwrap();
948 + assert_eq!(resp.status(), StatusCode::OK);
949 + let v: serde_json::Value = serde_json::from_str(&body_string(resp).await).unwrap();
950 + assert_eq!(v["accepted"], true);
951 + assert_eq!(v["target"], "linux/x86_64", "echoes the retried target");
952 + assert!(
953 + v["build_id"].as_i64().is_some(),
954 + "a fresh single-target build id is returned"
955 + );
956 + }
957 +
958 + #[tokio::test]
959 + async fn retry_rejects_an_unshipped_target_as_bad_request() {
960 + // goingson does not ship windows; a retry of it is a 400, not a 500.
961 + let tmp = tempfile::tempdir().unwrap();
962 + let app = router(test_state(tmp.path()).await);
963 + let resp = app
964 + .oneshot(retry_req(
965 + r#"{"app":"goingson","target":"windows/x86_64","version":"0.4.1"}"#,
966 + ))
967 + .await
968 + .unwrap();
969 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
970 + assert!(body_string(resp).await.contains("does not ship target"));
971 + }
972 +
973 + #[tokio::test]
974 + async fn retry_rejects_a_malformed_target_as_bad_request() {
975 + let tmp = tempfile::tempdir().unwrap();
976 + let app = router(test_state(tmp.path()).await);
977 + let resp = app
978 + .oneshot(retry_req(
979 + r#"{"app":"goingson","target":"not-a-target","version":"0.4.1"}"#,
980 + ))
981 + .await
982 + .unwrap();
983 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
984 + }
985 +
986 + #[tokio::test]
987 + async fn retry_rejects_an_unknown_app_as_bad_request() {
988 + let tmp = tempfile::tempdir().unwrap();
989 + let app = router(test_state(tmp.path()).await);
990 + let resp = app
991 + .oneshot(retry_req(r#"{"app":"nope","target":"linux/x86_64"}"#))
992 + .await
993 + .unwrap();
994 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
995 + assert!(body_string(resp).await.contains("unknown app"));
996 + }
997 +
998 + // ---- /events websocket: the HTTP->WS wiring (upgrade + serialize + send).
999 + // The channel merge and lagged framing themselves are covered in
1000 + // `crate::events`; this asserts a real client on `/events` receives an
1001 + // emitted event as the flat-`kind` JSON the TUI parses. ----
1002 +
1003 + #[tokio::test]
1004 + async fn events_ws_streams_emitted_events_as_flat_json() {
1005 + use futures_util::StreamExt;
1006 + use tokio_tungstenite::tungstenite::Message as WsMessage;
1007 +
1008 + let tmp = tempfile::tempdir().unwrap();
1009 + let state = test_state(tmp.path()).await;
1010 + let events = state.events.clone();
1011 + let app = router(state);
1012 +
1013 + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1014 + let addr = listener.local_addr().unwrap();
1015 + tokio::spawn(async move {
1016 + axum::serve(listener, app).await.unwrap();
1017 + });
1018 +
1019 + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/events"))
1020 + .await
1021 + .expect("client connects to /events");
1022 +
1023 + // The handler subscribes to the bus inside `on_upgrade`, which can land
1024 + // just after the handshake returns; a broadcast has no backlog for a
1025 + // late subscriber, so re-emit until the first frame arrives rather than
1026 + // racing that window with a single send.
1027 + let ev = crate::events::Event::PublishFailed {
1028 + app: AppId::new("goingson"),
1029 + target: "macos/aarch64".parse().unwrap(),
1030 + channel: "stable".into(),
1031 + error: "boom".into(),
1032 + };
1033 + let mut frame = None;
1034 + for _ in 0..100 {
1035 + crate::events::emit(&events, ev.clone());
1036 + if let Ok(Some(Ok(WsMessage::Text(t)))) =
1037 + tokio::time::timeout(std::time::Duration::from_millis(50), ws.next()).await
1038 + {
1039 + frame = Some(t);
1040 + break;
1041 + }
1042 + }
1043 + let frame = frame.expect("received an event frame within the retry budget");
1044 + let v: serde_json::Value = serde_json::from_str(&frame).unwrap();
1045 + assert_eq!(v["kind"], "publish_failed", "flat kind-tagged wire shape");
1046 + assert_eq!(v["channel"], "stable");
1047 + assert!(v.get("event").is_none(), "not nested under `event`");
1048 + }
923 1049 }