diff --git a/crates/utils/lib/lib.rs b/crates/utils/lib/lib.rs index a43719ed8..f374726ee 100644 --- a/crates/utils/lib/lib.rs +++ b/crates/utils/lib/lib.rs @@ -227,6 +227,27 @@ pub fn bundle_download_url(version: &str, arch: &str, os: &str) -> String { ) } +/// Returns the GitHub release download URL for the `checksums.sha256` asset +/// listing the SHA-256 digest of every asset in the release. +pub fn checksums_download_url(version: &str) -> String { + format!( + "https://github.com/{GITHUB_ORG}/{MICROSANDBOX_REPO}/releases/download/v{version}/checksums.sha256" + ) +} + +/// Extracts the digest published for `filename` from `sha256sum`-formatted +/// checksums, as released in the `checksums.sha256` asset. Returns `None` +/// when no entry matches. +pub fn bundle_digest_from_checksums(checksums: &str, filename: &str) -> Option { + checksums.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let digest = fields.next()?; + // sha256sum marks binary-mode entries with a leading `*`. + let name = fields.next()?.trim_start_matches('*'); + (name == filename).then(|| digest.to_owned()) + }) +} + /// Returns an HTTP client configured for release asset downloads. #[cfg(feature = "http-client")] pub fn http_client() -> ureq::Agent { @@ -287,6 +308,33 @@ pub fn is_windows_drive_separator_at(s: &str, index: usize) -> bool { mod tests { use super::*; + #[test] + fn bundle_digest_is_selected_from_sha256sum_checksums() { + let checksums = format!( + "{} agentd-aarch64\n\ + {} microsandbox-darwin-aarch64.tar.gz\n\ + {} *microsandbox-linux-x86_64.tar.gz\n\ + malformed line\n", + "a".repeat(64), + "b".repeat(64), + "c".repeat(64), + ); + + assert_eq!( + bundle_digest_from_checksums(&checksums, "microsandbox-darwin-aarch64.tar.gz"), + Some("b".repeat(64)), + ); + // Binary-mode `*` markers are stripped before matching. + assert_eq!( + bundle_digest_from_checksums(&checksums, "microsandbox-linux-x86_64.tar.gz"), + Some("c".repeat(64)), + ); + assert_eq!( + bundle_digest_from_checksums(&checksums, "microsandbox-windows-x86_64.tar.gz"), + None, + ); + } + /// `MSB_HOME` is honoured verbatim (no `.microsandbox` suffix appended) /// so callers can isolate state per process without disturbing tooling /// that reads `$HOME` (npm cache, ssh keys, etc.). diff --git a/sdk/go/setup.go b/sdk/go/setup.go index fc2ca6815..e65e0aa29 100644 --- a/sdk/go/setup.go +++ b/sdk/go/setup.go @@ -2,8 +2,11 @@ package microsandbox import ( "archive/tar" + "bytes" "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -109,7 +112,9 @@ var ( // EnsureInstalled ensures the msb + libkrunfw runtime is present at // ~/.microsandbox/ and downloads it from the matching GitHub release -// if not. It is OPTIONAL: the SDK's FFI library is embedded in the +// if not, verifying the bundle against the SHA-256 digest published in +// the release's checksums.sha256 asset before extracting anything. +// It is OPTIONAL: the SDK's FFI library is embedded in the // Go binary and loads automatically on first use, so EnsureInstalled // only governs the optional msb runtime download. // @@ -212,7 +217,7 @@ func materializeFFI(dir string) (string, error) { return "", fmt.Errorf("create %s: %w", libDir, err) } dest := filepath.Join(libDir, bundle.Filename()) - if existing, err := os.ReadFile(dest); err == nil && bytesEqual(existing, ffiBytes) { + if existing, err := os.ReadFile(dest); err == nil && bytes.Equal(existing, ffiBytes) { return dest, nil } if err := writeFile(dest, ffiBytes, 0o755); err != nil { @@ -349,8 +354,14 @@ func osStringFor(goos string) (string, error) { } } -// bundleURL is the GitHub release asset URL for the current OS/arch. -func bundleURL() (string, error) { +// releaseDownloadBase is the GitHub release download URL prefix. A var so +// tests can point downloads at a local server. +var releaseDownloadBase = fmt.Sprintf( + "https://github.com/%s/%s/releases/download", githubOrg, githubRepo) + +// bundleFilename is the release asset name of the msb + libkrunfw bundle +// for the current OS/arch. +func bundleFilename() (string, error) { arch, err := archString() if err != nil { return "", err @@ -359,15 +370,30 @@ func bundleURL() (string, error) { if err != nil { return "", err } - return fmt.Sprintf( - "https://github.com/%s/%s/releases/download/v%s/%s-%s-%s.tar.gz", - githubOrg, githubRepo, sdkVersion, githubRepo, osName, arch, - ), nil + return fmt.Sprintf("%s-%s-%s.tar.gz", githubRepo, osName, arch), nil +} + +// bundleURL is the GitHub release asset URL for the current OS/arch. +func bundleURL() (string, error) { + name, err := bundleFilename() + if err != nil { + return "", err + } + return fmt.Sprintf("%s/v%s/%s", releaseDownloadBase, sdkVersion, name), nil +} + +// checksumsURL is the GitHub release asset URL for the checksums.sha256 file +// listing the SHA-256 digest of every asset in the release. +func checksumsURL() string { + return fmt.Sprintf("%s/v%s/checksums.sha256", releaseDownloadBase, sdkVersion) } -// downloadMsbAndKrunfw fetches the release bundle and extracts msb + -// libkrunfw into /{bin,lib}/. The FFI library inside the -// tarball is ignored (the SDK ships it embedded). +// downloadMsbAndKrunfw fetches the release bundle, verifies it against the +// SHA-256 digest published in the release's checksums.sha256 asset, and +// extracts msb + libkrunfw into /{bin,lib}/. Verification is +// fail-closed: if the digest cannot be fetched or does not match, nothing +// is extracted. The FFI library inside the tarball is ignored (the SDK +// ships it embedded). func downloadMsbAndKrunfw(ctx context.Context, installDir string) error { binDir := filepath.Join(installDir, "bin") libDir := filepath.Join(installDir, "lib") @@ -378,31 +404,34 @@ func downloadMsbAndKrunfw(ctx context.Context, installDir string) error { return err } + filename, err := bundleFilename() + if err != nil { + return err + } url, err := bundleURL() if err != nil { return err } - reqCtx, cancel := context.WithTimeout(ctx, httpTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + sums, err := httpGet(ctx, checksumsURL()) + if err != nil { + return fmt.Errorf("fetch release checksums: %w", err) + } + want, err := bundleDigestFromChecksums(string(sums), filename) if err != nil { return err } - client := &http.Client{Timeout: httpTimeout} - resp, err := client.Do(req) + data, err := httpGet(ctx, url) if err != nil { - return fmt.Errorf("GET %s: %w", url, err) + return err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + sum := sha256.Sum256(data) + if got := hex.EncodeToString(sum[:]); got != want { + return fmt.Errorf("%s SHA-256 mismatch: expected %s, got %s", filename, want, got) } - if err := extractMsbAndKrunfw(resp.Body, binDir, libDir); err != nil { + if err := extractMsbAndKrunfw(bytes.NewReader(data), binDir, libDir); err != nil { return err } @@ -427,6 +456,58 @@ func downloadMsbAndKrunfw(ctx context.Context, installDir string) error { return nil } +// httpGet fetches url and returns the full response body. +func httpGet(ctx context.Context, url string) ([]byte, error) { + reqCtx, cancel := context.WithTimeout(ctx, httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + return body, nil +} + +// bundleDigestFromChecksums extracts the digest for filename from the +// sha256sum-formatted checksums published with each release. +func bundleDigestFromChecksums(checksums, filename string) (string, error) { + for _, line := range strings.Split(checksums, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + // sha256sum marks binary-mode entries with a leading "*". + if strings.TrimPrefix(fields[1], "*") != filename { + continue + } + digest := strings.ToLower(fields[0]) + if len(digest) != 64 { + return "", fmt.Errorf("checksums.sha256 publishes an invalid SHA-256 for %s: %s", filename, fields[0]) + } + if _, err := hex.DecodeString(digest); err != nil { + return "", fmt.Errorf("checksums.sha256 publishes an invalid SHA-256 for %s: %s", filename, fields[0]) + } + return digest, nil + } + return "", fmt.Errorf("checksums.sha256 has no entry for %s", filename) +} + // extractMsbAndKrunfw streams a tar.gz from r and copies msb + libkrunfw* // into the appropriate dirs. Any libmicrosandbox_go_ffi entries are // skipped — the SDK ships its FFI library embedded. @@ -510,16 +591,3 @@ func writeFile(dest string, data []byte, mode os.FileMode) error { } return nil } - -// bytesEqual is a tiny byte-slice equality without an `bytes` import. -func bytesEqual(a, b []byte) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/sdk/go/setup_test.go b/sdk/go/setup_test.go index 286f99a50..d7e54728e 100644 --- a/sdk/go/setup_test.go +++ b/sdk/go/setup_test.go @@ -4,9 +4,15 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" + "strings" "testing" ) @@ -139,6 +145,161 @@ func TestExtractMsbAndKrunfwWindowsBundle(t *testing.T) { } } +func TestBundleDigestFromChecksums(t *testing.T) { + t.Parallel() + + linuxDigest := strings.Repeat("ab", 32) + darwinDigest := strings.Repeat("CD", 32) + checksums := linuxDigest + " microsandbox-linux-x86_64.tar.gz\n" + + darwinDigest + " *microsandbox-darwin-aarch64.tar.gz\n" + + "malformed line\n" + + got, err := bundleDigestFromChecksums(checksums, "microsandbox-linux-x86_64.tar.gz") + if err != nil { + t.Fatalf("bundleDigestFromChecksums: %v", err) + } + if got != linuxDigest { + t.Errorf("digest = %q, want %q", got, linuxDigest) + } + + // Binary-mode "*" markers are stripped and digests normalized to + // lowercase. + got, err = bundleDigestFromChecksums(checksums, "microsandbox-darwin-aarch64.tar.gz") + if err != nil { + t.Fatalf("bundleDigestFromChecksums: %v", err) + } + if got != strings.ToLower(darwinDigest) { + t.Errorf("digest = %q, want %q", got, strings.ToLower(darwinDigest)) + } + + if _, err := bundleDigestFromChecksums(checksums, "microsandbox-windows-x86_64.tar.gz"); err == nil || + !strings.Contains(err.Error(), "microsandbox-windows-x86_64.tar.gz") { + t.Errorf("missing entry error = %v, want mention of the bundle filename", err) + } + + if _, err := bundleDigestFromChecksums("nothex bundle.tar.gz\n", "bundle.tar.gz"); err == nil || + !strings.Contains(err.Error(), "invalid SHA-256") { + t.Errorf("invalid digest error = %v, want invalid SHA-256 error", err) + } +} + +// makeBundleTarball builds an in-memory tar.gz shaped like a release bundle +// for the current platform. +func makeBundleTarball(t *testing.T) []byte { + t.Helper() + + var archive bytes.Buffer + gz := gzip.NewWriter(&archive) + tw := tar.NewWriter(gz) + files := []struct { + name string + data string + }{ + {name: msbFilename(), data: "msb"}, + {name: libkrunfwFilename(), data: "krunfw"}, + } + for _, file := range files { + hdr := &tar.Header{ + Name: file.name, + Mode: 0o755, + Size: int64(len(file.data)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write([]byte(file.data)); err != nil { + t.Fatalf("write tar data: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar writer: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip writer: %v", err) + } + return archive.Bytes() +} + +// Not parallel: overrides releaseDownloadBase, which parallel tests must +// not observe. +func TestDownloadMsbAndKrunfwVerifiesBundleDigest(t *testing.T) { + bundle := makeBundleTarball(t) + sum := sha256.Sum256(bundle) + digest := hex.EncodeToString(sum[:]) + filename, err := bundleFilename() + if err != nil { + t.Fatalf("bundleFilename: %v", err) + } + + tests := []struct { + name string + checksums string + checksumsStatus int + wantErr string + }{ + { + name: "verified install", + checksums: digest + " " + filename + "\n", + }, + { + name: "digest mismatch", + checksums: strings.Repeat("0", 64) + " " + filename + "\n", + wantErr: "SHA-256 mismatch", + }, + { + name: "checksums unavailable", + checksumsStatus: http.StatusNotFound, + wantErr: "fetch release checksums", + }, + { + name: "missing bundle entry", + checksums: digest + " other.tar.gz\n", + wantErr: "no entry for", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v" + sdkVersion + "/checksums.sha256": + if tt.checksumsStatus != 0 { + w.WriteHeader(tt.checksumsStatus) + return + } + _, _ = w.Write([]byte(tt.checksums)) + case "/v" + sdkVersion + "/" + filename: + _, _ = w.Write(bundle) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + orig := releaseDownloadBase + releaseDownloadBase = srv.URL + defer func() { releaseDownloadBase = orig }() + + dir := t.TempDir() + err := downloadMsbAndKrunfw(context.Background(), dir) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("downloadMsbAndKrunfw: %v", err) + } + assertFileContents(t, filepath.Join(dir, "bin", msbFilename()), "msb") + assertFileContents(t, filepath.Join(dir, "lib", libkrunfwFilename()), "krunfw") + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("downloadMsbAndKrunfw error = %v, want containing %q", err, tt.wantErr) + } + if _, statErr := os.Stat(filepath.Join(dir, "bin", msbFilename())); !os.IsNotExist(statErr) { + t.Fatalf("msb must not be extracted when verification fails, stat error = %v", statErr) + } + }) + } +} + func assertFileContents(t *testing.T, path, want string) { t.Helper() diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index cbbd800a8..4295fe026 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -80,7 +80,9 @@ zeroize.workspace = true [build-dependencies] flate2.workspace = true +hex.workspace = true microsandbox-utils.workspace = true +sha2.workspace = true tar.workspace = true [dev-dependencies] diff --git a/sdk/rust/build.rs b/sdk/rust/build.rs index cf9488324..c0d57c947 100644 --- a/sdk/rust/build.rs +++ b/sdk/rust/build.rs @@ -80,7 +80,10 @@ fn install_prebuilt(base_dir: PathBuf) { "cargo:warning=downloading microsandbox runtime dependencies (v{PREBUILT_VERSION})..." ); + let expected_digest = + fetch_bundle_digest(&url).expect("failed to fetch microsandbox bundle checksums"); let data = download(&url).expect("failed to download microsandbox bundle"); + verify_bundle_digest(&data, &expected_digest); extract_bundle(&data, &bin_dir, &lib_dir).expect("failed to extract bundle"); create_symlinks(&lib_dir, &libkrunfw_name); @@ -189,6 +192,34 @@ fn download(url: &str) -> Result, Box> { Ok(buf) } +/// Fetch the published SHA-256 digest for the bundle at `bundle_url` from the +/// release's `checksums.sha256` asset. Fail-closed: an unreachable checksums +/// asset or one without an entry for this bundle fails the build. +#[cfg(all(feature = "prebuilt", not(windows)))] +fn fetch_bundle_digest(bundle_url: &str) -> Result> { + let checksums_url = microsandbox_utils::checksums_download_url(PREBUILT_VERSION); + let checksums = String::from_utf8(download(&checksums_url)?)?; + let filename = bundle_url.rsplit('/').next().unwrap_or(bundle_url); + microsandbox_utils::bundle_digest_from_checksums(&checksums, filename) + .ok_or_else(|| format!("release checksums do not contain an entry for {filename}").into()) +} + +#[cfg(all(feature = "prebuilt", not(windows)))] +fn verify_bundle_digest(data: &[u8], expected: &str) { + use sha2::{Digest as _, Sha256}; + + let expected = expected.strip_prefix("sha256:").unwrap_or(expected); + assert!( + expected.len() == 64 && expected.bytes().all(|byte| byte.is_ascii_hexdigit()), + "microsandbox bundle has an invalid published SHA-256 digest: {expected}" + ); + let actual = hex::encode(Sha256::digest(data)); + assert!( + actual.eq_ignore_ascii_case(expected), + "microsandbox bundle SHA-256 mismatch: expected {expected}, got {actual}" + ); +} + #[cfg(all(feature = "prebuilt", not(windows)))] fn extract_bundle(data: &[u8], bin_dir: &Path, lib_dir: &Path) -> io::Result<()> { let decoder = flate2::read::GzDecoder::new(Cursor::new(data)); diff --git a/sdk/rust/lib/setup/download.rs b/sdk/rust/lib/setup/download.rs index bba54b9d0..e28fc0dfe 100644 --- a/sdk/rust/lib/setup/download.rs +++ b/sdk/rust/lib/setup/download.rs @@ -44,9 +44,12 @@ pub struct Setup { /// Expected SHA-256 for the downloaded release bundle. /// - /// Self-downgrade supplies the digest published by the GitHub release API - /// so target staging fails before extraction if the retained bundle bytes - /// do not match the release asset. + /// When set, this digest is used as-is and the release's published + /// `checksums.sha256` asset is not fetched — self-downgrade supplies the + /// digest from the GitHub release API this way. When unset, the digest is + /// fetched from the release's `checksums.sha256` asset before the bundle + /// download. Either way, verification is fail-closed: the bundle is not + /// extracted unless its bytes match the expected digest. #[builder(default, setter(strip_option, into))] expected_bundle_sha256: Option, } @@ -105,15 +108,18 @@ impl Setup { std::env::consts::OS, ); + let expected_digest = match self.expected_bundle_sha256.clone() { + Some(digest) => digest, + None => fetch_bundle_digest(version, &url).await?, + }; + tracing::info!( version = version, url = %url, "downloading microsandbox runtime dependencies" ); let data = download_bytes(&url).await?; - if let Some(expected) = self.expected_bundle_sha256.as_deref() { - verify_bundle_digest(&data, expected)?; - } + verify_bundle_digest(&data, &expected_digest)?; extract_bundle(&data, bin_dir, lib_dir)?; tracing::info!("microsandbox runtime dependencies installed"); @@ -149,8 +155,9 @@ impl Setup { /// Install microsandbox runtime dependencies with default settings. /// -/// This downloads the microsandbox bundle tarball and extracts `msb` -/// and `libkrunfw` to `~/.microsandbox/{bin,lib}/`. +/// This downloads the microsandbox bundle tarball, verifies it against the +/// SHA-256 digest published in the release's `checksums.sha256` asset, and +/// extracts `msb` and `libkrunfw` to `~/.microsandbox/{bin,lib}/`. pub async fn install() -> MicrosandboxResult<()> { Setup::builder().build().install().await } @@ -229,6 +236,41 @@ async fn download_bytes(url: &str) -> MicrosandboxResult> { Ok(data) } +/// Fetch the published SHA-256 digest for the bundle at `bundle_url` from the +/// release's `checksums.sha256` asset. +/// +/// The checksums asset is served from the same release-download endpoint as +/// the bundle itself, so this adds no GitHub API calls (and no unauthenticated +/// rate-limit exposure) to the default install path. The fetch is fail-closed: +/// a release without published checksums, an unreachable checksums asset, or a +/// checksums file without an entry for this bundle all abort installation. +/// Callers that cannot rely on published checksums can supply +/// `expected_bundle_sha256` explicitly instead. +async fn fetch_bundle_digest(version: &str, bundle_url: &str) -> MicrosandboxResult { + let checksums_url = microsandbox_utils::checksums_download_url(version); + let checksums = download_bytes(&checksums_url).await.map_err(|error| { + MicrosandboxError::Custom(format!( + "could not fetch release bundle checksums from {checksums_url}: {error}" + )) + })?; + let checksums = String::from_utf8(checksums).map_err(|_| { + MicrosandboxError::Custom(format!( + "release checksums at {checksums_url} are not valid UTF-8" + )) + })?; + let filename = bundle_url.rsplit('/').next().unwrap_or(bundle_url); + bundle_digest_from_checksums(&checksums, filename) +} + +/// Extract the digest for `filename` from `sha256sum`-formatted checksums. +fn bundle_digest_from_checksums(checksums: &str, filename: &str) -> MicrosandboxResult { + microsandbox_utils::bundle_digest_from_checksums(checksums, filename).ok_or_else(|| { + MicrosandboxError::Custom(format!( + "release checksums do not contain an entry for {filename}" + )) + }) +} + fn verify_bundle_digest(data: &[u8], expected: &str) -> MicrosandboxResult<()> { let expected = expected.strip_prefix("sha256:").unwrap_or(expected); if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { @@ -342,4 +384,51 @@ mod tests { let error = verify_bundle_digest(b"changed", &"0".repeat(64)).unwrap_err(); assert!(error.to_string().contains("SHA-256 mismatch")); } + + #[test] + fn bundle_digest_is_selected_from_release_checksums() { + let hello = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + let checksums = format!( + "{} agentd-aarch64\n\ + {hello} microsandbox-darwin-aarch64.tar.gz\n\ + {} *microsandbox-linux-x86_64.tar.gz\n", + "a".repeat(64), + "b".repeat(64), + ); + + let digest = + bundle_digest_from_checksums(&checksums, "microsandbox-darwin-aarch64.tar.gz").unwrap(); + assert_eq!(digest, hello); + verify_bundle_digest(b"hello", &digest).unwrap(); + + // Binary-mode `*` markers are stripped before matching. + let digest = + bundle_digest_from_checksums(&checksums, "microsandbox-linux-x86_64.tar.gz").unwrap(); + assert_eq!(digest, "b".repeat(64)); + } + + #[test] + fn missing_checksums_entry_fails_closed() { + let checksums = format!("{} agentd-aarch64\n", "a".repeat(64)); + + let error = bundle_digest_from_checksums(&checksums, "microsandbox-linux-aarch64.tar.gz") + .unwrap_err(); + assert!( + error + .to_string() + .contains("microsandbox-linux-aarch64.tar.gz") + ); + } + + #[test] + fn malformed_checksums_digest_fails_closed() { + // A matching entry whose digest is not 64 hex chars must still fail + // verification rather than being accepted. + let checksums = "not-a-digest microsandbox-linux-aarch64.tar.gz\n"; + + let digest = + bundle_digest_from_checksums(checksums, "microsandbox-linux-aarch64.tar.gz").unwrap(); + let error = verify_bundle_digest(b"hello", &digest).unwrap_err(); + assert!(error.to_string().contains("invalid published SHA-256")); + } }