@@ -14,6 +14,7 @@ use futures::TryStreamExt;
1414use semver:: Version ;
1515use tough:: editor:: RepositoryEditor ;
1616use tough:: editor:: signed:: SignedRole ;
17+ use tough:: error:: Error ;
1718use tough:: schema:: { Root , Target } ;
1819use tough:: { ExpirationEnforcement , Repository , RepositoryLoader , TargetName } ;
1920use tufaceous_artifact:: {
@@ -31,6 +32,7 @@ use crate::utils::merge_anyhow_list;
3132use crate :: { AddArtifact , ArchiveBuilder } ;
3233
3334/// A TUF repository describing Omicron.
35+ #[ derive( Debug ) ]
3436pub struct OmicronRepo {
3537 log : slog:: Logger ,
3638 repo : Repository ,
@@ -44,9 +46,9 @@ impl OmicronRepo {
4446 repo_path : & Utf8Path ,
4547 system_version : Version ,
4648 keys : Vec < Key > ,
49+ root : SignedRole < Root > ,
4750 expiry : DateTime < Utc > ,
4851 ) -> Result < Self > {
49- let root = crate :: root:: new_root ( keys. clone ( ) , expiry) . await ?;
5052 let editor = OmicronRepoEditor :: initialize (
5153 repo_path. to_owned ( ) ,
5254 root,
@@ -64,6 +66,86 @@ impl OmicronRepo {
6466 Self :: load_untrusted ( log, repo_path) . await
6567 }
6668
69+ /// Loads a repository from the given path.
70+ ///
71+ /// This method enforces expirations. To load without expiration enforcement, use
72+ /// [`Self::load_ignore_expiration`].
73+ pub async fn load (
74+ log : & slog:: Logger ,
75+ repo_path : & Utf8Path ,
76+ trusted_roots : impl IntoIterator < Item = impl AsRef < [ u8 ] > > ,
77+ ) -> Result < Self > {
78+ Self :: load_impl (
79+ log,
80+ repo_path,
81+ trusted_roots,
82+ ExpirationEnforcement :: Safe ,
83+ )
84+ . await
85+ }
86+
87+ /// Loads a repository from the given path, ignoring expiration.
88+ ///
89+ /// Use cases for this include:
90+ ///
91+ /// 1. When you're editing an existing repository and will re-sign it afterwards.
92+ /// 2. When you're reading a repository that was uploaded out-of-band,
93+ /// instead of fetched from a network-accessible repository
94+ /// 3. In an environment in which time isn't available.
95+ pub async fn load_ignore_expiration (
96+ log : & slog:: Logger ,
97+ repo_path : & Utf8Path ,
98+ trusted_roots : impl IntoIterator < Item = impl AsRef < [ u8 ] > > ,
99+ ) -> Result < Self > {
100+ Self :: load_impl (
101+ log,
102+ repo_path,
103+ trusted_roots,
104+ ExpirationEnforcement :: Unsafe ,
105+ )
106+ . await
107+ }
108+
109+ async fn load_impl (
110+ log : & slog:: Logger ,
111+ repo_path : & Utf8Path ,
112+ trusted_roots : impl IntoIterator < Item = impl AsRef < [ u8 ] > > ,
113+ exp : ExpirationEnforcement ,
114+ ) -> Result < Self > {
115+ let repo_path = repo_path. canonicalize_utf8 ( ) ?;
116+ let mut verify_error = None ;
117+ for root in trusted_roots {
118+ match RepositoryLoader :: new (
119+ & root,
120+ Url :: from_file_path ( repo_path. join ( "metadata" ) )
121+ . expect ( "the canonical path is not absolute?" ) ,
122+ Url :: from_file_path ( repo_path. join ( "targets" ) )
123+ . expect ( "the canonical path is not absolute?" ) ,
124+ )
125+ . expiration_enforcement ( exp)
126+ . load ( )
127+ . await
128+ {
129+ Ok ( repo) => {
130+ return Ok ( Self {
131+ log : log. new ( slog:: o!( "component" => "OmicronRepo" ) ) ,
132+ repo,
133+ repo_path,
134+ } ) ;
135+ }
136+ Err (
137+ err @ ( Error :: VerifyMetadata { .. }
138+ | Error :: VerifyTrustedMetadata { .. } ) ,
139+ ) if verify_error. is_none ( ) => {
140+ verify_error = Some ( err. into ( ) ) ;
141+ continue ;
142+ }
143+ Err ( err) => return Err ( err. into ( ) ) ,
144+ }
145+ }
146+ Err ( verify_error. unwrap_or_else ( || anyhow ! ( "trust store is empty" ) ) )
147+ }
148+
67149 /// Loads a repository from the given path.
68150 ///
69151 /// This method enforces expirations. To load without expiration enforcement, use
@@ -81,7 +163,9 @@ impl OmicronRepo {
81163 /// Use cases for this include:
82164 ///
83165 /// 1. When you're editing an existing repository and will re-sign it afterwards.
84- /// 2. In an environment in which time isn't available.
166+ /// 2. When you're reading a repository that was uploaded out-of-band,
167+ /// instead of fetched from a network-accessible repository
168+ /// 3. In an environment in which time isn't available.
85169 pub async fn load_untrusted_ignore_expiration (
86170 log : & slog:: Logger ,
87171 repo_path : & Utf8Path ,
@@ -95,25 +179,12 @@ impl OmicronRepo {
95179 repo_path : & Utf8Path ,
96180 exp : ExpirationEnforcement ,
97181 ) -> Result < Self > {
98- let log = log. new ( slog:: o!( "component" => "OmicronRepo" ) ) ;
99182 let repo_path = repo_path. canonicalize_utf8 ( ) ?;
100183 let root_json = repo_path. join ( "metadata" ) . join ( "1.root.json" ) ;
101184 let root = tokio:: fs:: read ( & root_json)
102185 . await
103186 . with_context ( || format ! ( "error reading from {root_json}" ) ) ?;
104-
105- let repo = RepositoryLoader :: new (
106- & root,
107- Url :: from_file_path ( repo_path. join ( "metadata" ) )
108- . expect ( "the canonical path is not absolute?" ) ,
109- Url :: from_file_path ( repo_path. join ( "targets" ) )
110- . expect ( "the canonical path is not absolute?" ) ,
111- )
112- . expiration_enforcement ( exp)
113- . load ( )
114- . await ?;
115-
116- Ok ( Self { log, repo, repo_path } )
187+ Self :: load_impl ( log, & repo_path, & [ root] , exp) . await
117188 }
118189
119190 /// Returns a canonicalized form of the repository path.
@@ -503,11 +574,96 @@ mod tests {
503574 use dropshot:: test_util:: LogContext ;
504575 use dropshot:: { ConfigLogging , ConfigLoggingIfExists , ConfigLoggingLevel } ;
505576
506- use crate :: ArtifactSource ;
507- use crate :: assemble:: ArtifactDeploymentUnits ;
577+ use crate :: assemble:: {
578+ ArtifactDeploymentUnits , ArtifactManifest , OmicronRepoAssembler ,
579+ } ;
580+ use crate :: { ArchiveExtractor , ArtifactSource } ;
508581
509582 use super :: * ;
510583
584+ #[ tokio:: test]
585+ async fn load_trusted ( ) {
586+ let log_config = ConfigLogging :: File {
587+ level : ConfigLoggingLevel :: Trace ,
588+ path : "UNUSED" . into ( ) ,
589+ if_exists : ConfigLoggingIfExists :: Fail ,
590+ } ;
591+ let logctx = LogContext :: new (
592+ "reject_artifacts_with_the_same_filename" ,
593+ & log_config,
594+ ) ;
595+
596+ // Generate a "trusted" root and an "untrusted" root.
597+ let expiry = Utc :: now ( ) + Days :: new ( 1 ) ;
598+ let trusted_key = Key :: generate_ed25519 ( ) . unwrap ( ) ;
599+ let trusted_root =
600+ crate :: root:: new_root ( vec ! [ trusted_key. clone( ) ] , expiry)
601+ . await
602+ . unwrap ( ) ;
603+ let untrusted_key = Key :: generate_ed25519 ( ) . unwrap ( ) ;
604+ let untrusted_root =
605+ crate :: root:: new_root ( vec ! [ untrusted_key] , expiry) . await . unwrap ( ) ;
606+
607+ // Generate a repository using the trusted root.
608+ let tempdir = Utf8TempDir :: new ( ) . unwrap ( ) ;
609+ let archive_path = tempdir. path ( ) . join ( "repo.zip" ) ;
610+ let mut assembler = OmicronRepoAssembler :: new (
611+ & logctx. log ,
612+ ArtifactManifest :: new_fake ( ) ,
613+ vec ! [ trusted_key] ,
614+ expiry,
615+ archive_path. clone ( ) ,
616+ ) ;
617+ assembler. set_root_role ( trusted_root. clone ( ) ) ;
618+ assembler. build ( ) . await . unwrap ( ) ;
619+ // And now that we've created an archive and cleaned up the build
620+ // directory, immediately unarchive it... this is a bit silly, huh?
621+ let repo_dir = tempdir. path ( ) . join ( "repo" ) ;
622+ ArchiveExtractor :: from_path ( & archive_path)
623+ . unwrap ( )
624+ . extract ( & repo_dir)
625+ . unwrap ( ) ;
626+
627+ // If the trust store contains the root we generated the repo from, we
628+ // should successfully load it.
629+ for trust_store in [
630+ vec ! [ trusted_root. buffer( ) ] ,
631+ vec ! [ trusted_root. buffer( ) , untrusted_root. buffer( ) ] ,
632+ vec ! [ untrusted_root. buffer( ) , trusted_root. buffer( ) ] ,
633+ vec ! [ trusted_root. buffer( ) , trusted_root. buffer( ) ] ,
634+ ] {
635+ OmicronRepo :: load ( & logctx. log , & repo_dir, trust_store)
636+ . await
637+ . unwrap ( ) ;
638+ }
639+ // If the trust store is empty, we should fail.
640+ assert_eq ! (
641+ OmicronRepo :: load( & logctx. log, & repo_dir, [ ] as [ Vec <u8 >; 0 ] )
642+ . await
643+ . unwrap_err( )
644+ . to_string( ) ,
645+ "trust store is empty"
646+ ) ;
647+ // If the trust store otherwise does not contain the root we generated
648+ // the repo from, we should also fail.
649+ for trust_store in [
650+ vec ! [ untrusted_root. buffer( ) ] ,
651+ vec ! [ untrusted_root. buffer( ) , untrusted_root. buffer( ) ] ,
652+ ] {
653+ assert_eq ! (
654+ OmicronRepo :: load( & logctx. log, & repo_dir, trust_store)
655+ . await
656+ . unwrap_err( )
657+ . to_string( ) ,
658+ "Failed to verify timestamp metadata: \
659+ Signature threshold of 1 not met for role timestamp \
660+ (0 valid signatures)"
661+ )
662+ }
663+
664+ logctx. cleanup_successful ( ) ;
665+ }
666+
511667 #[ tokio:: test]
512668 async fn reject_artifacts_with_the_same_filename ( ) {
513669 let log_config = ConfigLogging :: File {
@@ -520,12 +676,16 @@ mod tests {
520676 & log_config,
521677 ) ;
522678 let tempdir = Utf8TempDir :: new ( ) . unwrap ( ) ;
679+ let keys = vec ! [ Key :: generate_ed25519( ) . unwrap( ) ] ;
680+ let expiry = Utc :: now ( ) + Days :: new ( 1 ) ;
681+ let root = crate :: root:: new_root ( keys. clone ( ) , expiry) . await . unwrap ( ) ;
523682 let mut repo = OmicronRepo :: initialize (
524683 & logctx. log ,
525684 tempdir. path ( ) ,
526685 "0.0.0" . parse ( ) . unwrap ( ) ,
527- vec ! [ Key :: generate_ed25519( ) . unwrap( ) ] ,
528- Utc :: now ( ) + Days :: new ( 1 ) ,
686+ keys,
687+ root,
688+ expiry,
529689 )
530690 . await
531691 . unwrap ( )
0 commit comments