{"record":{"id":"359c6fd10a752e46","repo":"jdx/mise","slug":"remote-cache-client-metadata-is-not-canonical-json","errorCode":null,"errorMessage":"remote cache client metadata is not canonical JSON","messagePattern":"remote cache client metadata is not canonical JSON","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/task/task_cache_store.rs","lineNumber":406,"sourceCode":"        let action = CacheDigest {\n            algorithm: \"blake3\".into(),\n            hash: key.to_string(),\n            size: action_size,\n        };\n        let Some(result) = self.client.get_action_result(&action).await? else {\n            return Ok(None);\n        };\n        let metadata = result\n            .metadata\n            .as_ref()\n            .ok_or_else(|| eyre!(\"remote action result is missing client metadata\"))?;\n        let metadata_bytes = self\n            .client\n            .get_blob(metadata, CLIENT_METADATA_MEDIA_TYPE)\n            .await?;\n        let metadata: RemoteClientMetadata = serde_json::from_slice(&metadata_bytes)?;\n        if canonical_json(&serde_json::to_value(&metadata)?)? != metadata_bytes {\n            bail!(\"remote cache client metadata is not canonical JSON\");\n        }\n        let mut manifest = metadata.into_manifest(key)?;\n        for root in &manifest.roots {\n            validate_cache_path(root)?;\n        }\n        let artifact = match &result.output_root {\n            Some(root) => {\n                let temporary = materialize_remote_tree(self, root).await?;\n                Some(TaskCacheStoreArtifact::temporary(temporary))\n            }\n            None if manifest.roots.is_empty() => None,\n            None => bail!(\"remote action result is missing its output root\"),\n        };\n        manifest.artifact_checksum = Some(calculate_artifact_checksum(\n            &manifest,\n            artifact.as_ref().map(TaskCacheStoreArtifact::path),\n        )?);\n        let manifest = serde_json::to_vec(&manifest)?;","sourceCodeStart":388,"sourceCodeEnd":424,"githubUrl":"https://github.com/jdx/mise/blob/6f52dcdf99e282ef7a7db68c81301fa4618d0f79/src/task/task_cache_store.rs#L388-L424","documentation":"After fetching the client-metadata blob, mise re-serializes the parsed value with its canonical JSON encoder and requires the output to equal the served bytes byte-for-byte (src/task/task_cache_store.rs:405-407). Any difference — key reordering, whitespace, escape style, unicode normalization — means the blob was not stored or served verbatim, which breaks the content-addressed trust model, so the read aborts.","triggerScenarios":"get_action_result returns a metadata digest; get_blob fetches it; canonical_json(serde_json::to_value(metadata)) != metadata_bytes. Happens when a proxy, CDN, or gateway re-serializes JSON responses, when the server regenerates JSON on read instead of serving the stored blob, or when a non-mise writer uploaded pretty-printed/non-canonical JSON.","commonSituations":"Corporate MITM proxies or CDNs configured to \"optimize\" JSON; a custom remote-cache server that stores metadata as a database row and re-emits JSON; hand-uploaded debug blobs; charset/encoding transcoding in front of the cache endpoint.","solutions":["Verify the server serves blob bytes unmodified: compare the blake3 digest of what the client uploaded with what a direct GET returns.","Remove any JSON-transforming proxy/CDN rule (disable content optimization) in front of the remote cache URL, or bypass it.","Delete the non-canonical entries and let a current mise rewrite them canonically.","Until fixed, set task.cache.remote_mode (or MISE_TASK_CACHE_REMOTE_MODE) to write-only so reads never hit the rewriting layer."],"exampleFix":"# before: server re-emits JSON from a DB row (non-canonical)\nGET /blobs/<digest>  -> {\"version\": 1, \"kind\": \"task\", ...}   # reordered keys\n\n# after: server stores and returns the exact uploaded bytes\nGET /blobs/<digest>  -> exact byte stream, digest matches blake3 of upload","handlingStrategy":"fallback","validationCode":"# verify the server serves blobs byte-exact: fetch a blob twice and hash it\ncurl -s \"$REMOTE_URL/blobs/$DIGEST\" | b3sum   # must equal the digest it was stored under\ncurl -s \"$REMOTE_URL/blobs/$DIGEST\" | b3sum   # stable across reads","typeGuard":null,"tryCatchPattern":"# shell: if a rewriting proxy corrupts blobs, drop to write-only and report\nif ! mise run build; then\n  dmesg_cache_err=$(mise run build 2>&1)\n  case \"$dmesg_cache_err\" in *\"not canonical JSON\"*)\n    echo \"remote cache server rewrites JSON — disabling remote reads\";\n    MISE_TASK_CACHE_REMOTE_MODE=write-only mise run build ;;\n  esac\nfi","preventionTips":["Serve cache blobs from immutable, byte-exact storage; never regenerate JSON on read.","Disable JSON minification/optimization on any proxy, CDN, or WAF in front of the cache URL.","Smoke-test the endpoint: GET a known blob and compare its hash to the digest used in the URL.","Keep remote reads off (write-only) until byte-exactness is proven after infrastructure changes."],"tags":["task-cache","remote-cache","json","integrity","proxy"],"backgroundTag":"cache-integrity-check","analyzedSha":"6f52dcdf99e282ef7a7db68c81301fa4618d0f79","analyzedAt":"2026-08-22T10:14:23.840Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T15:18:49.778Z"}