@@ -2,6 +2,7 @@ use serde_json::Value;
22use std:: path:: Path ;
33use std:: time:: Duration ;
44
5+ use crate :: error:: { Error , Result } ;
56use crate :: execution_contract:: encode_uri_component;
67use 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+
84162pub 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
253333mod 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
0 commit comments