Explorar o código

repo browser: resolve against the configured recipe-repo roots

repo_root_ok() required the process cwd to contain docker-images/ (the
launcher running from the launcher repo). In the HA add-on the cwd has
neither docker-images/ nor host-scripts/, so every /repo/... link in the
viewer returned 'repo path not allowed'. Resolution now tries the cwd
first (the dev case) then every config.docker_image_sources entry (the
synced recipe repos — their checkouts carry docker-images/ + recipes/).
A valid-shape path that no root has is 'file not found' (not 'not
allowed'). Regression test: cwd stripped, sources-only resolution.
Alexander Korolev hai 1 semana
pai
achega
f57c05e879
Modificáronse 4 ficheiros con 99 adicións e 19 borrados
  1. 57 17
      src/report/repo.rs
  2. 40 0
      src/report/tests.rs
  3. 1 1
      src/web/mod.rs
  4. 1 1
      src/web/tests.rs

+ 57 - 17
src/report/repo.rs

@@ -1,8 +1,11 @@
 //! Repo file browser behind `/repo/...` — the target of the viewer's toml
 //! links: build dirs under `docker-images/` (Dockerfile, versions.env,
 //! profiles, patches), built-in `host-scripts/`, and recipe-local files
-//! under `recipes/`. The root is the process cwd (the launcher runs from the
-//! repo root); paths are plain names, no traversal, top level restricted.
+//! under `recipes/`. Roots: the process cwd (the launcher running from a
+//! repo root) plus every `docker_image_sources` entry — the synced recipe
+//! repos (their checkouts carry `docker-images/` + `recipes/`, which is
+//! how the HA add-on serves them). Paths are plain names, no traversal,
+//! top level restricted.
 //!
 //! `toml_links` scans a recipe.toml for the repo-referencing values and
 //! builds the `{value, href, title}` list the viewer wraps into anchors.
@@ -15,23 +18,51 @@ fn repo_root_ok() -> Option<std::path::PathBuf> {
     (cwd.join("docker-images").is_dir() || cwd.join("host-scripts").is_dir()).then_some(cwd)
 }
 
+/// Resolution roots: the cwd (dev — the launcher repo itself) plus every
+/// configured `docker_image_sources` entry (the synced recipe repos).
+fn repo_roots(config: &crate::config::Config) -> Vec<std::path::PathBuf> {
+    let mut roots = Vec::new();
+    if let Some(cwd) = repo_root_ok() {
+        roots.push(cwd);
+    }
+    for s in &config.docker_image_sources {
+        let p = std::path::PathBuf::from(s);
+        if !roots.contains(&p) {
+            roots.push(p);
+        }
+    }
+    roots
+}
+
 fn plain_seg(s: &str) -> bool {
     !s.contains("..")
         && s.chars()
             .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
 }
 
-fn repo_resolve(path: &str) -> Option<std::path::PathBuf> {
-    let root = repo_root_ok()?;
+/// Resolve a repo path against every root; the first hit wins. `Ok(None)`
+/// = the path shape is valid but no root has it (→ "file not found");
+/// `Err(())` = the path shape itself is not allowed (bad top level,
+/// traversal, exotic characters).
+fn repo_resolve(
+    config: &crate::config::Config,
+    path: &str,
+) -> Result<Option<std::path::PathBuf>, ()> {
     let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
-    let first = segs.first().copied()?;
+    let first = segs.first().copied().ok_or(())?;
     if !matches!(first, "docker-images" | "host-scripts" | "recipes") {
-        return None;
+        return Err(());
     }
     if !segs.iter().all(|s| plain_seg(s)) {
-        return None;
+        return Err(());
+    }
+    for root in repo_roots(config) {
+        let p = segs.iter().fold(root, |p, s| p.join(s));
+        if p.exists() {
+            return Ok(Some(p));
+        }
     }
-    Some(segs.iter().fold(root, |p, s| p.join(s)))
+    Ok(None)
 }
 
 fn dir_listing(base: &str, rel: &str, p: &std::path::Path) -> String {
@@ -92,9 +123,19 @@ pub(crate) async fn repo_route(
 ) -> Response {
     use axum::http::StatusCode;
     use axum::response::IntoResponse;
-    let p = match repo_resolve(&path) {
-        Some(p) => p,
-        None => {
+    let p = match repo_resolve(&state.config, &path) {
+        Ok(Some(p)) => p,
+        Ok(None) => {
+            // valid shape, but no root has it
+            return (
+                StatusCode::NOT_FOUND,
+                axum::response::Html(
+                    "<!doctype html><meta charset=\"utf-8\">file not found".to_string(),
+                ),
+            )
+                .into_response();
+        }
+        Err(()) => {
             return (
                 StatusCode::NOT_FOUND,
                 axum::response::Html(
@@ -105,12 +146,11 @@ pub(crate) async fn repo_route(
         }
     };
     if p.is_dir() {
-        let root = repo_root_ok().unwrap_or_default();
-        let rel = p
-            .strip_prefix(&root)
-            .unwrap_or(&p)
-            .to_string_lossy()
-            .replace(std::path::MAIN_SEPARATOR_STR, "/");
+        // list under the root that actually served this path (the first
+        // existing one) — the rel path for the breadcrumb is the same
+        // cleaned `path` the client asked for
+        let rel = path.trim_end_matches('/').to_string();
+        let rel = rel.strip_prefix('/').unwrap_or(&rel).to_string();
         return axum::response::Html(dir_listing(&state.base, &rel, &p)).into_response();
     }
     let text = match std::fs::read_to_string(&p) {

+ 40 - 0
src/report/tests.rs

@@ -443,3 +443,43 @@ async fn refresher_publishes_on_change_only() {
     h.abort();
     fs::remove_dir_all(&dir).ok();
 }
+
+#[tokio::test]
+async fn repo_route_resolves_from_configured_sources() {
+    // The add-on case: the process cwd has no docker-images/ — the synced
+    // recipe-repo roots come from config.docker_image_sources instead.
+    use crate::web::router;
+    let base = std::env::temp_dir().join(format!("sm121-repo-route-{}", std::process::id()));
+    std::fs::create_dir_all(base.join("docker-images/kindling")).unwrap();
+    std::fs::write(
+        base.join("docker-images/kindling/Dockerfile"),
+        "FROM scratch\n",
+    )
+    .unwrap();
+    let old = std::env::current_dir().unwrap();
+    std::env::set_current_dir(std::env::temp_dir()).unwrap(); // cwd: no repo
+    let mut config = crate::web::tests::fake_config();
+    config.docker_image_sources.push(base.display().to_string());
+    let app = crate::web::tests::app_state_with(config, None);
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    tokio::spawn(async move { axum::serve(listener, router(app)).await.unwrap() });
+    let dir = reqwest::get(format!(
+        "http://{addr}/recipes/repo/docker-images/kindling/"
+    ))
+    .await
+    .unwrap();
+    let html = dir.text().await.unwrap();
+    assert!(
+        html.contains("Dockerfile"),
+        "the configured source root must serve the listing: {html}"
+    );
+    let file = reqwest::get(format!(
+        "http://{addr}/recipes/repo/docker-images/kindling/Dockerfile"
+    ))
+    .await
+    .unwrap();
+    assert_eq!(file.status(), 200, "files under the source root serve");
+    std::env::set_current_dir(old).unwrap();
+    let _ = std::fs::remove_dir_all(&base);
+}

+ 1 - 1
src/web/mod.rs

@@ -348,6 +348,6 @@ pub async fn run(
 #[cfg(test)]
 mod api_tests;
 #[cfg(test)]
-mod tests;
+pub(crate) mod tests;
 #[cfg(test)]
 mod ui_js_tests;

+ 1 - 1
src/web/tests.rs

@@ -59,7 +59,7 @@ pub(crate) fn app_state(token: Option<&str>) -> AppState {
     app_state_with(fake_config(), token)
 }
 
-fn app_state_with(config: Config, token: Option<&str>) -> AppState {
+pub(crate) fn app_state_with(config: Config, token: Option<&str>) -> AppState {
     let (tx, _) = tokio::sync::broadcast::channel(16);
     AppState {
         config: std::sync::Arc::new(config),