[pkg-resolver] Persistent dynamic repository configs

This implements loading and saving of the dynamic repository configs in
the RepositoryManager.

Bug: PKG-598 #done

Change-Id: I200bdfb370b4940d7c55a808c324b2fd98271260
diff --git a/garnet/bin/pkg_resolver/src/main.rs b/garnet/bin/pkg_resolver/src/main.rs
index 21efb54..64f3701 100644
--- a/garnet/bin/pkg_resolver/src/main.rs
+++ b/garnet/bin/pkg_resolver/src/main.rs
@@ -26,14 +26,17 @@
 #[cfg(test)]
 mod test_util;
 
-use repository_manager::RepositoryManager;
+use repository_manager::{RepositoryManager, RepositoryManagerBuilder};
 use repository_service::RepositoryService;
 
 use rewrite_manager::RewriteManager;
 use rewrite_service::RewriteService;
 
 const SERVER_THREADS: usize = 2;
+
 const STATIC_REPO_DIR: &str = "/config/data/pkg_resolver/repositories";
+const DYNAMIC_REPO_PATH: &str = "/data/repositories.json";
+
 const DYNAMIC_RULES_PATH: &str = "/data/rewrite_rules.json";
 
 fn main() -> Result<(), Error> {
@@ -100,21 +103,21 @@
 }
 
 fn load_repo_manager() -> RepositoryManager {
-    let static_repo_dir = Path::new(STATIC_REPO_DIR);
-
-    if static_repo_dir.exists() {
-        return RepositoryManager::new();
-    }
-
-    let (repo_manager, errors) = RepositoryManager::load_dir(static_repo_dir);
-
     // report any errors we saw, but don't error out because otherwise we won't be able
     // to update the system.
-    for err in errors {
-        fx_log_err!("error loading static repo config: {}", err);
-    }
-
-    repo_manager
+    RepositoryManagerBuilder::new(DYNAMIC_REPO_PATH)
+        .unwrap_or_else(|(builder, err)| {
+            fx_log_err!("error loading dynamic repo config: {}", err);
+            builder
+        })
+        .load_static_configs_dir(STATIC_REPO_DIR)
+        .unwrap_or_else(|(builder, errs)| {
+            for err in errs {
+                fx_log_err!("error loading static repo config: {}", err);
+            }
+            builder
+        })
+        .build()
 }
 
 fn load_rewrite_manager() -> RewriteManager {
diff --git a/garnet/bin/pkg_resolver/src/repository_manager.rs b/garnet/bin/pkg_resolver/src/repository_manager.rs
index a3c9e09..f271a33 100644
--- a/garnet/bin/pkg_resolver/src/repository_manager.rs
+++ b/garnet/bin/pkg_resolver/src/repository_manager.rs
@@ -5,6 +5,7 @@
 use {
     failure::Fail,
     fidl_fuchsia_pkg_ext::{RepositoryConfig, RepositoryConfigs},
+    fuchsia_syslog::fx_log_err,
     fuchsia_uri::pkg_uri::RepoUri,
     std::collections::btree_set,
     std::collections::hash_map::Entry,
@@ -18,25 +19,12 @@
 /// [RepositoryManager] controls access to all the repository configs used by the package resolver.
 #[derive(Debug, PartialEq, Eq)]
 pub struct RepositoryManager {
+    dynamic_configs_path: PathBuf,
     static_configs: HashMap<RepoUri, RepositoryConfig>,
     dynamic_configs: HashMap<RepoUri, RepositoryConfig>,
 }
 
 impl RepositoryManager {
-    /// Construct a new [RepositoryManager].
-    pub fn new() -> Self {
-        RepositoryManager { static_configs: HashMap::new(), dynamic_configs: HashMap::new() }
-    }
-
-    /// Load a directory of [RepositoryConfigs] files into a [RepositoryManager], or error out if we
-    /// encounter io errors during the load. It returns a [RepositoryManager], as well as all the
-    /// individual [LoadError] errors encountered during the load.
-    pub fn load_dir<T: AsRef<Path>>(static_config_dir: T) -> (Self, Vec<LoadError>) {
-        let (static_configs, errors) = load_configs_dir(static_config_dir);
-        let mgr = RepositoryManager { static_configs, dynamic_configs: HashMap::new() };
-        (mgr, errors)
-    }
-
     /// Returns a reference to the [RepositoryConfig] config identified by the config `repo_url`,
     /// or `None` if it does not exist.
     pub fn get(&self, repo_url: &RepoUri) -> Option<&RepositoryConfig> {
@@ -52,7 +40,9 @@
     /// and the old [RepositoryConfig] is returned. If this repository is a static config, the
     /// static config is shadowed by the dynamic config until it is removed.
     pub fn insert(&mut self, config: RepositoryConfig) -> Option<RepositoryConfig> {
-        self.dynamic_configs.insert(config.repo_url().clone(), config)
+        let result = self.dynamic_configs.insert(config.repo_url().clone(), config);
+        self.save();
+        result
     }
 
     /// Removes a [RepositoryConfig] identified by the config `repo_url`.
@@ -61,6 +51,7 @@
         repo_url: &RepoUri,
     ) -> Result<Option<RepositoryConfig>, CannotRemoveStaticRepositories> {
         if let Some(config) = self.dynamic_configs.remove(repo_url) {
+            self.save();
             return Ok(Some(config));
         }
 
@@ -82,6 +73,119 @@
 
         List { keys: keys.into_iter(), repo_mgr: self }
     }
+
+    /// If persistent dynamic configs are enabled, save the current configs to disk. Log, and
+    /// ultimately ignore, any errors that occur to make sure forward progress can always be made.
+    fn save(&self) {
+        let configs = self.dynamic_configs.iter().map(|(_, c)| c.clone()).collect::<Vec<_>>();
+
+        let result = (|| {
+            let mut temp_path = self.dynamic_configs_path.clone().into_os_string();
+            temp_path.push(".new");
+            let temp_path = PathBuf::from(temp_path);
+            {
+                let f = fs::File::create(&temp_path)?;
+                serde_json::to_writer(f, &RepositoryConfigs::Version1(configs))?;
+            }
+            fs::rename(temp_path, &self.dynamic_configs_path)
+        })();
+
+        match result {
+            Ok(()) => {}
+            Err(err) => {
+                fx_log_err!("error while saving repositories: {}", err);
+            }
+        }
+    }
+}
+
+/// [RepositoryManagerBuilder] constructs a [RepositoryManager], optionally initializing it
+/// with [RepositoryConfig]s passed in directly or loaded out of the filesystem.
+#[derive(Clone, Debug)]
+pub struct RepositoryManagerBuilder {
+    dynamic_configs_path: PathBuf,
+    static_configs: HashMap<RepoUri, RepositoryConfig>,
+    dynamic_configs: HashMap<RepoUri, RepositoryConfig>,
+}
+
+impl RepositoryManagerBuilder {
+    /// Create a new builder and initialize it with the dynamic
+    /// [RepositoryConfigs](RepositoryConfig) from this path if it exists, and add it to the
+    /// [RepositoryManager], or error out if we encounter errors during the load. The
+    /// [RepositoryManagerBuilder] is also returned on error in case the errors should be ignored.
+    pub fn new<T>(dynamic_configs_path: T) -> Result<Self, (Self, LoadError)>
+    where
+        T: Into<PathBuf>,
+    {
+        let dynamic_configs_path = dynamic_configs_path.into();
+
+        let (dynamic_configs, err) = if dynamic_configs_path.exists() {
+            match load_configs_file(&dynamic_configs_path) {
+                Ok(dynamic_configs) => (dynamic_configs, None),
+                Err(err) => (vec![], Some(err)),
+            }
+        } else {
+            (vec![], None)
+        };
+
+        let builder = RepositoryManagerBuilder {
+            dynamic_configs_path: dynamic_configs_path.into(),
+            static_configs: HashMap::new(),
+            dynamic_configs: dynamic_configs
+                .into_iter()
+                .map(|config| (config.repo_url().clone(), config))
+                .collect(),
+        };
+
+        if let Some(err) = err {
+            Err((builder, err))
+        } else {
+            Ok(builder)
+        }
+    }
+
+    /// Adds these static [RepoConfigs](RepoConfig) to the [RepositoryManager].
+    #[cfg(test)]
+    pub fn static_configs<T>(mut self, iter: T) -> Self
+    where
+        T: IntoIterator<Item = RepositoryConfig>,
+    {
+        for config in iter.into_iter() {
+            self.static_configs.insert(config.repo_url().clone(), config);
+        }
+
+        self
+    }
+
+    /// Load a directory of [RepositoryConfigs](RepositoryConfig) files into the
+    /// [RepositoryManager], or error out if we encounter errors during the load. The
+    /// [RepositoryManagerBuilder] is also returned on error in case the errors should be ignored.
+    pub fn load_static_configs_dir<T>(
+        mut self,
+        static_configs_dir: T,
+    ) -> Result<Self, (Self, Vec<LoadError>)>
+    where
+        T: AsRef<Path>,
+    {
+        let static_configs_dir = static_configs_dir.as_ref();
+
+        let (static_configs, errs) = load_configs_dir(static_configs_dir);
+        self.static_configs = static_configs;
+        if errs.is_empty() {
+            Ok(self)
+        } else {
+            Err((self, errs))
+        }
+    }
+
+    /// Build the [RepositoryManager].
+    pub fn build(self) -> RepositoryManager {
+        RepositoryManager {
+            dynamic_configs_path: self.dynamic_configs_path,
+            static_configs: self.static_configs,
+            dynamic_configs: self.dynamic_configs,
+        }
+    }
 }
 
 /// Load a directory of [RepositoryConfigs] files into a [RepositoryManager], or error out if we
@@ -251,16 +355,57 @@
     use std::fs::File;
     use std::io::Write;
 
+    fn assert_does_not_exist_error(err: &LoadError, missing_path: &Path) {
+        match &err {
+            LoadError::Io { path, error } => {
+                assert_eq!(path, missing_path);
+                assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "{}", error);
+            }
+            err => {
+                panic!("unexpected error: {}", err);
+            }
+        }
+    }
+
+    fn assert_parse_error(err: &LoadError, invalid_path: &Path) {
+        match err {
+            LoadError::Parse { path, .. } => {
+                assert_eq!(path, invalid_path);
+            }
+            err => {
+                panic!("unexpected error: {}", err);
+            }
+        }
+    }
+
+    fn assert_overridden_error(err: &LoadError, config: &RepositoryConfig) {
+        match err {
+            LoadError::Overridden { replaced_config } => {
+                assert_eq!(replaced_config, config);
+            }
+            err => {
+                panic!("unexpected error: {}", err);
+            }
+        }
+    }
+
     #[test]
     fn test_insert_get_remove() {
-        let mut repos = RepositoryManager::new();
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+        let mut repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path).unwrap().build();
+
         assert_eq!(
-            repos,
-            RepositoryManager { static_configs: HashMap::new(), dynamic_configs: HashMap::new() }
+            repomgr,
+            RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path.clone(),
+                static_configs: HashMap::new(),
+                dynamic_configs: HashMap::new(),
+            }
         );
 
         let fuchsia_uri = RepoUri::parse("fuchsia-pkg://fuchsia.com").unwrap();
-        assert_eq!(repos.get(&fuchsia_uri), None);
+        assert_eq!(repomgr.get(&fuchsia_uri), None);
 
         let config1 = RepositoryConfigBuilder::new(fuchsia_uri.clone())
             .add_root_key(RepositoryKey::Ed25519(vec![0]))
@@ -269,10 +414,11 @@
             .add_root_key(RepositoryKey::Ed25519(vec![1]))
             .build();
 
-        assert_eq!(repos.insert(config1.clone()), None);
+        assert_eq!(repomgr.insert(config1.clone()), None);
         assert_eq!(
-            repos,
+            repomgr,
             RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path.clone(),
                 static_configs: HashMap::new(),
                 dynamic_configs: hashmap! {
                     fuchsia_uri.clone() => config1.clone(),
@@ -280,10 +426,11 @@
             }
         );
 
-        assert_eq!(repos.insert(config2.clone()), Some(config1.clone()));
+        assert_eq!(repomgr.insert(config2.clone()), Some(config1.clone()));
         assert_eq!(
-            repos,
+            repomgr,
             RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path.clone(),
                 static_configs: HashMap::new(),
                 dynamic_configs: hashmap! {
                     fuchsia_uri.clone() => config2.clone(),
@@ -291,13 +438,17 @@
             }
         );
 
-        assert_eq!(repos.get(&fuchsia_uri), Some(&config2));
-        assert_eq!(repos.remove(&fuchsia_uri), Ok(Some(config2.clone())));
+        assert_eq!(repomgr.get(&fuchsia_uri), Some(&config2));
+        assert_eq!(repomgr.remove(&fuchsia_uri), Ok(Some(config2.clone())));
         assert_eq!(
-            repos,
-            RepositoryManager { static_configs: HashMap::new(), dynamic_configs: HashMap::new() }
+            repomgr,
+            RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path.clone(),
+                static_configs: HashMap::new(),
+                dynamic_configs: HashMap::new()
+            }
         );
-        assert_eq!(repos.remove(&fuchsia_uri), Ok(None));
+        assert_eq!(repomgr.remove(&fuchsia_uri), Ok(None));
     }
 
     #[test]
@@ -312,13 +463,17 @@
             .add_root_key(RepositoryKey::Ed25519(vec![2]))
             .build();
 
-        let dir = create_dir(vec![(
+        let static_dir = create_dir(vec![(
             "fuchsia.com.json",
             RepositoryConfigs::Version1(vec![fuchsia_config1.clone()]),
         )]);
 
-        let (mut repomgr, errors) = RepositoryManager::load_dir(dir.path());
-        assert!(errors.is_empty(), "errors: {:?}", errors);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let mut repomgr = RepositoryManagerBuilder::new(dynamic_dir.path().join("config"))
+            .unwrap()
+            .load_static_configs_dir(static_dir.path())
+            .unwrap()
+            .build();
 
         assert_eq!(repomgr.get(&fuchsia_uri), Some(&fuchsia_config1));
         assert_eq!(repomgr.insert(fuchsia_config2.clone()), None);
@@ -335,13 +490,19 @@
             .add_root_key(RepositoryKey::Ed25519(vec![1]))
             .build();
 
-        let dir = create_dir(vec![(
+        let static_dir = create_dir(vec![(
             "fuchsia.com.json",
             RepositoryConfigs::Version1(vec![fuchsia_config1.clone()]),
         )]);
 
-        let (mut repomgr, errors) = RepositoryManager::load_dir(dir.path());
-        assert!(errors.is_empty(), "errors: {:?}", errors);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+
+        let mut repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(static_dir.path())
+            .unwrap()
+            .build();
 
         assert_eq!(repomgr.get(&fuchsia_uri), Some(&fuchsia_config1));
         assert_eq!(repomgr.remove(&fuchsia_uri), Err(CannotRemoveStaticRepositories));
@@ -349,26 +510,23 @@
     }
 
     #[test]
-    fn test_load_dir_not_exists() {
-        let dir = tempfile::tempdir().unwrap();
+    fn test_builder_static_configs_dir_not_exists() {
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
 
-        let does_not_exist_dir = dir.path().join("not-exists");
-        let (_, errors) = RepositoryManager::load_dir(&does_not_exist_dir);
+        let static_dir = tempfile::tempdir().unwrap();
+        let does_not_exist_dir = static_dir.path().join("not-exists");
+
+        let (_, errors) = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(&does_not_exist_dir)
+            .unwrap_err();
         assert_eq!(errors.len(), 1, "{:?}", errors);
-
-        match &errors[0] {
-            LoadError::Io { path, error } => {
-                assert_eq!(path, &does_not_exist_dir);
-                assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "{}", error);
-            }
-            err => {
-                panic!("unexpected error: {}", err);
-            }
-        }
+        assert_does_not_exist_error(&errors[0], &does_not_exist_dir);
     }
 
     #[test]
-    fn test_load_dir_invalid() {
+    fn test_builder_static_configs_dir_invalid_config() {
         let dir = tempfile::tempdir().unwrap();
         let invalid_path = dir.path().join("invalid");
 
@@ -395,21 +553,21 @@
                 .unwrap();
         }
 
-        let (repomgr, errors) = RepositoryManager::load_dir(dir.path());
-        assert_eq!(errors.len(), 1, "{:?}", errors);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
 
-        match &errors[0] {
-            LoadError::Parse { path, .. } => {
-                assert_eq!(path, &invalid_path);
-            }
-            err => {
-                panic!("unexpected error: {}", err);
-            }
-        }
+        let (builder, errors) = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(dir.path())
+            .unwrap_err();
+        assert_eq!(errors.len(), 1, "{:?}", errors);
+        assert_parse_error(&errors[0], &invalid_path);
+        let repomgr = builder.build();
 
         assert_eq!(
             repomgr,
             RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path,
                 static_configs: hashmap! {
                     example_uri => example_config,
                     fuchsia_uri => fuchsia_config,
@@ -420,7 +578,7 @@
     }
 
     #[test]
-    fn test_load_dir() {
+    fn test_builder_static_configs_dir() {
         let fuchsia_uri = RepoUri::parse("fuchsia-pkg://fuchsia.com").unwrap();
         let fuchsia_config = RepositoryConfigBuilder::new(fuchsia_uri.clone()).build();
 
@@ -432,12 +590,19 @@
             ("fuchsia.com.json", RepositoryConfigs::Version1(vec![fuchsia_config.clone()])),
         ]);
 
-        let (repomgr, errors) = RepositoryManager::load_dir(dir.path());
-        assert!(errors.is_empty(), "errors: {:?}", errors);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+
+        let repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(dir.path())
+            .unwrap()
+            .build();
 
         assert_eq!(
             repomgr,
             RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path,
                 static_configs: hashmap! {
                     example_uri => example_config,
                     fuchsia_uri => fuchsia_config,
@@ -448,7 +613,7 @@
     }
 
     #[test]
-    fn test_load_dir_overlapping_filename_wins() {
+    fn test_builder_static_configs_dir_overlapping_filename_wins() {
         let fuchsia_uri = RepoUri::parse("fuchsia-pkg://fuchsia.com").unwrap();
 
         let fuchsia_config = RepositoryConfigBuilder::new(fuchsia_uri.clone())
@@ -486,26 +651,25 @@
             ),
         ]);
 
-        let (repomgr, errors) = RepositoryManager::load_dir(dir.path());
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+        let (builder, errors) = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(dir.path())
+            .unwrap_err();
 
-        let overridden_configs =
-            vec![fuchsia_config, fuchsia_com_config, example_config, oem_config];
-        assert_eq!(errors.len(), overridden_configs.len(), "errors: {:?}", errors);
+        assert_eq!(errors.len(), 4);
+        assert_overridden_error(&errors[0], &fuchsia_config);
+        assert_overridden_error(&errors[1], &fuchsia_com_config);
+        assert_overridden_error(&errors[2], &example_config);
+        assert_overridden_error(&errors[3], &oem_config);
 
-        for (err, config) in errors.into_iter().zip(overridden_configs) {
-            match err {
-                LoadError::Overridden { replaced_config } => {
-                    assert_eq!(replaced_config, config);
-                }
-                _ => {
-                    panic!("unexpected error: {}", err);
-                }
-            }
-        }
+        let repomgr = builder.build();
 
         assert_eq!(
             repomgr,
             RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path,
                 static_configs: hashmap! {
                     fuchsia_uri => fuchsia_com_json_config,
                 },
@@ -515,7 +679,7 @@
     }
 
     #[test]
-    fn test_load_dir_overlapping_first_wins() {
+    fn test_builder_static_configs_dir_overlapping_first_wins() {
         let fuchsia_uri = RepoUri::parse("fuchsia-pkg://fuchsia.com").unwrap();
 
         let fuchsia_config1 = RepositoryConfigBuilder::new(fuchsia_uri.clone())
@@ -533,21 +697,22 @@
             ("2", RepositoryConfigs::Version1(vec![fuchsia_config2.clone()])),
         ]);
 
-        let (repomgr, errors) = RepositoryManager::load_dir(dir.path());
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+        let (builder, errors) = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(dir.path())
+            .unwrap_err();
 
         assert_eq!(errors.len(), 1);
-        match &errors[0] {
-            LoadError::Overridden { replaced_config } => {
-                assert_eq!(replaced_config, &fuchsia_config2);
-            }
-            err => {
-                panic!("unexpected error: {}", err);
-            }
-        }
+        assert_overridden_error(&errors[0], &fuchsia_config2);
+
+        let repomgr = builder.build();
 
         assert_eq!(
             repomgr,
             RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path,
                 static_configs: hashmap! {
                     fuchsia_uri => fuchsia_config1,
                 },
@@ -557,8 +722,127 @@
     }
 
     #[test]
+    fn test_builder_dynamic_configs_path_ignores_if_not_exists() {
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+        let repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path).unwrap().build();
+
+        assert_eq!(
+            repomgr,
+            RepositoryManager {
+                dynamic_configs_path: dynamic_configs_path,
+                static_configs: HashMap::new(),
+                dynamic_configs: HashMap::new(),
+            }
+        );
+    }
+
+    #[test]
+    fn test_builder_dynamic_configs_path_invalid_config() {
+        let dir = tempfile::tempdir().unwrap();
+        let invalid_path = dir.path().join("invalid");
+
+        {
+            let mut f = File::create(&invalid_path).unwrap();
+            f.write(b"hello world").unwrap();
+        }
+
+        let (builder, err) = RepositoryManagerBuilder::new(&invalid_path).unwrap_err();
+        assert_parse_error(&err, &invalid_path);
+        let repomgr = builder.build();
+
+        assert_eq!(
+            repomgr,
+            RepositoryManager {
+                dynamic_configs_path: invalid_path,
+                static_configs: HashMap::new(),
+                dynamic_configs: HashMap::new(),
+            }
+        );
+    }
+
+    #[test]
+    fn test_builder_dynamic_configs_path() {
+        let fuchsia_uri = RepoUri::parse("fuchsia-pkg://fuchsia.com").unwrap();
+
+        let config = RepositoryConfigBuilder::new(fuchsia_uri.clone())
+            .add_root_key(RepositoryKey::Ed25519(vec![0]))
+            .build();
+
+        let dynamic_dir =
+            create_dir(vec![("config", RepositoryConfigs::Version1(vec![config.clone()]))]);
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+
+        let repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path).unwrap().build();
+
+        assert_eq!(
+            repomgr,
+            RepositoryManager {
+                dynamic_configs_path,
+                static_configs: HashMap::new(),
+                dynamic_configs: hashmap! {
+                    fuchsia_uri => config,
+                },
+            }
+        );
+    }
+
+    #[test]
+    fn test_persistence() {
+        let fuchsia_uri = RepoUri::parse("fuchsia-pkg://fuchsia.com").unwrap();
+
+        let static_config = RepositoryConfigBuilder::new(fuchsia_uri.clone())
+            .add_root_key(RepositoryKey::Ed25519(vec![1]))
+            .build();
+        let static_configs = RepositoryConfigs::Version1(vec![static_config.clone()]);
+        let static_dir = create_dir(vec![("config", static_configs.clone())]);
+
+        let old_dynamic_config = RepositoryConfigBuilder::new(fuchsia_uri.clone())
+            .add_root_key(RepositoryKey::Ed25519(vec![2]))
+            .build();
+        let old_dynamic_configs = RepositoryConfigs::Version1(vec![old_dynamic_config.clone()]);
+        let dynamic_dir = create_dir(vec![("config", old_dynamic_configs.clone())]);
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+
+        let mut repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(&static_dir)
+            .unwrap()
+            .build();
+
+        // make sure the dynamic config file didn't change just from opening it.
+        let f = File::open(&dynamic_configs_path).unwrap();
+        let actual: RepositoryConfigs = serde_json::from_reader(f).unwrap();
+        assert_eq!(actual, old_dynamic_configs);
+
+        let new_dynamic_config = RepositoryConfigBuilder::new(fuchsia_uri.clone())
+            .add_root_key(RepositoryKey::Ed25519(vec![3]))
+            .build();
+        let new_dynamic_configs = RepositoryConfigs::Version1(vec![new_dynamic_config.clone()]);
+
+        // Inserting a new repo should update the config file.
+        assert_eq!(repomgr.insert(new_dynamic_config.clone()), Some(old_dynamic_config));
+        let f = File::open(&dynamic_configs_path).unwrap();
+        let actual: RepositoryConfigs = serde_json::from_reader(f).unwrap();
+        assert_eq!(actual, new_dynamic_configs);
+
+        // Removing the repo should empty out the file.
+        assert_eq!(repomgr.remove(&fuchsia_uri), Ok(Some(new_dynamic_config)));
+        let f = File::open(&dynamic_configs_path).unwrap();
+        let actual: RepositoryConfigs = serde_json::from_reader(f).unwrap();
+        assert_eq!(actual, RepositoryConfigs::Version1(vec![]));
+
+        // We should now be back to the static config.
+        assert_eq!(repomgr.get(&fuchsia_uri), Some(&static_config));
+        assert_eq!(repomgr.remove(&fuchsia_uri), Err(CannotRemoveStaticRepositories));
+    }
+
+    #[test]
     fn test_list_empty() {
-        let repomgr = RepositoryManager::new();
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+
+        let repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path).unwrap().build();
         assert_eq!(repomgr.list().collect::<Vec<_>>(), Vec::<&RepositoryConfig>::new());
     }
 
@@ -570,13 +854,19 @@
         let fuchsia_uri = RepoUri::parse("fuchsia-pkg://fuchsia.com").unwrap();
         let fuchsia_config = RepositoryConfigBuilder::new(fuchsia_uri).build();
 
-        let dir = create_dir(vec![
+        let static_dir = create_dir(vec![
             ("example.com", RepositoryConfigs::Version1(vec![example_config.clone()])),
             ("fuchsia.com", RepositoryConfigs::Version1(vec![fuchsia_config.clone()])),
         ]);
 
-        let (repomgr, errors) = RepositoryManager::load_dir(dir.path());
-        assert_eq!(errors.len(), 0);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+
+        let repomgr = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .load_static_configs_dir(static_dir.path())
+            .unwrap()
+            .build();
 
         assert_eq!(repomgr.list().collect::<Vec<_>>(), vec![&example_config, &fuchsia_config,]);
     }
diff --git a/garnet/bin/pkg_resolver/src/repository_service.rs b/garnet/bin/pkg_resolver/src/repository_service.rs
index a90a66c..738dc42 100644
--- a/garnet/bin/pkg_resolver/src/repository_service.rs
+++ b/garnet/bin/pkg_resolver/src/repository_service.rs
@@ -137,10 +137,10 @@
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::test_util::create_dir;
+    use crate::repository_manager::RepositoryManagerBuilder;
     use fidl::endpoints::create_proxy_and_stream;
     use fidl_fuchsia_pkg::RepositoryIteratorMarker;
-    use fidl_fuchsia_pkg_ext::{RepositoryConfig, RepositoryConfigBuilder, RepositoryConfigs};
+    use fidl_fuchsia_pkg_ext::{RepositoryConfig, RepositoryConfigBuilder};
     use fuchsia_uri::pkg_uri::RepoUri;
     use std::convert::TryInto;
 
@@ -165,8 +165,10 @@
 
     #[fuchsia_async::run_singlethreaded(test)]
     async fn test_list_empty() {
-        let mgr = Arc::new(RwLock::new(RepositoryManager::new()));
-        let service = RepositoryService::new(mgr);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+        let mgr = RepositoryManagerBuilder::new(&dynamic_configs_path).unwrap().build();
+        let service = RepositoryService::new(Arc::new(RwLock::new(mgr)));
 
         let results = await!(list(&service));
         assert_eq!(results, vec![]);
@@ -182,10 +184,12 @@
             })
             .collect::<Vec<_>>();
 
-        let dir = create_dir(vec![("configs", RepositoryConfigs::Version1(configs.clone()))]);
-
-        let (mgr, errors) = RepositoryManager::load_dir(dir);
-        assert_eq!(errors.len(), 0);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+        let mgr = RepositoryManagerBuilder::new(&dynamic_configs_path)
+            .unwrap()
+            .static_configs(configs.clone())
+            .build();
 
         let service = RepositoryService::new(Arc::new(RwLock::new(mgr)));
 
@@ -196,11 +200,16 @@
 
     #[fuchsia_async::run_singlethreaded(test)]
     async fn test_insert_list_remove() {
-        let mgr = Arc::new(RwLock::new(RepositoryManager::new()));
-        let mut service = RepositoryService::new(mgr);
+        let dynamic_dir = tempfile::tempdir().unwrap();
+        let dynamic_configs_path = dynamic_dir.path().join("config");
+        let mgr = RepositoryManagerBuilder::new(&dynamic_configs_path).unwrap().build();
+        let mut service = RepositoryService::new(Arc::new(RwLock::new(mgr)));
 
         // First, create a bunch of repo configs we're going to use for testing.
-        let configs = (0..1000)
+        // FIXME: the current implementation ends up writing O(n^2) bytes when serializing the
+        // repositories. Raise this number to be greater than LIST_CHUNK_SIZE once serialization
+        // is cheaper.
+        let configs = (0..20)
             .map(|i| {
                 let uri = RepoUri::parse(&format!("fuchsia-pkg://fuchsia{:04}.com", i)).unwrap();
                 RepositoryConfigBuilder::new(uri).build()
diff --git a/garnet/lib/rust/fidl_fuchsia_pkg_ext/src/repo.rs b/garnet/lib/rust/fidl_fuchsia_pkg_ext/src/repo.rs
index 8188d20..83b7832 100644
--- a/garnet/lib/rust/fidl_fuchsia_pkg_ext/src/repo.rs
+++ b/garnet/lib/rust/fidl_fuchsia_pkg_ext/src/repo.rs
@@ -206,7 +206,7 @@
 }
 
 /// Wraper for serializing repository configs to the on-disk JSON format.
-#[derive(Clone, Debug, Deserialize, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
 #[serde(tag = "version", content = "content", deny_unknown_fields)]
 pub enum RepositoryConfigs {
     #[serde(rename = "1")]