@@ -19,11 +19,13 @@ use quilt_uri::Host;
1919use quilt_uri:: S3Uri ;
2020
2121use crate :: Error ;
22+ use crate :: commands:: RoleCache ;
2223use crate :: model;
2324use crate :: model:: QuiltModel ;
2425use crate :: quilt;
2526
2627use super :: package_data:: InstalledPackageEntryData ;
28+ use super :: package_list:: denied_mark;
2729
2830// ── Merge data for Leptos UI ──
2931
@@ -89,6 +91,18 @@ pub struct CommitData {
8991 pub user_meta_error : Option < String > ,
9092 pub workflow : Option < CommitWorkflowData > ,
9193 pub workflows : CommitWorkflows ,
94+ /// Why the active role cannot reach this package's bucket, worded exactly
95+ /// as the roster words it. `Some` only on a denial.
96+ ///
97+ /// Same field name, same shape and same helper as
98+ /// [`super::package_data::InstalledPackageData::no_access_reason`], so the
99+ /// three surfaces that speak about a denial all say one thing.
100+ ///
101+ /// Committing is not offline work — the workflow quality gate reads the
102+ /// bucket's `.quilt/workflows/config.yml` before any manifest is written —
103+ /// so on a denial the commit affordances cannot succeed and the page
104+ /// disables them, quoting this as the reason.
105+ pub no_access_reason : Option < String > ,
92106 pub entries : Vec < InstalledPackageEntryData > ,
93107 pub ignored_count : usize ,
94108 pub unmodified_count : usize ,
@@ -258,6 +272,7 @@ fn workflows_config_to_commit_workflows(
258272#[ allow( clippy:: too_many_lines, reason = "cohesive commit-data assembly" ) ]
259273async fn get_commit_data_from_model (
260274 m : & impl model:: QuiltModel ,
275+ roles : & RoleCache ,
261276 tracing : & crate :: telemetry:: Telemetry ,
262277 namespace : & quilt_uri:: Namespace ,
263278) -> Result < CommitData , Error > {
@@ -267,23 +282,42 @@ async fn get_commit_data_from_model(
267282 ) )
268283 } ) ?;
269284
270- // Committing is local work: the remote round trip inside `status` only
271- // refreshes `upstream_state`, which this page reports but does not act
272- // on. A denial there must therefore not keep the page shut — the user
273- // can still commit a package in a bucket the active role cannot read.
285+ let lineage = m. get_installed_package_lineage ( & installed_package) . await ?;
286+
287+ let typed_uri = lineage
288+ . remote_uri
289+ . as_ref ( )
290+ . map ( quilt_uri:: S3PackageUri :: from) ;
291+ let origin_host = typed_uri. as_ref ( ) . and_then ( |u| u. catalog . as_ref ( ) ) ;
292+ if let Some ( host) = origin_host {
293+ tracing. add_host ( host) ;
294+ }
295+
296+ // Opening the page is local work: the remote round trip inside `status`
297+ // only refreshes `upstream_state`, which this page reports but does not
298+ // act on. A denial there must therefore not keep the page shut.
274299 // Recomputing against the cached manifest gives the same answer the
275300 // engine used to hand back when it swallowed the refresh error, minus
276301 // the upstream freshness nobody here needs.
302+ //
303+ // Committing itself is a different matter: the workflow quality gate
304+ // reads the bucket's config before any manifest is written, so under a
305+ // denied role no commit can succeed. The page still opens — the file
306+ // list and the message are worth seeing — but it carries the reason so
307+ // the commit affordances can be disabled and explained rather than
308+ // failing on click.
309+ let mut no_access_reason = None ;
277310 let status = match m
278311 . get_installed_package_status ( & installed_package, None )
279312 . await
280313 {
281314 Ok ( status) => status,
282315 Err ( err) if err. is_access_denied ( ) => {
283316 tracing:: info!(
284- "No read access to the remote of {}; committing on cached lineage" ,
317+ "No read access to the remote of {}; opening the commit page on cached lineage" ,
285318 installed_package. namespace,
286319 ) ;
320+ no_access_reason = denied_mark ( m, roles, origin_host) . await . reason ;
287321 m. recompute_local_status ( & installed_package, None ) . await ?
288322 }
289323 Err ( err) => return Err ( err) ,
@@ -298,17 +332,6 @@ async fn get_commit_data_from_model(
298332 quilt:: lineage:: UpstreamState :: Error => "error" ,
299333 } ;
300334
301- let lineage = m. get_installed_package_lineage ( & installed_package) . await ?;
302-
303- let typed_uri = lineage
304- . remote_uri
305- . as_ref ( )
306- . map ( quilt_uri:: S3PackageUri :: from) ;
307- let origin_host = typed_uri. as_ref ( ) . and_then ( |u| u. catalog . as_ref ( ) ) ;
308- if let Some ( host) = origin_host {
309- tracing. add_host ( host) ;
310- }
311-
312335 // Build lookup maps for junky files
313336 let junky_map: std:: collections:: HashMap < _ , _ > = status
314337 . junky_changes
@@ -435,6 +458,7 @@ async fn get_commit_data_from_model(
435458 user_meta_error,
436459 workflow,
437460 workflows,
461+ no_access_reason,
438462 entries : entries_list,
439463 ignored_count,
440464 unmodified_count,
@@ -444,14 +468,15 @@ async fn get_commit_data_from_model(
444468#[ tauri:: command]
445469pub async fn get_commit_data (
446470 m : tauri:: State < ' _ , model:: Model > ,
471+ roles : tauri:: State < ' _ , RoleCache > ,
447472 tracing : tauri:: State < ' _ , crate :: telemetry:: Telemetry > ,
448473 namespace : String ,
449474) -> Result < CommitData , String > {
450475 let namespace: quilt_uri:: Namespace = namespace
451476 . try_into ( )
452477 . map_err ( |e : quilt_uri:: UriError | e. to_string ( ) ) ?;
453478
454- get_commit_data_from_model ( & * m, & tracing, & namespace)
479+ get_commit_data_from_model ( & * m, & roles , & tracing, & namespace)
455480 . await
456481 . map_err ( |e| e. to_frontend_string ( ) )
457482}
@@ -785,7 +810,7 @@ mod tests {
785810 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
786811 let namespace = ( "foo" , "bar" ) . into ( ) ;
787812
788- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
813+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
789814 . await
790815 . map_err ( |e| e. to_string ( ) ) ?;
791816
@@ -799,12 +824,10 @@ mod tests {
799824 Ok ( ( ) )
800825 }
801826
802- /// Committing is local work. A role that cannot read the remote bucket
803- /// can still have local edits worth committing, so a denial on the
804- /// status refresh must not keep the Commit page from opening — the
805- /// entries come from the local recompute instead.
806- #[ tokio:: test]
807- async fn commit_data_opens_when_the_role_cannot_read_the_remote ( ) -> Result < ( ) , String > {
827+ /// The model mocks shared by the denial-contrast pair below: an installed
828+ /// package with a remote, a cached manifest, and an ungoverned bucket. The
829+ /// caller wires up the status call, which is the only thing that differs.
830+ fn denial_contrast_model ( ) -> crate :: model:: MockQuiltModel {
808831 let mut model = mocks:: create ( ) ;
809832 model
810833 . expect_get_installed_package ( )
@@ -818,9 +841,6 @@ mod tests {
818841 "abcdef" . to_string ( ) ,
819842 ) )
820843 } ) ;
821- model
822- . expect_get_installed_package_status ( )
823- . returning ( |_, _| Err ( access_denied_error ( ) ) ) ;
824844 // The installed revision's manifest is already in the local cache,
825845 // so reading it back needs no remote round trip and no read access.
826846 model
@@ -830,25 +850,84 @@ mod tests {
830850 . expect_get_installed_package_records ( )
831851 . returning ( |_| Ok ( std:: collections:: BTreeMap :: new ( ) ) ) ;
832852 model. expect_get_workflows_config ( ) . returning ( |_| Ok ( None ) ) ;
853+ model
854+ }
855+
856+ /// Opening the page is local work: a role that cannot read the remote
857+ /// bucket can still have local edits worth looking at, so a denial on the
858+ /// status refresh must not keep the Commit page shut — the entries come
859+ /// from the local recompute instead.
860+ ///
861+ /// Committing is *not* local work (the workflow gate reads the bucket's
862+ /// config before any manifest is written), so the page must also carry the
863+ /// reason, worded exactly as the roster words it, for the disabled commit
864+ /// affordances to quote.
865+ #[ tokio:: test]
866+ async fn commit_data_opens_and_states_the_denial ( ) -> Result < ( ) , String > {
867+ let mut model = denial_contrast_model ( ) ;
868+ model
869+ . expect_get_installed_package_status ( )
870+ . returning ( |_, _| Err ( access_denied_error ( ) ) ) ;
833871 model. expect_recompute_local_status ( ) . returning ( |_, _| {
834872 Ok ( quilt:: lineage:: InstalledPackageStatus :: new (
835873 quilt:: lineage:: UpstreamState :: UpToDate ,
836874 one_local_change ( ) ,
837875 ) )
838876 } ) ;
877+ model. expect_refresh_roles ( ) . returning ( |_| {
878+ Ok ( quilt_rs:: RoleInfo {
879+ current : "ReadOnly" . to_string ( ) ,
880+ available : vec ! [ "ReadWrite" . to_string( ) , "ReadOnly" . to_string( ) ] ,
881+ } )
882+ } ) ;
883+ model. expect_clear_remote_client_cache ( ) . returning ( |_| ( ) ) ;
839884
840885 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
841886 let namespace = ( "foo" , "bar" ) . into ( ) ;
842887
843- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
888+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
844889 . await
845890 . map_err ( |e| e. to_string ( ) ) ?;
846891
847892 assert_eq ! ( data. namespace, "foo/bar" ) ;
848893 assert ! (
849894 data. entries. iter( ) . any( |e| e. filename == "file.txt" ) ,
850- "the locally-computed change must still be offered for commit"
895+ "the locally-computed change must still be listed"
896+ ) ;
897+ assert_eq ! (
898+ data. no_access_reason. as_deref( ) ,
899+ Some ( "Current role ReadOnly has no access to this bucket" ) ,
900+ "the commit page must say what the roster and the detail page say"
901+ ) ;
902+ Ok ( ( ) )
903+ }
904+
905+ /// The contrast: the same package on a bucket the active role *can* read
906+ /// carries no reason at all, so the commit affordances stay live.
907+ #[ tokio:: test]
908+ async fn commit_data_on_a_readable_bucket_states_no_denial ( ) -> Result < ( ) , String > {
909+ let mut model = denial_contrast_model ( ) ;
910+ model
911+ . expect_get_installed_package_status ( )
912+ . returning ( |_, _| {
913+ Ok ( quilt:: lineage:: InstalledPackageStatus :: new (
914+ quilt:: lineage:: UpstreamState :: UpToDate ,
915+ one_local_change ( ) ,
916+ ) )
917+ } ) ;
918+
919+ let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
920+ let namespace = ( "foo" , "bar" ) . into ( ) ;
921+
922+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
923+ . await
924+ . map_err ( |e| e. to_string ( ) ) ?;
925+
926+ assert ! (
927+ data. entries. iter( ) . any( |e| e. filename == "file.txt" ) ,
928+ "the same local change is on offer"
851929 ) ;
930+ assert_eq ! ( data. no_access_reason, None ) ;
852931 Ok ( ( ) )
853932 }
854933
@@ -859,7 +938,8 @@ mod tests {
859938 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
860939 let namespace = ( "missing" , "package" ) . into ( ) ;
861940
862- let result = get_commit_data_from_model ( & model, & tracing, & namespace) . await ;
941+ let result =
942+ get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace) . await ;
863943 assert ! ( result. is_err( ) ) ;
864944 }
865945
@@ -921,7 +1001,7 @@ mod tests {
9211001 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
9221002 let namespace = ( "foo" , "bar" ) . into ( ) ;
9231003
924- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1004+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
9251005 . await
9261006 . map_err ( |e| e. to_string ( ) ) ?;
9271007
@@ -987,7 +1067,7 @@ mod tests {
9871067 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
9881068 let namespace = ( "foo" , "bar" ) . into ( ) ;
9891069
990- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1070+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
9911071 . await
9921072 . map_err ( |e| e. to_string ( ) ) ?;
9931073
@@ -1045,7 +1125,7 @@ mod tests {
10451125 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
10461126 let namespace = ( "foo" , "bar" ) . into ( ) ;
10471127
1048- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1128+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
10491129 . await
10501130 . map_err ( |e| e. to_string ( ) ) ?;
10511131
@@ -1128,7 +1208,7 @@ workflows:
11281208 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
11291209 let namespace = ( "foo" , "bar" ) . into ( ) ;
11301210
1131- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1211+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
11321212 . await
11331213 . map_err ( |e| e. to_string ( ) ) ?;
11341214
@@ -1173,7 +1253,7 @@ workflows:
11731253 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
11741254 let namespace = ( "foo" , "bar" ) . into ( ) ;
11751255
1176- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1256+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
11771257 . await
11781258 . map_err ( |e| e. to_string ( ) ) ?;
11791259
@@ -1195,7 +1275,7 @@ workflows:
11951275 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
11961276 let namespace = ( "foo" , "bar" ) . into ( ) ;
11971277
1198- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1278+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
11991279 . await
12001280 . map_err ( |e| e. to_string ( ) ) ?;
12011281
@@ -1279,7 +1359,7 @@ workflows:
12791359 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
12801360 let namespace = ( "foo" , "bar" ) . into ( ) ;
12811361
1282- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1362+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
12831363 . await
12841364 . map_err ( |e| e. to_string ( ) ) ?;
12851365
@@ -1313,7 +1393,7 @@ workflows:
13131393 let tracing = crate :: telemetry:: Telemetry :: default ( ) ;
13141394 let namespace = ( "foo" , "bar" ) . into ( ) ;
13151395
1316- let data = get_commit_data_from_model ( & model, & tracing, & namespace)
1396+ let data = get_commit_data_from_model ( & model, & RoleCache :: default ( ) , & tracing, & namespace)
13171397 . await
13181398 . map_err ( |e| e. to_string ( ) ) ?;
13191399
0 commit comments