[pkgctl] Fetch and process repository configs via http.

pkgctl is now able to fetch repository configs via providing a http URL.
To allow for compatibility / transition during amberctl's depreciation,
pkgctl now also processes v1 and v2 conig.json structures.
This implementation leverages the FIDL http service.

Fixed: 71468
Change-Id: Id47472e9ba5c4d2ac34feeb4af0408cabd0c284b
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/495859
Reviewed-by: Erick Tryzelaar <etryzelaar@google.com>
Reviewed-by: Amit Uttamchandani <amituttam@google.com>
Commit-Queue: Thomas Zander <thomasalpinus@google.com>
diff --git a/src/sys/pkg/bin/pkgctl/BUILD.gn b/src/sys/pkg/bin/pkgctl/BUILD.gn
index 152a7bb..9ffcd04 100644
--- a/src/sys/pkg/bin/pkgctl/BUILD.gn
+++ b/src/sys/pkg/bin/pkgctl/BUILD.gn
@@ -16,19 +16,26 @@
   deps = [
     "//garnet/lib/rust/files_async",
     "//sdk/fidl/fuchsia.io:fuchsia.io-rustc",
+    "//sdk/fidl/fuchsia.net:fuchsia.net-rustc",
+    "//sdk/fidl/fuchsia.net.http:fuchsia.net.http-rustc",
+    "//sdk/fidl/fuchsia.net.stack:fuchsia.net.stack-rustc",
     "//sdk/fidl/fuchsia.pkg:fuchsia.pkg-rustc",
     "//sdk/fidl/fuchsia.pkg.rewrite:fuchsia.pkg.rewrite-rustc",
     "//sdk/fidl/fuchsia.space:fuchsia.space-rustc",
     "//src/lib/fidl/rust/fidl",
     "//src/lib/fuchsia-async",
     "//src/lib/fuchsia-component",
+    "//src/lib/fuchsia-url",
     "//src/lib/zircon/rust:fuchsia-zircon",
     "//src/sys/lib/fidl-fuchsia-pkg-ext",
     "//src/sys/lib/fidl-fuchsia-pkg-rewrite-ext",
     "//third_party/rust_crates:anyhow",
     "//third_party/rust_crates:argh",
     "//third_party/rust_crates:futures",
+    "//third_party/rust_crates:hex",
     "//third_party/rust_crates:matches",
+    "//third_party/rust_crates:regex",
+    "//third_party/rust_crates:serde",
     "//third_party/rust_crates:serde_json",
     "//third_party/rust_crates:thiserror",
   ]
@@ -38,6 +45,7 @@
     "src/args.rs",
     "src/error.rs",
     "src/main.rs",
+    "src/v1repoconf.rs",
   ]
 
   visibility = [
diff --git a/src/sys/pkg/bin/pkgctl/meta/pkgctl.cmx b/src/sys/pkg/bin/pkgctl/meta/pkgctl.cmx
index 45c790b..86738bd 100644
--- a/src/sys/pkg/bin/pkgctl/meta/pkgctl.cmx
+++ b/src/sys/pkg/bin/pkgctl/meta/pkgctl.cmx
@@ -7,6 +7,7 @@
     },
     "sandbox": {
         "services": [
+            "fuchsia.net.http.Loader",
             "fuchsia.pkg.PackageCache",
             "fuchsia.pkg.PackageResolver",
             "fuchsia.pkg.PackageResolverAdmin",
diff --git a/src/sys/pkg/bin/pkgctl/src/args.rs b/src/sys/pkg/bin/pkgctl/src/args.rs
index 97813fd..9a0b888 100644
--- a/src/sys/pkg/bin/pkgctl/src/args.rs
+++ b/src/sys/pkg/bin/pkgctl/src/args.rs
@@ -83,12 +83,64 @@
 #[argh(subcommand, name = "add")]
 /// Add a source repository.
 pub struct RepoAddCommand {
-    /// path to a respository config file, in JSON format, which contains the different repository metadata and URLs.
-    #[argh(option, short = 'f')]
+    #[argh(subcommand)]
+    pub subcommand: RepoAddSubCommand,
+}
+
+#[derive(FromArgs, Debug, PartialEq)]
+#[argh(subcommand)]
+pub enum RepoAddSubCommand {
+    File(RepoAddFileCommand),
+    Url(RepoAddUrlCommand),
+}
+
+#[derive(Debug, PartialEq)]
+pub enum RepoConfigFormat {
+    Version1,
+    Version2,
+}
+
+#[derive(FromArgs, Debug, PartialEq)]
+#[argh(subcommand, name = "file")]
+/// Add a respository config from a local file, in JSON format, which contains the different repository metadata and URLs.
+pub struct RepoAddFileCommand {
+    /// the expected config.json file format version.
+    #[argh(
+        option,
+        short = 'f',
+        default = "RepoConfigFormat::Version2",
+        from_str_fn(repo_config_format)
+    )]
+    pub format: RepoConfigFormat,
+    /// name of the source (a name from the URL will be derived if not provided).
+    #[argh(option, short = 'n')]
+    pub name: Option<String>,
+    /// respository config file, in JSON format, which contains the different repository metadata and URLs.
+    #[argh(positional)]
     pub file: PathBuf,
 }
 
 #[derive(FromArgs, Debug, PartialEq)]
+#[argh(subcommand, name = "url")]
+/// Add a respository config via http, in JSON format, which contains the different repository metadata and URLs.
+pub struct RepoAddUrlCommand {
+    /// the expected config.json file format version.
+    #[argh(
+        option,
+        short = 'f',
+        default = "RepoConfigFormat::Version2",
+        from_str_fn(repo_config_format)
+    )]
+    pub format: RepoConfigFormat,
+    /// name of the source (a name from the URL will be derived if not provided).
+    #[argh(option, short = 'n')]
+    pub name: Option<String>,
+    /// http(s) URL pointing to a respository config file, in JSON format, which contains the different repository metadata and URLs.
+    #[argh(positional)]
+    pub repo_url: String,
+}
+
+#[derive(FromArgs, Debug, PartialEq)]
 #[argh(subcommand, name = "rm")]
 /// Remove a configured source repository.
 pub struct RepoRemoveCommand {
@@ -243,6 +295,14 @@
     serde_json::from_str(&config).map_err(|e| e.to_string())
 }
 
+fn repo_config_format(value: &str) -> Result<RepoConfigFormat, String> {
+    match value {
+        "1" => Ok(RepoConfigFormat::Version1),
+        "2" => Ok(RepoConfigFormat::Version2),
+        _ => Err(format!("unknown format {:?}", value)),
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use {super::*, matches::assert_matches};
@@ -321,17 +381,55 @@
         check(&["repo", "-v"], RepoCommand { verbose: true, subcommand: None });
         check(&["repo", "--verbose"], RepoCommand { verbose: true, subcommand: None });
         check(
-            &["repo", "add", "-f", "foo"],
+            &["repo", "add", "file", "foo"],
             RepoCommand {
                 verbose: false,
-                subcommand: Some(RepoSubCommand::Add(RepoAddCommand { file: "foo".into() })),
+                subcommand: Some(RepoSubCommand::Add(RepoAddCommand {
+                    subcommand: RepoAddSubCommand::File(RepoAddFileCommand {
+                        format: RepoConfigFormat::Version2,
+                        name: None,
+                        file: "foo".into(),
+                    }),
+                })),
             },
         );
         check(
-            &["repo", "add", "--file", "foo"],
+            &["repo", "add", "file", "-f", "1", "foo"],
             RepoCommand {
                 verbose: false,
-                subcommand: Some(RepoSubCommand::Add(RepoAddCommand { file: "foo".into() })),
+                subcommand: Some(RepoSubCommand::Add(RepoAddCommand {
+                    subcommand: RepoAddSubCommand::File(RepoAddFileCommand {
+                        format: RepoConfigFormat::Version1,
+                        name: None,
+                        file: "foo".into(),
+                    }),
+                })),
+            },
+        );
+        check(
+            &["repo", "add", "file", "-n", "devhost", "foo"],
+            RepoCommand {
+                verbose: false,
+                subcommand: Some(RepoSubCommand::Add(RepoAddCommand {
+                    subcommand: RepoAddSubCommand::File(RepoAddFileCommand {
+                        format: RepoConfigFormat::Version2,
+                        name: Some("devhost".to_string()),
+                        file: "foo".into(),
+                    }),
+                })),
+            },
+        );
+        check(
+            &["repo", "add", "url", "-n", "devhost", "http://foo.tld/fuchsia/config.json"],
+            RepoCommand {
+                verbose: false,
+                subcommand: Some(RepoSubCommand::Add(RepoAddCommand {
+                    subcommand: RepoAddSubCommand::Url(RepoAddUrlCommand {
+                        format: RepoConfigFormat::Version2,
+                        name: Some("devhost".to_string()),
+                        repo_url: "http://foo.tld/fuchsia/config.json".into(),
+                    }),
+                })),
             },
         );
         check(
diff --git a/src/sys/pkg/bin/pkgctl/src/main.rs b/src/sys/pkg/bin/pkgctl/src/main.rs
index 02d57da..74c60ab 100644
--- a/src/sys/pkg/bin/pkgctl/src/main.rs
+++ b/src/sys/pkg/bin/pkgctl/src/main.rs
@@ -6,11 +6,14 @@
     crate::args::{
         Args, Command, ExperimentCommand, ExperimentDisableCommand, ExperimentEnableCommand,
         ExperimentSubCommand, GcCommand, GetHashCommand, OpenCommand, PkgStatusCommand,
-        RepoAddCommand, RepoCommand, RepoRemoveCommand, RepoSubCommand, ResolveCommand,
-        RuleClearCommand, RuleCommand, RuleDumpDynamicCommand, RuleListCommand, RuleReplaceCommand,
+        RepoAddCommand, RepoAddFileCommand, RepoAddSubCommand, RepoAddUrlCommand, RepoCommand,
+        RepoConfigFormat, RepoRemoveCommand, RepoSubCommand, ResolveCommand, RuleClearCommand,
+        RuleCommand, RuleDumpDynamicCommand, RuleListCommand, RuleReplaceCommand,
         RuleReplaceFileCommand, RuleReplaceJsonCommand, RuleReplaceSubCommand, RuleSubCommand,
     },
+    crate::v1repoconf::SourceConfig,
     anyhow::{bail, format_err, Context as _},
+    fidl_fuchsia_net_http::{self as http},
     fidl_fuchsia_pkg::{
         PackageCacheMarker, PackageResolverAdminMarker, PackageResolverMarker, PackageUrl,
         RepositoryManagerMarker, RepositoryManagerProxy,
@@ -22,6 +25,7 @@
     files_async, fuchsia_async as fasync,
     fuchsia_component::client::connect_to_service,
     fuchsia_zircon as zx,
+    futures::io::copy,
     futures::stream::TryStreamExt,
     std::{
         convert::{TryFrom, TryInto},
@@ -35,6 +39,7 @@
 
 mod args;
 mod error;
+mod v1repoconf;
 
 pub fn main() -> Result<(), anyhow::Error> {
     let mut executor = fasync::Executor::new()?;
@@ -167,12 +172,55 @@
                     }
                     Ok(0)
                 }
-                Some(RepoSubCommand::Add(RepoAddCommand { file })) => {
-                    let repo: RepositoryConfig =
-                        serde_json::from_reader(io::BufReader::new(File::open(file)?))?;
+                Some(RepoSubCommand::Add(RepoAddCommand { subcommand })) => {
+                    match subcommand {
+                        RepoAddSubCommand::File(RepoAddFileCommand { format, name, file }) => {
+                            let res = match format {
+                                RepoConfigFormat::Version1 => {
+                                    let mut repo: SourceConfig = serde_json::from_reader(
+                                        io::BufReader::new(File::open(file)?),
+                                    )?;
+                                    // If a name is specified via the command line, override the
+                                    // automatically derived name.
+                                    if let Some(n) = name {
+                                        repo.set_id(&n);
+                                    }
+                                    let r = repo_manager.add(repo.into()).await?;
+                                    r
+                                }
+                                RepoConfigFormat::Version2 => {
+                                    let repo: RepositoryConfig = serde_json::from_reader(
+                                        io::BufReader::new(File::open(file)?),
+                                    )?;
+                                    let r = repo_manager.add(repo.into()).await?;
+                                    r
+                                }
+                            };
 
-                    let res = repo_manager.add(repo.into()).await?;
-                    let () = res.map_err(zx::Status::from_raw)?;
+                            let () = res.map_err(zx::Status::from_raw)?;
+                        }
+                        RepoAddSubCommand::Url(RepoAddUrlCommand { format, name, repo_url }) => {
+                            let res = fetch_url(repo_url).await?;
+                            let res = match format {
+                                RepoConfigFormat::Version1 => {
+                                    let mut repo: SourceConfig = serde_json::from_slice(&res)?;
+                                    // If a name is specified via the command line, override the
+                                    // automatically derived name.
+                                    if let Some(n) = name {
+                                        repo.set_id(&n);
+                                    }
+                                    let r = repo_manager.add(repo.into()).await?;
+                                    r
+                                }
+                                RepoConfigFormat::Version2 => {
+                                    let repo: RepositoryConfig = serde_json::from_slice(&res)?;
+                                    let r = repo_manager.add(repo.into()).await?;
+                                    r
+                                }
+                            };
+                            let () = res.map_err(zx::Status::from_raw)?;
+                        }
+                    }
 
                     Ok(0)
                 }
@@ -368,3 +416,44 @@
         .map(|repo| RepositoryConfig::try_from(repo).map_err(|e| anyhow::Error::from(e)))
         .collect()
 }
+
+async fn fetch_url<T: Into<String>>(url_string: T) -> Result<Vec<u8>, anyhow::Error> {
+    let http_svc = connect_to_service::<http::LoaderMarker>()
+        .context("Unable to connect to fuchsia.net.http.Loader")?;
+
+    let url_request = http::Request {
+        url: Some(url_string.into()),
+        method: Some(String::from("GET")),
+        headers: None,
+        body: None,
+        deadline: None,
+        ..http::Request::EMPTY
+    };
+
+    let response =
+        http_svc.fetch(url_request).await.context("Error while calling Loader::Fetch")?;
+
+    if let Some(e) = response.error {
+        return Err(format_err!("LoaderProxy error - {:?}", e));
+    }
+
+    let socket = match response.body {
+        Some(s) => fasync::Socket::from_socket(s).context("Error while wrapping body socket")?,
+        _ => {
+            return Err(format_err!("failed to read UrlBody from the stream"));
+        }
+    };
+
+    let mut body = Vec::new();
+    let bytes_received =
+        copy(socket, &mut body).await.context("Failed to read bytes from the socket")?;
+
+    if bytes_received < 1 {
+        return Err(format_err!(
+            "Failed to download data from url! bytes_received = {}",
+            bytes_received
+        ));
+    }
+
+    Ok(body)
+}
diff --git a/src/sys/pkg/bin/pkgctl/src/v1repoconf.rs b/src/sys/pkg/bin/pkgctl/src/v1repoconf.rs
new file mode 100644
index 0000000..929bd12
--- /dev/null
+++ b/src/sys/pkg/bin/pkgctl/src/v1repoconf.rs
@@ -0,0 +1,131 @@
+// Copyright 2021 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This is a temporary solution to use v1 repository configs in pkgctl
+// until there is no longer a need to accept v1 repository configs.
+
+use {
+    fidl_fuchsia_pkg as fidl,
+    serde::{Deserialize, Serialize},
+    std::borrow::Cow,
+};
+
+#[derive(Debug, Serialize, Deserialize)]
+#[allow(non_snake_case)]
+pub struct KeyConfig {
+    r#type: String,
+    #[serde(with = "hex_serde")]
+    value: Vec<u8>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[allow(non_snake_case)]
+pub struct StatusConfig {
+    Enabled: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[allow(non_snake_case)]
+pub struct BlobEncryptionKey {
+    #[serde(with = "hex_serde")]
+    Data: Vec<u8>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[allow(non_snake_case)]
+pub struct SourceConfig {
+    ID: String,
+    RepoURL: String,
+    BlobRepoURL: String,
+    RatePeriod: i64,
+    RootKeys: Vec<KeyConfig>,
+    #[serde(default = "default_root_version")]
+    rootVersion: u32,
+    #[serde(default = "default_root_threshold")]
+    rootThreshold: u32,
+    StatusConfig: StatusConfig,
+    Auto: bool,
+    BlobKey: Option<BlobEncryptionKey>,
+}
+
+impl SourceConfig {
+    pub fn set_id(&mut self, id: &str) {
+        self.ID = id.to_string();
+    }
+}
+
+impl From<SourceConfig> for fidl::RepositoryConfig {
+    fn from(config: SourceConfig) -> Self {
+        fidl::RepositoryConfig {
+            repo_url: Some(format_repo_url(&config.ID)),
+            root_version: Some(config.rootVersion),
+            root_threshold: Some(config.rootThreshold),
+            root_keys: Some(config.RootKeys.into_iter().map(|key| key.into()).collect()),
+            mirrors: Some({
+                [fidl::MirrorConfig {
+                    mirror_url: Some(config.RepoURL),
+                    subscribe: Some(config.Auto),
+                    ..fidl::MirrorConfig::EMPTY
+                }]
+                .to_vec()
+            }),
+            ..fidl::RepositoryConfig::EMPTY
+        }
+    }
+}
+
+impl From<KeyConfig> for fidl::RepositoryKeyConfig {
+    fn from(key: KeyConfig) -> Self {
+        match key.r#type.as_str() {
+            "ed25519" => fidl::RepositoryKeyConfig::Ed25519Key(key.value),
+            _ => fidl::RepositoryKeyConfig::unknown(0, Default::default()),
+        }
+    }
+}
+
+fn default_root_version() -> u32 {
+    1
+}
+
+fn default_root_threshold() -> u32 {
+    1
+}
+
+fn format_repo_url<'a>(url: &'a str) -> String {
+    // If the canonical prefix was already part of the command line argument provided,
+    // don't sanitize this prefix part of the string.
+    let id = if let Some(u) = url.strip_prefix("fuchsia-pkg://") { u } else { url };
+    return format!("fuchsia-pkg://{}", sanitize_id(id));
+}
+
+fn sanitize_id<'a>(id: &'a str) -> Cow<'a, str> {
+    return id
+        .chars()
+        .map(|c| match c {
+            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' => c,
+            _ => '_',
+        })
+        .collect();
+}
+
+mod hex_serde {
+    use {hex, serde::Deserialize};
+
+    pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let s = hex::encode(bytes);
+        serializer.serialize_str(&s)
+    }
+
+    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let value = String::deserialize(deserializer)?;
+        hex::decode(value.as_bytes())
+            .map_err(|e| serde::de::Error::custom(format!("bad hex value: {:?}: {}", value, e)))
+    }
+}
diff --git a/src/sys/pkg/lib/repo/add.go b/src/sys/pkg/lib/repo/add.go
index c61ad0b..9c62136 100644
--- a/src/sys/pkg/lib/repo/add.go
+++ b/src/sys/pkg/lib/repo/add.go
@@ -83,7 +83,7 @@
 }
 
 func repoAddCmd(file string) []string {
-	return []string{"pkgctl", "repo", "add", "--file", file}
+	return []string{"pkgctl", "repo", "add", "file", file}
 }
 
 type shell interface {
diff --git a/src/sys/pkg/tests/pkgctl/BUILD.gn b/src/sys/pkg/tests/pkgctl/BUILD.gn
index b73c8b6..d27ecf4 100644
--- a/src/sys/pkg/tests/pkgctl/BUILD.gn
+++ b/src/sys/pkg/tests/pkgctl/BUILD.gn
@@ -10,14 +10,18 @@
   edition = "2018"
 
   deps = [
+    "//sdk/fidl/fuchsia.net:fuchsia.net-rustc",
+    "//sdk/fidl/fuchsia.net.http:fuchsia.net.http-rustc",
     "//sdk/fidl/fuchsia.pkg:fuchsia.pkg-rustc",
     "//sdk/fidl/fuchsia.pkg.rewrite:fuchsia.pkg.rewrite-rustc",
+    "//sdk/fidl/fuchsia.posix.socket:fuchsia.posix.socket-rustc",
     "//sdk/fidl/fuchsia.space:fuchsia.space-rustc",
     "//sdk/fidl/fuchsia.sys:fuchsia.sys-rustc",
     "//src/lib/fidl/rust/fidl",
     "//src/lib/fuchsia-async",
     "//src/lib/fuchsia-component",
     "//src/lib/fuchsia-url",
+    "//src/lib/testing/fuchsia-hyper-test-support",
     "//src/lib/zircon/rust:fuchsia-zircon",
     "//src/sys/lib/fidl-fuchsia-pkg-ext",
     "//src/sys/lib/fidl-fuchsia-pkg-rewrite-ext",
diff --git a/src/sys/pkg/tests/pkgctl/meta/pkgctl-integration-test.cmx b/src/sys/pkg/tests/pkgctl/meta/pkgctl-integration-test.cmx
index ee78aee3..0a58067 100644
--- a/src/sys/pkg/tests/pkgctl/meta/pkgctl-integration-test.cmx
+++ b/src/sys/pkg/tests/pkgctl/meta/pkgctl-integration-test.cmx
@@ -1,6 +1,17 @@
 {
+    "facets": {
+        "fuchsia.test": {
+            "injected-services": {
+                "fuchsia.net.NameLookup": "fuchsia-pkg://fuchsia.com/dns-resolver#meta/dns-resolver.cmx",
+                "fuchsia.net.http.Loader": "fuchsia-pkg://fuchsia.com/http-client#meta/http-client.cmx",
+                "fuchsia.net.routes.State": "fuchsia-pkg://fuchsia.com/netstack#meta/netstack.cmx",
+                "fuchsia.posix.socket.Provider": "fuchsia-pkg://fuchsia.com/netstack#meta/netstack.cmx"
+            }
+        }
+    },
     "include": [
-        "sdk/lib/diagnostics/syslog/client.shard.cmx"
+        "sdk/lib/diagnostics/syslog/client.shard.cmx",
+        "src/lib/fuchsia-hyper/hyper.shard.cmx"
     ],
     "program": {
         "binary": "test/pkgctl-integration-test"
@@ -10,6 +21,7 @@
             "isolated-temp"
         ],
         "services": [
+            "fuchsia.net.http.Loader",
             "fuchsia.sys.Environment",
             "fuchsia.sys.Launcher",
             "fuchsia.sys.Loader"
diff --git a/src/sys/pkg/tests/pkgctl/src/lib.rs b/src/sys/pkg/tests/pkgctl/src/lib.rs
index a9e3fae..3711d7c 100644
--- a/src/sys/pkg/tests/pkgctl/src/lib.rs
+++ b/src/sys/pkg/tests/pkgctl/src/lib.rs
@@ -26,6 +26,7 @@
         client::{AppBuilder, Output},
         server::{NestedEnvironment, ServiceFs},
     },
+    fuchsia_hyper_test_support::{handler::StaticResponse, TestServer},
     fuchsia_url::pkg_url::{PkgUrl, RepoUrl},
     fuchsia_zircon::Status,
     futures::prelude::*,
@@ -34,6 +35,7 @@
     std::{
         convert::TryFrom,
         fs::{create_dir, File},
+        io::Write,
         iter::FusedIterator,
         path::PathBuf,
         sync::Arc,
@@ -59,6 +61,7 @@
 
     fn new() -> Self {
         let mut fs = ServiceFs::new();
+        fs.add_proxy_service::<fidl_fuchsia_net_http::LoaderMarker, _>();
 
         let package_resolver = Arc::new(MockPackageResolverService::new());
         let package_resolver_clone = package_resolver.clone();
@@ -488,6 +491,40 @@
         .build()
 }
 
+// This builds a v2 RepositoryConfig that is expected to be in the repository manager add request
+// when pkgctl is provided with the V1_LEGACY_TEST_REPO_JSON structure below as input.
+fn make_v1_legacy_expected_test_repo_config() -> RepositoryConfig {
+    RepositoryConfigBuilder::new(RepoUrl::new("legacy_repo".to_string()).expect("valid url"))
+        .add_root_key(RepositoryKey::Ed25519(vec![0u8]))
+        .add_mirror(
+            MirrorConfigBuilder::new("http://legacy.org".parse::<Uri>().unwrap())
+                .unwrap()
+                .subscribe(true)
+                .build(),
+        )
+        .build()
+}
+
+const V1_LEGACY_TEST_REPO_JSON: &str = r#"{
+    "ID":"legacy_repo",
+    "RepoURL":"http://legacy.org",
+    "BlobRepoURL":"http://legacy.org/blobs",
+    "RatePeriod":60,
+    "RootKeys":[
+        {
+            "type":"ed25519",
+            "value":"00"
+        }
+    ],
+    "rootVersion":1,
+    "rootThreshold":1,
+    "StatusConfig":{
+        "Enabled":true
+    },
+    "Auto":true,
+    "BlobKey":null
+}"#;
+
 #[fasync::run_singlethreaded(test)]
 async fn test_repo() {
     let env = TestEnv::new();
@@ -578,17 +615,38 @@
 }
 
 macro_rules! repo_add_tests {
-    ($($test_name:ident: $flag:expr,)*) => {
+    ($($test_name:ident: $source:expr, $version:expr,)*) => {
         $(
             #[fasync::run_singlethreaded(test)]
             async fn $test_name() {
                 let env = TestEnv::new();
-                let repo_config = make_test_repo_config();
-                let f =
-                    File::create(env.repo_config_arg_path.join("the-config")).expect("create repo config file");
-                serde_json::to_writer(f, &repo_config).expect("write RepositoryConfig json");
 
-                let output = env.run_pkgctl(vec!["repo", "add", $flag, "/repo-configs/the-config"]).await;
+                let repo_config = match $version {
+                    "1" =>  make_v1_legacy_expected_test_repo_config(),
+                    _ => make_test_repo_config(),
+                };
+
+                let output = match $source {
+                    "file" => {
+                        let mut f =
+                            File::create(env.repo_config_arg_path.join("the-config")).expect("create repo config file");
+                        match $version {
+                            "1" => { f.write_all(V1_LEGACY_TEST_REPO_JSON.as_bytes()).expect("write v1 SourceConfig json"); },
+                            _ => { serde_json::to_writer(f, &repo_config).expect("write RepositoryConfig json"); }
+                        };
+                        env.run_pkgctl(vec!["repo", "add", $source, "-f", $version, "/repo-configs/the-config"]).await
+                    },
+                    "url" => {
+                        let response = match $version {
+                            "1" => StaticResponse::ok_body(V1_LEGACY_TEST_REPO_JSON),
+                            _ => StaticResponse::ok_body(serde_json::to_string(&repo_config).unwrap()),
+                        };
+                        let server = TestServer::builder().handler(response).start();
+                        env.run_pkgctl(vec!["repo", "add", $source, "-f", $version, server.local_url_for_path("some/path").as_str()]).await
+                    },
+                    // Unsupported source
+                    _ => env.run_pkgctl(vec!["repo", "add", $source, "-f", $version]).await,
+                };
 
                 assert_stdout(&output, "");
                 env.assert_only_repository_manager_called_with(vec![CapturedRepositoryManagerRequest::Add {
@@ -600,8 +658,10 @@
 }
 
 repo_add_tests! {
-    test_repo_add_short: "-f",
-    test_repo_add_long: "--file",
+    test_repo_add_v1_file: "file", "1",
+    test_repo_add_v2_file: "file", "2",
+    test_repo_add_v1_url: "url", "1",
+    test_repo_add_v2_url: "url", "2",
 }
 
 #[fasync::run_singlethreaded(test)]