astrid-runtime/astrid · error

source digest must be 64 lowercase hex characters

Error message

source digest must be 64 lowercase hex characters

What it means

`SourceIdentity::from_snapshot_fields` records a migration source as present, which requires a valid blake3 digest. `SourceDigest::parse` rejected the supplied digest string, so the identity cannot be built and the error is wrapped as InvalidData. Present sources must carry a canonical 64-character lowercase hex digest for later integrity comparison.

Solutions

  1. Store the digest as 64 lowercase hex characters with no prefix (strip any `blake3:` prefix first).
  2. Recompute the digest with blake3 and lower-case hex encoding before writing the snapshot.
  3. If the source is genuinely unknown, set `present=false` (with absent digest) instead of a placeholder digest.
  4. Validate with a regex `^[0-9a-f]{64}$` before constructing the snapshot fields.

Example fix

// before
let digest = "BLAKE3:9F86D081884C7D65..."; // prefixed + uppercase
// after
let digest = &hex_str.to_lowercase();
let digest = hex_str.strip_prefix("blake3:").unwrap_or(hex_str); // 64 lowercase hex
Defensive patterns

Strategy: validation

Validate before calling

fn is_blake3_hex(s: &str) -> bool {
    s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
assert!(is_blake3_hex(digest), "digest must be 64 lowercase hex chars");
SourceIdentity::from_snapshot_fields(digest, entries, bytes, true)?;

Type guard

fn is_canonical_hex64(s: &str) -> bool {
    s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}

Try / catch

match from_snapshot_fields(digest, entries, bytes, true) {
    Err(e) if e.kind() == std::io::ErrorKind::InvalidData => eprintln!("bad digest format: {e}"),
    other => other?,
}

Prevention

When it happens

Trigger: Calling `from_snapshot_fields` with `present=true` and a digest that is not exactly 64 lowercase hex characters — e.g. uppercase hex, 32/128-char digests, base64, empty string, or a `blake3:<hex>` string passed with the prefix included.

Common situations: Tooling that hashes with a different algorithm/encoding and stuffs the result into the snapshot; copying a digest from a prefixed receipt string; truncating or double-encoding the hex during JSON round-trips.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/b13ac0c84df5b7ef. Report an issue: GitHub.

Appendix: source

Thrown at crates/astrid-kernel/src/legacy_migration_barrier/source.rs:205

            {
                Err("absent migration source has a non-zero inventory")
            },
            _ => Ok(self),
        }
    }

    pub(super) fn from_snapshot_fields(
        digest: &str,
        entries: u64,
        bytes: u64,
        present: bool,
    ) -> io::Result<Self> {
        if !present {
            return Ok(Self::absent());
        }
        Self::present(
            SourceDigest::parse(digest.to_owned())
                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
            SourceCount::new(entries),
            SourceCount::new(bytes),
        )
        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
    }
}

impl<'de> Deserialize<'de> for SourceIdentity {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(deny_unknown_fields)]
        struct Raw {
            digest: SourceDigest,
            entries: SourceCount,
            bytes: SourceCount,

View on GitHub (pinned to affd8760f4)