use crate::classified::ClassifiedCid;
use crate::content_id::ContentId;
use crate::error::ContentError;
use crate::trait_def::ContentAddressable;
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MigrationKind {
Recanonicalized,
Reprofiled,
HashRotated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IdentityMigration {
from: ClassifiedCid,
to: ClassifiedCid,
reason: MigrationKind,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct IdentityMigrationWire {
from: ClassifiedCid,
to: ClassifiedCid,
reason: MigrationKind,
}
impl IdentityMigration {
pub fn new(
from: ClassifiedCid,
to: ClassifiedCid,
reason: MigrationKind,
) -> Result<Self, ContentError> {
if !to.is_mintable() {
return Err(ContentError::InvalidCidProfile {
reason: format!(
"migration target must be a mintable profile (Content or Raw), got foreign codec 0x{:x} / hash 0x{:x}",
to.codec(),
to.hash_code()
),
});
}
if from == to {
return Err(ContentError::InvalidCidProfile {
reason: format!("migration from and to are the same identity ({from}); a self-migration states nothing"),
});
}
Ok(IdentityMigration { from, to, reason })
}
#[must_use]
pub fn from(&self) -> ClassifiedCid {
self.from
}
#[must_use]
pub fn to(&self) -> ClassifiedCid {
self.to
}
#[must_use]
pub fn reason(&self) -> MigrationKind {
self.reason
}
pub fn id(&self) -> Result<ContentId, ContentError> {
self.content_id()
}
}
impl<'de> Deserialize<'de> for IdentityMigration {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let wire = IdentityMigrationWire::deserialize(deserializer)?;
IdentityMigration::new(wire.from, wire.to, wire.reason)
.map_err(<D::Error as serde::de::Error>::custom)
}
}
impl ContentAddressable for IdentityMigration {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
crate::canonical::to_canonical_dagcbor(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classified::ForeignCid;
use crate::raw_id::RawContentId;
use ipld_core::cid::multihash::Multihash;
use ipld_core::cid::Cid;
fn sha_foreign(d: [u8; 32]) -> ClassifiedCid {
ClassifiedCid::from_cid(Cid::new_v1(0x55, Multihash::wrap(0x12, &d).unwrap()))
}
#[test]
fn foreign_to_raw_is_a_hash_rotation_and_has_a_stable_id() {
let content = b"the artifact";
let old = sha_foreign([1u8; 32]); let new = ClassifiedCid::from(RawContentId::from_content(content));
let m = IdentityMigration::new(old, new, MigrationKind::HashRotated).unwrap();
assert_eq!(m.from(), old);
assert_eq!(m.to(), new);
assert_eq!(m.reason(), MigrationKind::HashRotated);
let id1 = m.id().unwrap();
let bytes = m.canonical_form().unwrap();
let back: IdentityMigration =
crate::canonical::from_canonical_dagcbor_checked(&bytes).unwrap();
assert_eq!(back, m);
assert_eq!(back.id().unwrap(), id1);
assert!(m.verify(&id1).unwrap());
}
#[test]
fn reprofile_records_a_real_identity_change() {
let d = *blake3::hash(b"x").as_bytes();
let from = ClassifiedCid::from(ContentId::from_dag_cbor_digest(d)); let to = ClassifiedCid::from(RawContentId::from_blake3_digest(d)); assert_ne!(from, to, "law 6: same digest, different identity");
let m = IdentityMigration::new(from, to, MigrationKind::Reprofiled).unwrap();
let m2 = IdentityMigration::new(from, to, MigrationKind::Recanonicalized).unwrap();
assert_ne!(m.id().unwrap(), m2.id().unwrap());
}
#[test]
fn foreign_target_is_refused() {
let from = ClassifiedCid::from(RawContentId::from_content(b"a"));
let err = IdentityMigration::new(from, sha_foreign([2u8; 32]), MigrationKind::HashRotated);
assert!(matches!(err, Err(ContentError::InvalidCidProfile { .. })));
}
#[test]
fn self_migration_is_refused() {
let same = ClassifiedCid::from(RawContentId::from_content(b"same"));
for kind in [
MigrationKind::Recanonicalized,
MigrationKind::Reprofiled,
MigrationKind::HashRotated,
] {
assert!(matches!(
IdentityMigration::new(same, same, kind),
Err(ContentError::InvalidCidProfile { .. })
));
}
}
#[test]
fn deserialization_enforces_every_invariant() {
let raw = RawContentId::from_content(b"target");
let foreign = ForeignCid::new(Cid::new_v1(
0x55,
Multihash::wrap(0x12, &[5u8; 32]).unwrap(),
))
.unwrap();
let bad_to = serde_json::json!({
"from": ClassifiedCid::from(raw).to_string(),
"to": foreign.to_string(),
"reason": "hash_rotated",
});
let err = serde_json::from_value::<IdentityMigration>(bad_to.clone()).unwrap_err();
assert!(err.to_string().contains("mintable"), "{err}");
let self_mig = serde_json::json!({
"from": ClassifiedCid::from(raw).to_string(),
"to": ClassifiedCid::from(raw).to_string(),
"reason": "reprofiled",
});
let err = serde_json::from_value::<IdentityMigration>(self_mig).unwrap_err();
assert!(err.to_string().contains("same identity"), "{err}");
let extra = serde_json::json!({
"from": ClassifiedCid::from(foreign).to_string(),
"to": ClassifiedCid::from(raw).to_string(),
"reason": "hash_rotated",
"signature": "not part of this record",
});
assert!(serde_json::from_value::<IdentityMigration>(extra).is_err());
#[derive(Serialize)]
struct Craft {
from: ClassifiedCid,
to: ClassifiedCid,
reason: MigrationKind,
}
let crafted = crate::canonical::to_canonical_dagcbor(&Craft {
from: ClassifiedCid::from(raw),
to: ClassifiedCid::from(foreign),
reason: MigrationKind::HashRotated,
})
.unwrap();
assert!(
crate::canonical::from_canonical_dagcbor_checked::<IdentityMigration>(&crafted)
.is_err()
);
let crafted_self = crate::canonical::to_canonical_dagcbor(&Craft {
from: ClassifiedCid::from(raw),
to: ClassifiedCid::from(raw),
reason: MigrationKind::Reprofiled,
})
.unwrap();
assert!(
crate::canonical::from_canonical_dagcbor_checked::<IdentityMigration>(&crafted_self)
.is_err()
);
let good = IdentityMigration::new(
ClassifiedCid::from(foreign),
ClassifiedCid::from(raw),
MigrationKind::HashRotated,
)
.unwrap();
let bytes = good.canonical_form().unwrap();
let back: IdentityMigration =
crate::canonical::from_canonical_dagcbor_checked(&bytes).unwrap();
assert_eq!(back, good);
assert_eq!(back.id().unwrap(), good.id().unwrap());
}
#[test]
fn links_are_tag42_on_the_wire() {
let m = IdentityMigration::new(
ClassifiedCid::from(RawContentId::from_content(b"a")),
ClassifiedCid::from(RawContentId::from_content(b"b")),
MigrationKind::Recanonicalized,
)
.unwrap();
let bytes = m.canonical_form().unwrap();
let tag42 = bytes.windows(2).filter(|w| w == &[0xd8, 0x2a]).count();
assert_eq!(tag42, 2);
assert!(bytes.windows(15).any(|w| w == b"recanonicalized"));
}
}