Skip to content

Commit 252d587

Browse files
authored
Merge pull request #10532 from Extra-Chill/fix/10408-reviewer-url-v2
fix(artifacts): return exact controller reviewer references
2 parents dfda7d3 + ab3364e commit 252d587

11 files changed

Lines changed: 601 additions & 25 deletions

File tree

crates/homeboy-core/src/artifact_links.rs

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use serde_json::Value;
22
use std::path::Path;
33
use std::time::Duration;
44

5+
use crate::error::{Error, Result};
56
use crate::execution_contract::encode_uri_component;
67
use crate::observation::{ArtifactRecord, ArtifactViewerLink};
78

@@ -81,6 +82,83 @@ pub fn public_artifact_url(artifact: &ArtifactRecord) -> Option<String> {
8182
))
8283
}
8384

85+
/// Return the controller's stable artifact route rather than a filesystem
86+
/// layout URL. Terminal handoffs use this route because it remains valid when
87+
/// the runner's artifact layout is unavailable after completion.
88+
pub fn controller_artifact_url(artifact: &ArtifactRecord) -> Result<Option<String>> {
89+
if artifact.artifact_type != "file" {
90+
return Err(Error::validation_invalid_argument(
91+
"artifact.type",
92+
"reviewer artifact URLs require a controller-owned file",
93+
Some(artifact.id.clone()),
94+
None,
95+
));
96+
}
97+
let Some(base) = reviewer_public_artifact_base_url()? else {
98+
return Ok(None);
99+
};
100+
Ok(Some(format!(
101+
"{}/runs/{}/artifacts/{}",
102+
base,
103+
encode_uri_component(&artifact.run_id),
104+
encode_uri_component(&artifact.id)
105+
)))
106+
}
107+
108+
/// Resolve the public artifact origin once at the configuration boundary.
109+
/// Terminal handoffs only advertise HTTPS URLs on reviewer-reachable hosts.
110+
pub fn reviewer_public_artifact_base_url() -> Result<Option<String>> {
111+
let configured = crate::defaults::load_config()
112+
.artifact_origin
113+
.public_base_url;
114+
let value = configured.or_else(|| std::env::var(PUBLIC_ARTIFACT_BASE_URL_ENV).ok());
115+
let Some(value) = value else {
116+
return Ok(None);
117+
};
118+
let value = value.trim().trim_end_matches('/');
119+
if value.is_empty() {
120+
return Ok(None);
121+
}
122+
let url = reqwest::Url::parse(value).map_err(|error| {
123+
Error::validation_invalid_argument(
124+
PUBLIC_ARTIFACT_BASE_URL_ENV,
125+
"public artifact origin must be a valid HTTPS URL",
126+
Some(error.to_string()),
127+
None,
128+
)
129+
})?;
130+
let host = url.host_str().unwrap_or_default();
131+
if url.scheme() != "https" || host.is_empty() || non_public_host(host) {
132+
return Err(Error::validation_invalid_argument(
133+
PUBLIC_ARTIFACT_BASE_URL_ENV,
134+
"public artifact origin must use HTTPS with a reviewer-reachable host",
135+
Some(value.to_string()),
136+
None,
137+
));
138+
}
139+
Ok(Some(value.to_string()))
140+
}
141+
142+
fn non_public_host(host: &str) -> bool {
143+
let host = host.trim_matches(['[', ']']);
144+
let lower = host.to_ascii_lowercase();
145+
if lower == "localhost" || lower.ends_with(".localhost") || lower.ends_with(".local") {
146+
return true;
147+
}
148+
match host.parse::<std::net::IpAddr>() {
149+
Ok(std::net::IpAddr::V4(ip)) => {
150+
ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified()
151+
}
152+
Ok(std::net::IpAddr::V6(ip)) => {
153+
ip.is_loopback()
154+
|| ip.is_unspecified()
155+
|| (ip.segments()[0] & 0xfe00) == 0xfc00
156+
|| (ip.segments()[0] & 0xffc0) == 0xfe80
157+
}
158+
Err(_) => false,
159+
}
160+
}
161+
84162
pub fn public_artifact_path_url(root: &Path, base: &str, path: &Path) -> Option<String> {
85163
let base = base.trim().trim_end_matches('/');
86164
if base.is_empty() {
@@ -241,7 +319,9 @@ fn artifact_is_fetchable(artifact: &ArtifactRecord) -> bool {
241319
|| artifact.artifact_type == "remote_file"
242320
}
243321

244-
fn probe_public_artifact_url(public_url: &str) -> Result<reqwest::StatusCode, reqwest::Error> {
322+
fn probe_public_artifact_url(
323+
public_url: &str,
324+
) -> std::result::Result<reqwest::StatusCode, reqwest::Error> {
245325
crate::http_probe::blocking_client(PUBLIC_ARTIFACT_URL_PROBE_TIMEOUT)?
246326
.get(public_url)
247327
.header(reqwest::header::RANGE, "bytes=0-0")
@@ -253,6 +333,9 @@ fn probe_public_artifact_url(public_url: &str) -> Result<reqwest::StatusCode, re
253333
mod tests {
254334
use super::*;
255335
use crate::observation::ArtifactRecord;
336+
use std::sync::{Mutex, MutexGuard};
337+
338+
static ENV_LOCK: Mutex<()> = Mutex::new(());
256339

257340
#[test]
258341
fn derives_viewer_link_from_public_artifact_url_metadata() {
@@ -346,6 +429,71 @@ mod tests {
346429
);
347430
}
348431

432+
#[test]
433+
fn controller_url_requires_a_public_https_origin_and_encodes_ids() {
434+
let _env = EnvGuard::set(
435+
PUBLIC_ARTIFACT_BASE_URL_ENV,
436+
"https://artifacts.example.test/reviewer/",
437+
);
438+
let artifact = ArtifactRecord {
439+
id: "source /?%".to_string(),
440+
run_id: "run /?%".to_string(),
441+
artifact_type: "file".to_string(),
442+
..Default::default()
443+
};
444+
445+
assert_eq!(
446+
controller_artifact_url(&artifact)
447+
.expect("valid reviewer origin")
448+
.as_deref(),
449+
Some("https://artifacts.example.test/reviewer/runs/run%20%2F%3F%25/artifacts/source%20%2F%3F%25")
450+
);
451+
}
452+
453+
#[test]
454+
fn reviewer_public_artifact_base_rejects_local_or_non_https_origins() {
455+
for value in ["http://artifacts.example.test", "https://127.0.0.1:7351"] {
456+
let _env = EnvGuard::set(PUBLIC_ARTIFACT_BASE_URL_ENV, value);
457+
assert!(reviewer_public_artifact_base_url().is_err(), "{value}");
458+
}
459+
}
460+
461+
#[test]
462+
fn controller_origin_uses_typed_config_before_legacy_environment() {
463+
crate::test_support::with_isolated_home(|_| {
464+
let _env = EnvGuard::set(PUBLIC_ARTIFACT_BASE_URL_ENV, "https://runner.example.test");
465+
let mut config = crate::defaults::HomeboyConfig::default();
466+
config.artifact_origin.public_base_url =
467+
Some("https://controller.example.test/".to_string());
468+
crate::defaults::save_config(&config).expect("save controller config");
469+
470+
assert_eq!(
471+
reviewer_public_artifact_base_url()
472+
.expect("configured origin")
473+
.as_deref(),
474+
Some("https://controller.example.test")
475+
);
476+
});
477+
}
478+
479+
#[test]
480+
fn controller_url_is_absent_without_controller_origin() {
481+
crate::test_support::with_isolated_home(|_| {
482+
let _env = EnvGuard::unset(PUBLIC_ARTIFACT_BASE_URL_ENV);
483+
let artifact = ArtifactRecord {
484+
id: "artifact-1".to_string(),
485+
run_id: "run-1".to_string(),
486+
artifact_type: "file".to_string(),
487+
..Default::default()
488+
};
489+
490+
assert_eq!(
491+
controller_artifact_url(&artifact).expect("optional URL"),
492+
None
493+
);
494+
});
495+
}
496+
349497
#[test]
350498
fn directory_artifact_public_url_requires_artifact_root_path() {
351499
let _env = EnvGuard::set(
@@ -399,13 +547,30 @@ mod tests {
399547
struct EnvGuard {
400548
key: &'static str,
401549
prior: Option<String>,
550+
_lock: MutexGuard<'static, ()>,
402551
}
403552

404553
impl EnvGuard {
405554
fn set(key: &'static str, value: &str) -> Self {
555+
let lock = ENV_LOCK.lock().expect("environment lock");
406556
let prior = std::env::var(key).ok();
407557
std::env::set_var(key, value);
408-
Self { key, prior }
558+
Self {
559+
key,
560+
prior,
561+
_lock: lock,
562+
}
563+
}
564+
565+
fn unset(key: &'static str) -> Self {
566+
let lock = ENV_LOCK.lock().expect("environment lock");
567+
let prior = std::env::var(key).ok();
568+
std::env::remove_var(key);
569+
Self {
570+
key,
571+
prior,
572+
_lock: lock,
573+
}
409574
}
410575
}
411576

crates/homeboy-core/src/artifact_origin.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,120 @@ mod tests {
677677
assert_eq!(response.body, b"<html>report</html>");
678678
}
679679

680+
#[test]
681+
fn serves_nested_visual_compare_reviewer_paths_over_http() {
682+
let root = tempfile::tempdir().expect("artifact root");
683+
let fixture = [
684+
("source.png", b"source bytes".as_slice()),
685+
("candidate.png", b"candidate bytes".as_slice()),
686+
("diff.png", b"diff bytes".as_slice()),
687+
];
688+
let path = root.path().join("visual-compare/37-art-gallery-exhibition");
689+
std::fs::create_dir_all(&path).expect("artifact directory");
690+
for (name, bytes) in fixture {
691+
std::fs::write(path.join(name), bytes).expect("visual artifact");
692+
}
693+
694+
let listener = TcpListener::bind("127.0.0.1:0").expect("listener");
695+
let address = listener.local_addr().expect("listener address");
696+
let root_path = root.path().to_path_buf();
697+
let server = std::thread::spawn(move || {
698+
for stream in listener.incoming().take(3) {
699+
handle_stream(stream.expect("request stream"), &root_path).expect("serve request");
700+
}
701+
});
702+
let client = reqwest::blocking::Client::new();
703+
for (name, bytes) in fixture {
704+
let response = client
705+
.get(format!(
706+
"http://{address}/visual-compare/37-art-gallery-exhibition/{name}"
707+
))
708+
.send()
709+
.expect("reviewer request");
710+
assert_eq!(response.status(), reqwest::StatusCode::OK);
711+
assert_eq!(response.bytes().expect("response bytes").as_ref(), bytes);
712+
}
713+
server.join().expect("origin server");
714+
}
715+
716+
#[test]
717+
fn serves_generated_canonical_reviewer_urls_over_http() {
718+
crate::test_support::with_isolated_home(|home| {
719+
let _env = EnvGuard::set(
720+
crate::artifacts::PUBLIC_ARTIFACT_BASE_URL_ENV,
721+
"https://artifacts.example.test",
722+
);
723+
let store = ObservationStore::open_initialized().expect("store");
724+
let run = store
725+
.start_run(NewRunRecord::builder("runner-exec").build())
726+
.expect("run");
727+
let fixture = [
728+
("source", "source.png", b"source bytes".as_slice()),
729+
("candidate", "candidate.png", b"candidate bytes".as_slice()),
730+
("diff", "diff.png", b"diff bytes".as_slice()),
731+
];
732+
let source_dir = home.path().join("visual-compare/37-art-gallery-exhibition");
733+
std::fs::create_dir_all(&source_dir).expect("artifact directory");
734+
let artifacts = fixture
735+
.iter()
736+
.map(|(id, name, bytes)| {
737+
let source = source_dir.join(name);
738+
std::fs::write(&source, bytes).expect("visual artifact");
739+
store
740+
.record_artifact_with_id(
741+
&run.id,
742+
"visual_compare",
743+
&source,
744+
id,
745+
serde_json::json!({}),
746+
)
747+
.expect("controller artifact")
748+
})
749+
.collect::<Vec<_>>();
750+
751+
let listener = TcpListener::bind("127.0.0.1:0").expect("listener");
752+
let address = listener.local_addr().expect("listener address");
753+
let root = home.path().to_path_buf();
754+
let server = std::thread::spawn(move || {
755+
for stream in listener.incoming().take(3) {
756+
handle_stream(stream.expect("request stream"), &root).expect("serve request");
757+
}
758+
});
759+
let client = reqwest::blocking::Client::new();
760+
for ((id, _, bytes), artifact) in fixture.iter().zip(artifacts) {
761+
let public_url = crate::artifact_links::controller_artifact_url(&artifact)
762+
.expect("valid reviewer origin")
763+
.expect("configured public reviewer URL");
764+
assert_eq!(
765+
public_url,
766+
format!(
767+
"https://artifacts.example.test/runs/{}/artifacts/{id}",
768+
run.id
769+
)
770+
);
771+
let path = reqwest::Url::parse(&public_url)
772+
.expect("public URL")
773+
.path()
774+
.to_string();
775+
let response = client
776+
.get(format!("http://{address}{path}"))
777+
.send()
778+
.expect("unauthenticated reviewer request");
779+
assert_eq!(response.status(), reqwest::StatusCode::OK);
780+
assert_eq!(
781+
response.headers()[reqwest::header::CONTENT_TYPE],
782+
"image/png"
783+
);
784+
assert_eq!(
785+
response.headers()["x-homeboy-artifact-sha256"],
786+
artifact.sha256.as_deref().expect("checksum")
787+
);
788+
assert_eq!(response.bytes().expect("response bytes").as_ref(), *bytes);
789+
}
790+
server.join().expect("origin server");
791+
});
792+
}
793+
680794
#[test]
681795
fn inspect_reports_404_for_missing_workflow_bench_bundle_path() {
682796
let temp = tempfile::tempdir().expect("tempdir");

0 commit comments

Comments
 (0)