@@ -4,10 +4,13 @@ use serde::Deserialize;
44use std:: collections:: BTreeMap ;
55
66use super :: { step_success, ReleaseArtifact , ReleaseState , ReleaseStepResult } ;
7+ use crate :: release:: types:: DraftAdoptionIntent ;
78
89const PACKAGE_RECOVERY_MANIFEST : & str = "manifest.json" ;
910pub ( crate ) const PACKAGE_RECOVERY_MANIFEST_SCHEMA : & str = "homeboy.package-recovery" ;
1011pub ( crate ) const PACKAGE_RECOVERY_MANIFEST_SCHEMA_VERSION : u32 = 1 ;
12+ const DRAFT_ADOPTION_MANIFEST_SCHEMA : & str = "homeboy.draft-adoption" ;
13+ const DRAFT_ADOPTION_MANIFEST_SCHEMA_VERSION : u32 = 1 ;
1114
1215#[ derive( Deserialize ) ]
1316struct PackageRecoveryManifest {
@@ -20,6 +23,17 @@ struct PackageRecoveryManifest {
2023 artifacts : Vec < ReleaseArtifact > ,
2124}
2225
26+ #[ derive( Deserialize ) ]
27+ struct DraftAdoptionManifest {
28+ schema : String ,
29+ schema_version : u32 ,
30+ component_id : String ,
31+ tag : String ,
32+ version : String ,
33+ commit : String ,
34+ expected_assets : Vec < String > ,
35+ }
36+
2337pub ( crate ) struct PackageRecoveryContext < ' a > {
2438 pub ( crate ) component_id : & ' a str ,
2539 pub ( crate ) tag : & ' a str ,
@@ -46,6 +60,19 @@ pub(crate) fn run_artifact_inventory(
4660 }
4761
4862 let manifest_path = dir. join ( PACKAGE_RECOVERY_MANIFEST ) ;
63+ if manifest_path. is_file ( ) && is_draft_adoption_manifest ( & manifest_path) {
64+ let intent = inventory_draft_adoption_manifest ( & manifest_path, recovery_context) ?;
65+ let count = intent. expected_assets . len ( ) ;
66+ state. draft_adoption = Some ( intent) ;
67+ return Ok ( step_success (
68+ "artifacts.inventory" ,
69+ "artifacts.inventory" ,
70+ Some (
71+ serde_json:: json!( { "dir" : artifact_dir, "artifact_count" : 0 , "adoption_asset_count" : count } ) ,
72+ ) ,
73+ Vec :: new ( ) ,
74+ ) ) ;
75+ }
4976 let mut artifacts = if manifest_path. is_file ( ) && is_package_recovery_manifest ( & manifest_path) {
5077 inventory_package_recovery_manifest ( dir, & manifest_path, recovery_context) ?
5178 } else {
@@ -85,6 +112,17 @@ pub(crate) fn run_artifact_inventory(
85112/// explicit component `scripts.build` artifact; identical lower-precedence
86113/// duplicates collapse, while differing bytes fail before commit/tag/push.
87114pub ( crate ) fn establish_publication_authority ( state : & mut ReleaseState ) -> Result < Vec < String > > {
115+ if state. draft_adoption . is_some ( ) {
116+ if !state. artifacts . is_empty ( ) {
117+ return Err ( Error :: validation_invalid_argument (
118+ "release assets" ,
119+ "draft adoption cannot include local artifacts" ,
120+ None ,
121+ None ,
122+ ) ) ;
123+ }
124+ return Ok ( Vec :: new ( ) ) ;
125+ }
88126 let mut selected: BTreeMap < String , usize > = BTreeMap :: new ( ) ;
89127 let mut diagnostics = Vec :: new ( ) ;
90128 for artifact in & mut state. artifacts {
@@ -189,6 +227,97 @@ fn is_package_recovery_manifest(manifest_path: &std::path::Path) -> bool {
189227 == Some ( PACKAGE_RECOVERY_MANIFEST_SCHEMA )
190228}
191229
230+ fn is_draft_adoption_manifest ( manifest_path : & std:: path:: Path ) -> bool {
231+ let Ok ( contents) = std:: fs:: read_to_string ( manifest_path) else {
232+ return false ;
233+ } ;
234+ let Ok ( manifest) = serde_json:: from_str :: < serde_json:: Value > ( & contents) else {
235+ return false ;
236+ } ;
237+ manifest. get ( "schema" ) . and_then ( serde_json:: Value :: as_str)
238+ == Some ( DRAFT_ADOPTION_MANIFEST_SCHEMA )
239+ }
240+
241+ fn inventory_draft_adoption_manifest (
242+ manifest_path : & std:: path:: Path ,
243+ context : & PackageRecoveryContext ,
244+ ) -> Result < DraftAdoptionIntent > {
245+ let contents = std:: fs:: read_to_string ( manifest_path) . map_err ( |error| {
246+ Error :: internal_io (
247+ format ! (
248+ "Failed to read draft adoption manifest '{}': {error}" ,
249+ manifest_path. display( )
250+ ) ,
251+ Some ( manifest_path. display ( ) . to_string ( ) ) ,
252+ )
253+ } ) ?;
254+ let manifest: DraftAdoptionManifest = serde_json:: from_str ( & contents) . map_err ( |error| {
255+ Error :: validation_invalid_argument (
256+ "from-artifacts" ,
257+ format ! (
258+ "Draft adoption manifest '{}' is invalid: {error}" ,
259+ manifest_path. display( )
260+ ) ,
261+ Some ( manifest_path. display ( ) . to_string ( ) ) ,
262+ None ,
263+ )
264+ } ) ?;
265+ if manifest. schema_version != DRAFT_ADOPTION_MANIFEST_SCHEMA_VERSION {
266+ return Err ( Error :: validation_invalid_argument (
267+ "from-artifacts" ,
268+ "Draft adoption manifest has unsupported schema version" ,
269+ Some ( manifest_path. display ( ) . to_string ( ) ) ,
270+ None ,
271+ ) ) ;
272+ }
273+ let identity = PackageRecoveryManifest {
274+ schema : manifest. schema ,
275+ schema_version : 1 ,
276+ component_id : manifest. component_id ,
277+ tag : manifest. tag ,
278+ version : manifest. version ,
279+ commit : manifest. commit ,
280+ artifacts : Vec :: new ( ) ,
281+ } ;
282+ validate_recovery_identity ( & identity, manifest_path, context) ?;
283+ if manifest. expected_assets . is_empty ( )
284+ || manifest
285+ . expected_assets
286+ . iter ( )
287+ . any ( |name| !valid_asset_name ( name) )
288+ {
289+ return Err ( Error :: validation_invalid_argument (
290+ "from-artifacts" ,
291+ "Draft adoption manifest must contain non-empty safe asset names" ,
292+ Some ( manifest_path. display ( ) . to_string ( ) ) ,
293+ None ,
294+ ) ) ;
295+ }
296+ let mut names = manifest. expected_assets ;
297+ names. sort ( ) ;
298+ if names. windows ( 2 ) . any ( |pair| pair[ 0 ] == pair[ 1 ] ) {
299+ return Err ( Error :: validation_invalid_argument (
300+ "from-artifacts" ,
301+ "Draft adoption manifest contains duplicate asset names" ,
302+ Some ( manifest_path. display ( ) . to_string ( ) ) ,
303+ None ,
304+ ) ) ;
305+ }
306+ Ok ( DraftAdoptionIntent {
307+ expected_assets : names,
308+ } )
309+ }
310+
311+ fn valid_asset_name ( name : & str ) -> bool {
312+ !name. is_empty ( )
313+ && std:: path:: Path :: new ( name)
314+ . file_name ( )
315+ . and_then ( |value| value. to_str ( ) )
316+ == Some ( name)
317+ && name != "."
318+ && name != ".."
319+ }
320+
192321fn inventory_directory_files (
193322 dir : & std:: path:: Path ,
194323 artifact_dir : & str ,
@@ -609,6 +738,62 @@ mod tests {
609738 } )
610739 }
611740
741+ #[ test]
742+ fn draft_adoption_manifest_records_remote_only_intent_and_rejects_bad_identity_or_names ( ) {
743+ let temp = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
744+ let manifest = |component : & str , names : serde_json:: Value | {
745+ serde_json:: json!( {
746+ "schema" : "homeboy.draft-adoption" , "schema_version" : 1 ,
747+ "component_id" : component, "tag" : "v1.2.3" , "version" : "1.2.3" , "commit" : "abc123" ,
748+ "expected_assets" : names
749+ } )
750+ } ;
751+ std:: fs:: write (
752+ temp. path ( ) . join ( PACKAGE_RECOVERY_MANIFEST ) ,
753+ manifest (
754+ "plugin" ,
755+ serde_json:: json!( [ "plugin.zip" , "plugin.zip.sha256" ] ) ,
756+ )
757+ . to_string ( ) ,
758+ )
759+ . unwrap ( ) ;
760+ let mut state = ReleaseState :: default ( ) ;
761+ run_artifact_inventory (
762+ & mut state,
763+ & temp. path ( ) . to_string_lossy ( ) ,
764+ & recovery_context ( ) ,
765+ )
766+ . expect ( "adoption inventory" ) ;
767+ assert ! ( state. artifacts. is_empty( ) ) ;
768+ assert_eq ! (
769+ state. draft_adoption. unwrap( ) . expected_assets,
770+ [ "plugin.zip" , "plugin.zip.sha256" ]
771+ ) ;
772+
773+ std:: fs:: write (
774+ temp. path ( ) . join ( PACKAGE_RECOVERY_MANIFEST ) ,
775+ manifest ( "other" , serde_json:: json!( [ "plugin.zip" ] ) ) . to_string ( ) ,
776+ )
777+ . unwrap ( ) ;
778+ assert ! ( run_artifact_inventory(
779+ & mut ReleaseState :: default ( ) ,
780+ & temp. path( ) . to_string_lossy( ) ,
781+ & recovery_context( )
782+ )
783+ . is_err( ) ) ;
784+ std:: fs:: write (
785+ temp. path ( ) . join ( PACKAGE_RECOVERY_MANIFEST ) ,
786+ manifest ( "plugin" , serde_json:: json!( [ "plugin.zip" , "plugin.zip" ] ) ) . to_string ( ) ,
787+ )
788+ . unwrap ( ) ;
789+ assert ! ( run_artifact_inventory(
790+ & mut ReleaseState :: default ( ) ,
791+ & temp. path( ) . to_string_lossy( ) ,
792+ & recovery_context( )
793+ )
794+ . is_err( ) ) ;
795+ }
796+
612797 #[ test]
613798 fn final_package_authority_supersedes_different_preflight_bytes_and_publishes_once ( ) {
614799 let temp = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
0 commit comments