Add digests to download targets

This updates the definition of download targets within a manifest to
include a `digest` field, specifying what checksum should be calculated
on the downloaded file.

Change-Id: Ic3139f389ef47ab69c390b5b4729d6641e7970b8
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/qg/+/126212
Commit-Queue: Alexei Frolov <frolv@google.com>
Reviewed-by: Erik Gilling <konkers@google.com>
diff --git a/examples/simple_project/qg.toml b/examples/simple_project/qg.toml
index 7a9d84c..7326390 100644
--- a/examples/simple_project/qg.toml
+++ b/examples/simple_project/qg.toml
@@ -12,14 +12,17 @@
 [[targets.cipd.variants]]
 match = { os = "linux", arch = "x64" }
 url_parameters = { platform = "linux-amd64" }
+digest = "sha256:e9b210f087d12fdf5af68e732228a26753b5ab4bd83c551efb9822252804723e"
 
 [[targets.cipd.variants]]
 match = { os = "macos", arch = "x64" }
 url_parameters = { platform = "mac-amd64" }
+digest = "sha256:9f7ab348bfb8dbac3f3d70ce591f6fa6992524b0f98d756bc1c3820cd113926d"
 
 [[targets.cipd.variants]]
 match = { os = "windows", arch = "x64" }
 url_parameters = { platform = "windows-amd64" }
+digest = "sha256:c98d59b02a9251b24f4466a2ce61a870df0416482bbe2c5011abdbde7c96c4dc"
 
 # TODO(frolv): Support archive downloads.
 
diff --git a/qg/src/digest.rs b/qg/src/digest.rs
new file mode 100644
index 0000000..abaa34d
--- /dev/null
+++ b/qg/src/digest.rs
@@ -0,0 +1,96 @@
+// Copyright 2023 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+use std::str::FromStr;
+
+use crate::Error;
+
+#[derive(Debug, PartialEq)]
+#[allow(clippy::module_name_repetitions)]
+pub enum ExpectedDigest {
+    Ignore,
+    Sha256(String),
+}
+
+impl FromStr for ExpectedDigest {
+    type Err = Error;
+
+    /// Parses a string of the format "algorithm:digest" or the special value
+    /// "ignore".
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s == "ignore" {
+            return Ok(Self::Ignore);
+        }
+
+        let parts: Vec<_> = s.split(':').collect();
+        if parts.len() != 2 || parts[1].is_empty() {
+            // TODO(frolv): Missing digest string.
+            return Err(Error::GenericErrorPlaceholder);
+        }
+
+        match *parts.first().expect("guaranteed to have two elements") {
+            "sha256" => Ok(Self::Sha256(parts[1].to_string())),
+            _ => Err(Error::GenericErrorPlaceholder),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_valid() {
+        assert_eq!(
+            "sha256:abcdef".parse::<ExpectedDigest>().unwrap(),
+            ExpectedDigest::Sha256("abcdef".into())
+        );
+        assert_eq!(
+            "ignore".parse::<ExpectedDigest>().unwrap(),
+            ExpectedDigest::Ignore
+        );
+    }
+
+    #[test]
+    fn test_parse_invalid() {
+        assert!(matches!(
+            "".parse::<ExpectedDigest>().unwrap_err(),
+            Error::GenericErrorPlaceholder,
+        ));
+        assert!(matches!(
+            ":".parse::<ExpectedDigest>().unwrap_err(),
+            Error::GenericErrorPlaceholder,
+        ));
+        assert!(matches!(
+            "abcdef".parse::<ExpectedDigest>().unwrap_err(),
+            Error::GenericErrorPlaceholder,
+        ));
+        assert!(matches!(
+            ":abcdef".parse::<ExpectedDigest>().unwrap_err(),
+            Error::GenericErrorPlaceholder,
+        ));
+        assert!(matches!(
+            "md6:abcdef".parse::<ExpectedDigest>().unwrap_err(),
+            Error::GenericErrorPlaceholder,
+        ));
+        assert!(matches!(
+            "sha256:".parse::<ExpectedDigest>().unwrap_err(),
+            Error::GenericErrorPlaceholder,
+        ));
+        assert!(matches!(
+            "sha256:abc:def".parse::<ExpectedDigest>().unwrap_err(),
+            Error::GenericErrorPlaceholder,
+        ));
+    }
+}
diff --git a/qg/src/lib.rs b/qg/src/lib.rs
index 7d945b1..2aeafe7 100644
--- a/qg/src/lib.rs
+++ b/qg/src/lib.rs
@@ -23,6 +23,7 @@
 pub mod registry;
 pub mod target;
 
+mod digest;
 mod download;
 mod util;
 
diff --git a/qg/src/project/manifest.rs b/qg/src/project/manifest.rs
index 590144f..647b62d 100644
--- a/qg/src/project/manifest.rs
+++ b/qg/src/project/manifest.rs
@@ -1,4 +1,4 @@
-// Copyright 2022 The Pigweed Authors
+// Copyright 2023 The Pigweed Authors
 //
 // Licensed under the Apache License, Version 2.0 (the "License"); you may not
 // use this file except in compliance with the License. You may obtain a copy of
@@ -156,8 +156,11 @@
     ///
     pub format: Option<String>,
 
-    // For a `bin` format, what to rename the downloaded binary.
+    /// For a `bin` format, what to rename the downloaded binary.
     pub bin_name: Option<PathBuf>,
+
+    /// Checksum of the file.
+    pub digest: Option<String>,
 }
 
 /// An override of the default `url_parameters` for a downloadable package.
@@ -176,6 +179,9 @@
     /// Parameter overrides to apply if this variant is matched.
     #[serde(default)]
     pub url_parameters: HashMap<String, String>,
+
+    /// Checksum override for this variant's file.
+    pub digest: Option<String>,
 }
 
 /// A fake target for testing.
diff --git a/qg/src/target.rs b/qg/src/target.rs
index 1894f7c..28619b1 100644
--- a/qg/src/target.rs
+++ b/qg/src/target.rs
@@ -1,4 +1,4 @@
-// Copyright 2022 The Pigweed Authors
+// Copyright 2023 The Pigweed Authors
 //
 // Licensed under the Apache License, Version 2.0 (the "License"); you may not
 // use this file except in compliance with the License. You may obtain a copy of
@@ -15,6 +15,7 @@
 use std::collections::{HashMap, HashSet};
 use std::path::{Path, PathBuf};
 
+use crate::digest::ExpectedDigest;
 use crate::project::manifest;
 use crate::util::StringSub;
 use crate::{download, platform, Error, Result};
@@ -103,12 +104,14 @@
     pub url: StringSub,
     pub url_parameters: HashMap<String, String>,
     pub variants: Vec<DownloadVariant>,
+    pub digest: Option<ExpectedDigest>,
 }
 
 #[derive(Debug)]
 pub struct DownloadVariant {
     pub matches: VariantMatch,
     url_parameters: HashMap<String, String>,
+    pub digest: Option<ExpectedDigest>,
 }
 
 #[derive(Debug)]
@@ -144,9 +147,15 @@
                     }
                 }
 
+                let digest = match variant.digest {
+                    Some(s) => Some(s.parse()?),
+                    None => None,
+                };
+
                 Ok(DownloadVariant {
                     matches: variant_match,
                     url_parameters: variant.url_parameters,
+                    digest,
                 })
             })
             .collect::<Result<Vec<_>>>()?;
@@ -186,11 +195,17 @@
             _ => return Err(Error::GenericErrorPlaceholder),
         };
 
+        let digest = match value.digest {
+            Some(s) => Some(s.parse()?),
+            None => None,
+        };
+
         Ok(Self {
             format,
             url,
             url_parameters,
             variants,
+            digest,
         })
     }
 }
@@ -344,6 +359,7 @@
                     variants: vec![],
                     format: Some("bin".into()),
                     bin_name: Some("qg".into()),
+                    digest: None,
                 },
             )),
         };
@@ -368,6 +384,7 @@
                     variants: vec![],
                     format: Some("bin".into()),
                     bin_name: Some("qg".into()),
+                    digest: None,
                 },
             )),
         };
@@ -392,6 +409,7 @@
                     variants: vec![],
                     format: Some("bin".into()),
                     bin_name: Some("qg".into()),
+                    digest: None,
                 },
             )),
         };
@@ -411,6 +429,7 @@
                     variants: vec![],
                     format: Some("bin".into()),
                     bin_name: Some("qg".into()),
+                    digest: None,
                 },
             )),
         };
@@ -430,6 +449,7 @@
                     variants: vec![],
                     format: Some("bin".into()),
                     bin_name: Some("qg".into()),
+                    digest: None,
                 },
             )),
         };