diff --git a/contracts/cultmesh/content-vectors.cs-written.json b/contracts/cultmesh/content-vectors.cs-written.json new file mode 100644 index 00000000..0eaaaece --- /dev/null +++ b/contracts/cultmesh/content-vectors.cs-written.json @@ -0,0 +1,189 @@ +{ + "vectors": [ + { + "label": "answer_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrHZlY3Rvci1mb3VuZKljaHVua0hhc2jZR1NIQTI1Njo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtledlPbWVzaDpjZG46Y2h1bms6NTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y7FleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_found.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKx2ZWN0b3ItZm91bmSlZm91bmTDqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXPNEt\u002BncGF5bG9hZMUS3/n6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5OjulZXJyb3Kg" + }, + { + "label": "answer_not_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrnZlY3Rvci1taXNzaW5nqWNodW5rSGFzaNlAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_not_found.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK52ZWN0b3ItbWlzc2luZ6Vmb3VuZMKpY2h1bmtIYXNo2UAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2TZGaWxlTm90Rm91bmRFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgaXMgbm90IGF2YWlsYWJsZS4=" + }, + { + "label": "answer_record_key_disagrees.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkqnZlY3Rvci1rZXmpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtlebRtZXNoOmNkbjpjaHVuazpvdGhlcrFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_record_key_disagrees.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKp2ZWN0b3Ita2V5pWZvdW5kwqljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpc2l6ZUJ5dGVzAKdwYXlsb2FkxAClZXJyb3LZT0ludmFsaWREYXRhRXhjZXB0aW9uOiBDb250ZW50IGNodW5rIHJlY29yZCBrZXkgZGlzYWdyZWVzIHdpdGggaXRzIGNvbnRlbnQgaGFzaC4=" + }, + { + "label": "answer_size_mismatch.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkq3ZlY3Rvci1zaXplqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lyZWNvcmRLZXnZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOxZXhwZWN0ZWRTaXplQnl0ZXPNEt4=" + }, + { + "label": "answer_size_mismatch.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKt2ZWN0b3Itc2l6ZaVmb3VuZMKpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2WBJbnZhbGlkRGF0YUV4Y2VwdGlvbjogQ0ROIGFydGlmYWN0IGNodW5rIHBheWxvYWQgbWV0YWRhdGEgZG9lcyBub3QgbWF0Y2ggaXRzIG1hbmlmZXN0IHJlZmVyZW5jZS4=" + }, + { + "label": "answer_blank_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_blank_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_whitespace_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoyAgIKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_whitespace_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKMgICClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_blank_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1ibGFuay1oYXNoqWNodW5rSGFzaKCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_blank_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3ItYmxhbmstaGFzaKVmb3VuZMKpY2h1bmtIYXNooKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_whitespace_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1zcGFjZS1oYXNoqWNodW5rSGFzaKMgICCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_whitespace_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3Itc3BhY2UtaGFzaKVmb3VuZMKpY2h1bmtIYXNooyAgIKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_prefix_only_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksnZlY3Rvci1wcmVmaXgtaGFzaKljaHVua0hhc2iqc2hhMjU2OiAgIKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_prefix_only_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLJ2ZWN0b3ItcHJlZml4LWhhc2ilZm91bmTCqWNodW5rSGFzaKpzaGEyNTY6ICAgqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2UJBcmd1bWVudEV4Y2VwdGlvbjogSGFzaCBtdXN0IGJlIG5vbi1lbXB0eS4gKFBhcmFtZXRlciAnY2h1bmtIYXNoJyk=" + }, + { + "label": "answer_negative_size.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1uZWdhdGl2ZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVz/w==" + }, + { + "label": "answer_negative_size.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3ItbmVnYXRpdmWlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlgSW52YWxpZERhdGFFeGNlcHRpb246IENETiBhcnRpZmFjdCBjaHVuayBwYXlsb2FkIG1ldGFkYXRhIGRvZXMgbm90IG1hdGNoIGl0cyBtYW5pZmVzdCByZWZlcmVuY2Uu" + }, + { + "label": "answer_record_key_case.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1rZXktY2FzZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9NRVNIOkNETjpDSFVOSzo1OTkzQUM2MDQwN0U4NjYwMTg3M0JDMzhFRTZGOERCM0VBOTEyRTk4NzYxQ0I1NThBQjk3OTVEQjMwNkI0NzRDsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_record_key_case.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3Ita2V5LWNhc2WlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlPSW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVjb3JkIGtleSBkaXNhZ3JlZXMgd2l0aCBpdHMgY29udGVudCBoYXNoLg==" + }, + { + "label": "answer_blank_record_key.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksHZlY3Rvci1rZXktYmxhbmupY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtleaMgICCxZXhwZWN0ZWRTaXplQnl0ZXPNEt8=" + }, + { + "label": "answer_blank_record_key.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLB2ZWN0b3Ita2V5LWJsYW5rpWZvdW5kw6ljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpc2l6ZUJ5dGVzzRLfp3BheWxvYWTFEt/5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7pWVycm9yoA==" + }, + { + "label": "answer_double_prefix.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrXZlY3Rvci1kb3VibGWpY2h1bmtIYXNo2U5zaGEyNTY6c2hhMjU2OjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_double_prefix.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK12ZWN0b3ItZG91YmxlpWZvdW5kw6ljaHVua0hhc2jZR3NoYTI1Njo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlc80S36dwYXlsb2FkxRLf\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6O6VlcnJvcqA=" + }, + { + "label": "nil_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkwKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "nil_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "nil_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1uaWwtaGFzaKljaHVua0hhc2jAqXJlY29yZEtleaCxZXhwZWN0ZWRTaXplQnl0ZXNk" + }, + { + "label": "nil_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3ItbmlsLWhhc2ilZm91bmTCqWNodW5rSGFzaKCpc2l6ZUJ5dGVzAKdwYXlsb2FkxAClZXJyb3LZQkFyZ3VtZW50RXhjZXB0aW9uOiBIYXNoIG11c3QgYmUgbm9uLWVtcHR5LiAoUGFyYW1ldGVyICdDaHVua0hhc2gnKQ==" + }, + { + "label": "nil_record_key.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrnZlY3Rvci1uaWwta2V5qWNodW5rSGFzaNlAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKlyZWNvcmRLZXnAsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "nil_record_key.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK52ZWN0b3ItbmlsLWtleaVmb3VuZMKpY2h1bmtIYXNo2UAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2TZGaWxlTm90Rm91bmRFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgaXMgbm90IGF2YWlsYWJsZS4=" + }, + { + "label": "response_hash_last_digit", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKx2ZWN0b3ItZGlnaXSlZm91bmTDqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0MKlzaXplQnl0ZXPNEt\u002BncGF5bG9hZMUS3/n6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1\u002Bf4CBgoOEhYaHiImKi4yNjo\u002BQkZKTlJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvMzc7P0NHS09TV1tfY2drb3N3e3\u002BDh4uPk5ebn6Onq6\u002Bzt7u/w8fLz9PX29/j5\u002BgABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2BhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ent8fX5/gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp\u002BgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2\u002Bv8DBwsPExcbHyMnKy8zNzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4\u002BTl5ufo6err7O3u7/Dx8vP09fb3\u002BPn6AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0\u002BP0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\u002BAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq\u002BwsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5\u002Bjp6uvs7e7v8PHy8/T19vf4\u002BfoAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5OjulZXJyb3Kg" + }, + { + "label": "manifest", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA==" + }, + { + "label": "manifest_out_of_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" + }, + { + "label": "manifest_metadata_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" + }, + { + "label": "manifest_empty_body", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA" + } + ] +} diff --git a/contracts/cultmesh/content-vectors.rs-written.json b/contracts/cultmesh/content-vectors.rs-written.json new file mode 100644 index 00000000..38538e2b --- /dev/null +++ b/contracts/cultmesh/content-vectors.rs-written.json @@ -0,0 +1,159 @@ +{ + "vectors": [ + { + "label": "answer_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrHZlY3Rvci1mb3VuZKljaHVua0hhc2jZR1NIQTI1Njo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtledlPbWVzaDpjZG46Y2h1bms6NTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y7FleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_found.response", + "kind": "response", + "messagePackBase64": "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" + }, + { + "label": "answer_not_found.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrnZlY3Rvci1taXNzaW5nqWNodW5rSGFzaNlAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_not_found.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK52ZWN0b3ItbWlzc2luZ6Vmb3VuZMKpY2h1bmtIYXNo2UAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2TZGaWxlTm90Rm91bmRFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgaXMgbm90IGF2YWlsYWJsZS4=" + }, + { + "label": "answer_record_key_disagrees.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkqnZlY3Rvci1rZXmpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtlebRtZXNoOmNkbjpjaHVuazpvdGhlcrFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_record_key_disagrees.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKp2ZWN0b3Ita2V5pWZvdW5kwqljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpc2l6ZUJ5dGVzAKdwYXlsb2FkxAClZXJyb3LZT0ludmFsaWREYXRhRXhjZXB0aW9uOiBDb250ZW50IGNodW5rIHJlY29yZCBrZXkgZGlzYWdyZWVzIHdpdGggaXRzIGNvbnRlbnQgaGFzaC4=" + }, + { + "label": "answer_size_mismatch.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkq3ZlY3Rvci1zaXplqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lyZWNvcmRLZXnZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOxZXhwZWN0ZWRTaXplQnl0ZXPNEt4=" + }, + { + "label": "answer_size_mismatch.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKt2ZWN0b3Itc2l6ZaVmb3VuZMKpY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2WBJbnZhbGlkRGF0YUV4Y2VwdGlvbjogQ0ROIGFydGlmYWN0IGNodW5rIHBheWxvYWQgbWV0YWRhdGEgZG9lcyBub3QgbWF0Y2ggaXRzIG1hbmlmZXN0IHJlZmVyZW5jZS4=" + }, + { + "label": "answer_blank_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_blank_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_whitespace_message_id.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkoyAgIKljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_whitespace_message_id.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZKMgICClZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlISW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVxdWVzdCByZXF1aXJlcyBhIG1lc3NhZ2UgaWRlbnRpdHku" + }, + { + "label": "answer_blank_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1ibGFuay1oYXNoqWNodW5rSGFzaKCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_blank_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3ItYmxhbmstaGFzaKVmb3VuZMKpY2h1bmtIYXNooKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_whitespace_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksXZlY3Rvci1zcGFjZS1oYXNoqWNodW5rSGFzaKMgICCpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc2Q=" + }, + { + "label": "answer_whitespace_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLF2ZWN0b3Itc3BhY2UtaGFzaKVmb3VuZMKpY2h1bmtIYXNooyAgIKlzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlCQXJndW1lbnRFeGNlcHRpb246IEhhc2ggbXVzdCBiZSBub24tZW1wdHkuIChQYXJhbWV0ZXIgJ0NodW5rSGFzaCcp" + }, + { + "label": "answer_prefix_only_hash.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksnZlY3Rvci1wcmVmaXgtaGFzaKljaHVua0hhc2iqc2hhMjU2OiAgIKlyZWNvcmRLZXmgsWV4cGVjdGVkU2l6ZUJ5dGVzZA==" + }, + { + "label": "answer_prefix_only_hash.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZLJ2ZWN0b3ItcHJlZml4LWhhc2ilZm91bmTCqWNodW5rSGFzaKpzaGEyNTY6ICAgqXNpemVCeXRlcwCncGF5bG9hZMQApWVycm9y2UJBcmd1bWVudEV4Y2VwdGlvbjogSGFzaCBtdXN0IGJlIG5vbi1lbXB0eS4gKFBhcmFtZXRlciAnY2h1bmtIYXNoJyk=" + }, + { + "label": "answer_negative_size.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1uZWdhdGl2ZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjsWV4cGVjdGVkU2l6ZUJ5dGVz/w==" + }, + { + "label": "answer_negative_size.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3ItbmVnYXRpdmWlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlgSW52YWxpZERhdGFFeGNlcHRpb246IENETiBhcnRpZmFjdCBjaHVuayBwYXlsb2FkIG1ldGFkYXRhIGRvZXMgbm90IG1hdGNoIGl0cyBtYW5pZmVzdCByZWZlcmVuY2Uu" + }, + { + "label": "answer_record_key_case.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkr3ZlY3Rvci1rZXktY2FzZaljaHVua0hhc2jZQDU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V52U9NRVNIOkNETjpDSFVOSzo1OTkzQUM2MDQwN0U4NjYwMTg3M0JDMzhFRTZGOERCM0VBOTEyRTk4NzYxQ0I1NThBQjk3OTVEQjMwNkI0NzRDsWV4cGVjdGVkU2l6ZUJ5dGVzzRLf" + }, + { + "label": "answer_record_key_case.response", + "kind": "response", + "messagePackBase64": "h61zY2hlbWFWZXJzaW9u2SJjdWx0bWVzaC5jb250ZW50X2NodW5rX3Jlc3BvbnNlLnYxqW1lc3NhZ2VJZK92ZWN0b3Ita2V5LWNhc2WlZm91bmTCqWNodW5rSGFzaNlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y6lzaXplQnl0ZXMAp3BheWxvYWTEAKVlcnJvctlPSW52YWxpZERhdGFFeGNlcHRpb246IENvbnRlbnQgY2h1bmsgcmVjb3JkIGtleSBkaXNhZ3JlZXMgd2l0aCBpdHMgY29udGVudCBoYXNoLg==" + }, + { + "label": "answer_blank_record_key.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlksHZlY3Rvci1rZXktYmxhbmupY2h1bmtIYXNo2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjqXJlY29yZEtleaMgICCxZXhwZWN0ZWRTaXplQnl0ZXPNEt8=" + }, + { + "label": "answer_blank_record_key.response", + "kind": "response", + "messagePackBase64": "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" + }, + { + "label": "answer_double_prefix.request", + "kind": "request", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9u2SFjdWx0bWVzaC5jb250ZW50X2NodW5rX3JlcXVlc3QudjGpbWVzc2FnZUlkrXZlY3Rvci1kb3VibGWpY2h1bmtIYXNo2U5zaGEyNTY6c2hhMjU2OjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOpcmVjb3JkS2V5oLFleHBlY3RlZFNpemVCeXRlc80S3w==" + }, + { + "label": "answer_double_prefix.response", + "kind": "response", + "messagePackBase64": "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" + }, + { + "label": "response_hash_last_digit", + "kind": "response", + "messagePackBase64": "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" + }, + { + "label": "manifest", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA==" + }, + { + "label": "manifest_out_of_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" + }, + { + "label": "manifest_metadata_order", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" + }, + { + "label": "manifest_empty_body", + "kind": "manifest", + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA" + } + ] +} diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 97fa1b48..2c6f2211 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -78,6 +78,23 @@ to speak CultNet or participate in CultMesh. `tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs` carry the semantic ones. A single rows-to-bytes path driving both halves in one call remains its own cut (R-AR) and is not part of this claim. +- CultMesh content plane (`cultmesh.content_chunk_request.v1` and `.response.v1` + plus the CDN manifest, C#/Rust only): byte parity in both directions (manifest metadata keeps its wire key order and a + duplicate key is refused, as the reference refuses it), and the reference's answer + to a chunk request, including its failure spellings: every bad request is + answered `found: false`, never refused at decode. + `packages/cultnet-rs/tests/content.rs` and + `tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs` judge + `contracts/cultmesh/content-vectors.cs-written.json` and + `content-vectors.rs-written.json`. Rust carries these chunks only on the + session the client already holds (see `src/GameCult.Mesh/docs/transport-planes.md`); + it has no authenticated content connector, so it claims no TCP+TLS or QUIC + content path. + Recorded limits: a caller that passes `max_bytes = u64::MAX` to `fetch_content` gets an unbounded + body, because the reference refuses artifacts over `int.MaxValue` bytes and Rust bounds them only by the + caller's cap. + `normalize_hash` matches the `sha256:` prefix ASCII-case-insensitively while C# `OrdinalIgnoreCase` may + also match non-ASCII forms (for example `Å¿ha256:`); unprobed, and it affects hostile input only. ## Expansion Work Outside The Current Parity Claim diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs new file mode 100644 index 00000000..287b0f34 --- /dev/null +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -0,0 +1,445 @@ +//! Generate the Idunn runtime fixtures that cultnet-ts is tested against. +//! +//! The Rust owner builds every input: real enrolled identities, an Expected that +//! passes `validate`, an Activation issued by `IdunnRuntimeActivationLaunch`, the +//! bundle files written through `SingleFileMessagePackBackingStore`, and +//! runtime-presence vectors signed by the Rust signers. Each vector is also run +//! through `authenticate_runtime_presence_claim` and +//! `correlate_runtime_presence_claim` before it is written, so the bytes +//! committed to the TypeScript tests are ones this verifier accepted (or, for +//! the low-capacity vector, ones it correlates to the typed capacity +//! disagreement). Ed25519 is deterministic, so the TypeScript signer must +//! reproduce each vector byte for byte from the same fields. +//! +//! A `stateful` set is the lease-bound shape: Expected carries a state +//! lineage and requires a write lease, and Rust issues `lease.cc` naming the +//! warming vector's digest. Its warming vector is sequence 1; the active and +//! degraded vectors are sequence 2 and bind to that lease. Two further leases are +//! deliberately wrong (`lease-other-warming.cc` names a different warming digest, +//! `lease-other-target.cc` names another target) for refusal tests. +//! +//! Linux only: the provider identity is bound to `/etc/machine-id`, and the +//! generated `machine-id` file records the value the identities were bound to. +//! +//! Usage: idunn_runtime_fixture ... +//! +//! The specification is command-line flags. `@` splices in a response +//! file: one flag per line, `--flag value` split at the first whitespace, blank +//! lines and `#` lines ignored. CultLib's own sets are +//! `packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args`. +//! +//! Global flags: +//! --source-repository Expected.source_repository (default github.com/GameCult/Example) +//! --detail presence detail (default route-observation:route-challenge-1) +//! +//! `--set ` opens a fixture set written to `/`; the flags +//! below apply to the set most recently opened: +//! --target required +//! --transport --stable --candidate required: the route +//! --capability default `.api` +//! --schema default `.v1` +//! --health-contract default `.runtime-health` +//! --minimum-capacity default 1; above 1 also writes presence-active-below-minimum.bin +//! --stateful state lineage, write lease and lease vectors +//! --state-schema-generation stateful only; default state-v1 +//! --lease-other-target stateful only; default the first other set's target +//! --dependency k=v,k=v,... repeatable; keys kind, capability, schema, provider, +//! endpoint (required), compatibility (v1), capacity (1), +//! startup (before-promotion), authority (managed-incarnation), +//! projection (one hex digit the digest is filled with, 5) + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use cultcache_rs::{ + CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore, +}; +use cultnet_rs::{ + GameCultProviderHealthIdentity, GameCultRuntimeCapability, + GameCultRuntimePresenceHealthPurpose, GameCultRuntimePresenceHealthRecord, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedCapability, IdunnExpectedDependency, + IdunnExpectedIncarnationRecord, IdunnExpectedRoute, IdunnProcessWriteLeaseRecord, + IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, + IdunnRuntimeActivationSigner, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, + authenticate_runtime_presence_claim, correlate_runtime_presence_claim, + enroll_service_identity_at, verify_runtime_authority, +}; + +/// The TypeScript tests pin the same values; drift shows up as a byte mismatch. +const OBSERVED_AT: u64 = 1_790_000_000_000; +const ISSUED_AT: u64 = OBSERVED_AT - 5_000; +const SEQUENCE: u64 = 1; +const STORED_AT: &str = "2026-01-01T00:00:00.000Z"; + +struct Spec { + source_repository: String, + detail: String, + sets: Vec, +} + +#[derive(Default)] +struct Set { + dir: String, + target: String, + transport: String, + stable: String, + candidate: String, + capability: Option, + schema: Option, + health_contract: Option, + minimum_capacity: u32, + stateful: bool, + state_schema_generation: Option, + lease_other_target: Option, + dependencies: Vec, +} + +fn expand(args: impl Iterator) -> Result> { + let mut flat = Vec::new(); + for arg in args { + let Some(file) = arg.strip_prefix('@') else { + flat.push(arg); + continue; + }; + for line in fs::read_to_string(file).with_context(|| format!("reading {file}"))?.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + match line.split_once(char::is_whitespace) { + Some((flag, value)) => flat.extend([flag.to_string(), value.trim().to_string()]), + None => flat.push(line.to_string()), + } + } + } + Ok(flat) +} + +fn dependency(text: &str) -> Result { + let mut fields = BTreeMap::new(); + for pair in text.split(',') { + let (key, value) = pair + .split_once('=') + .with_context(|| format!("dependency field `{pair}` is not key=value"))?; + ensure!(fields.insert(key, value).is_none(), "dependency key `{key}` repeated"); + } + let mut take = |key: &str, default: Option<&str>| -> Result { + match (fields.remove(key), default) { + (Some(value), _) => Ok(value.to_string()), + (None, Some(default)) => Ok(default.to_string()), + (None, None) => bail!("dependency needs `{key}`"), + } + }; + let dependency = IdunnExpectedDependency { + kind: take("kind", None)?, + capability: take("capability", None)?, + schema: take("schema", None)?, + compatibility: take("compatibility", Some("v1"))?, + minimum_capacity: take("capacity", Some("1"))?.parse()?, + startup: take("startup", Some("before-promotion"))?, + provider_id: Some(take("provider", None)?), + provider_authority: Some(take("authority", Some("managed-incarnation"))?), + provider_expected_projection_sha256: Some(digest( + take("projection", Some("5"))?.chars().next().context("empty projection")?, + )), + provider_endpoint: Some(take("endpoint", None)?), + }; + ensure!(fields.is_empty(), "unknown dependency keys {:?}", fields.keys().collect::>()); + Ok(dependency) +} + +fn parse(args: impl Iterator) -> Result { + let mut spec = Spec { + source_repository: "github.com/GameCult/Example".into(), + detail: "route-observation:route-challenge-1".into(), + sets: Vec::new(), + }; + let mut args = expand(args)?.into_iter(); + while let Some(flag) = args.next() { + let mut value = || args.next().with_context(|| format!("{flag} needs a value")); + match flag.as_str() { + "--source-repository" => spec.source_repository = value()?, + "--detail" => spec.detail = value()?, + "--set" => spec.sets.push(Set { dir: value()?, minimum_capacity: 1, ..Set::default() }), + _ => { + let set = spec.sets.last_mut().with_context(|| format!("{flag} before any --set"))?; + match flag.as_str() { + "--target" => set.target = value()?, + "--transport" => set.transport = value()?, + "--stable" => set.stable = value()?, + "--candidate" => set.candidate = value()?, + "--capability" => set.capability = Some(value()?), + "--schema" => set.schema = Some(value()?), + "--health-contract" => set.health_contract = Some(value()?), + "--minimum-capacity" => set.minimum_capacity = value()?.parse()?, + "--stateful" => set.stateful = true, + "--state-schema-generation" => set.state_schema_generation = Some(value()?), + "--lease-other-target" => set.lease_other_target = Some(value()?), + "--dependency" => set.dependencies.push(dependency(&value()?)?), + other => bail!("unknown flag {other}"), + } + } + } + } + ensure!(!spec.sets.is_empty(), "no --set given"); + for set in &spec.sets { + for (name, field) in [ + ("target", &set.target), + ("transport", &set.transport), + ("stable", &set.stable), + ("candidate", &set.candidate), + ] { + ensure!(!field.is_empty(), "set {} needs --{name}", set.dir); + } + } + Ok(spec) +} + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let Some(out) = args.next() else { + bail!("usage: idunn_runtime_fixture ... (see the file header)"); + }; + let spec = parse(args)?; + let machine_id = fs::read_to_string("/etc/machine-id")?; + fs::create_dir_all(&out)?; + fs::write(Path::new(&out).join("machine-id"), machine_id.trim())?; + let idunn = enroll_service_identity_at::( + &Path::new(&out).join("idunn-identity.private"), + )?; + let idunn_anchor = idunn.trust_anchor()?; + fs::remove_file(Path::new(&out).join("idunn-identity.private"))?; + let _ = fs::remove_file(Path::new(&out).join("idunn-identity.private.lock")); + for set in &spec.sets { + build(Path::new(&out).join(&set.dir).as_path(), &spec, set, &idunn, &idunn_anchor)?; + } + Ok(()) +} + +fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) +} + +fn build( + dir: &Path, + spec: &Spec, + set: &Set, + idunn: &cultnet_rs::ServiceIdentitySigner, + idunn_anchor: &cultnet_rs::ServiceIdentityTrustAnchor, +) -> Result<()> { + fs::create_dir_all(dir)?; + let provider_path = dir.join("provider-identity.credential"); + let provider = enroll_service_identity_at::(&provider_path)?; + let provider_anchor = provider.trust_anchor()?; + let _ = fs::remove_file(dir.join("provider-identity.credential.lock")); + + let capability = set.capability.clone().unwrap_or_else(|| format!("{}.api", set.target)); + let capability_schema = set.schema.clone().unwrap_or_else(|| format!("{capability}.v1")); + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: set.target.clone(), + plan_id: digest('1'), + incarnation_id: format!("{}/incarnation-1", set.target), + sealed_release_id: digest('2'), + source_repository: spec.source_repository.clone(), + source_revision: "a".repeat(40), + recipe_sha256: digest('3'), + runtime_id: format!("{}-yggdrasil", set.target), + expected_signer_identity_id: provider_anchor.identity_id.clone(), + health_contract: set.health_contract.clone().unwrap_or_else(|| format!("{}.runtime-health", set.target)), + artifact_sha256: digest('4'), + state_schema_generation: set.stateful.then(|| set.state_schema_generation.clone().unwrap_or_else(|| "state-v1".into())), + state_contract_sha256: set.stateful.then(|| digest('7')), + write_lease_required: set.stateful, + route: Some(IdunnExpectedRoute { + route_id: format!("{}-route", set.target), + transport: set.transport.clone(), + stable_endpoint: set.stable.clone(), + candidate_endpoint: set.candidate.clone(), + }), + capabilities: vec![IdunnExpectedCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + minimum_capacity: set.minimum_capacity, + }], + dependencies: set.dependencies.clone(), + }; + expected.validate()?; + + let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('6'), ISSUED_AT, idunn)?; + let mut credential = Vec::new(); + let activation = launch.write_credential(&mut credential)?; + ensure!(credential.len() == 32, "activation credential is a 32-byte seed"); + fs::write(dir.join("activation-key.seed"), &credential)?; + let activation_signer = IdunnRuntimeActivationSigner::from_credential_reader(&credential[..])?; + + write_record( + &dir.join("expected.cc"), + &expected.target, + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + expected.canonical_bytes()?, + )?; + write_record( + &dir.join("activation.cc"), + &expected.target, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + activation.canonical_bytes()?, + )?; + + let authority = + verify_runtime_authority(&expected, &activation, idunn_anchor, &provider_anchor.public_key)?; + let sign_vector = |name: &str, + state: &str, + capacity: u32, + sequence: u64, + write_lease_sha256: Option, + disagreement: Option<&str>| + -> Result { + let mut presence = GameCultRuntimePresenceHealthRecord { + schema_version: cultnet_rs::GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: expected.target.clone(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation.canonical_sha256()?, + state_schema_generation: expected.state_schema_generation.clone(), + state_contract_sha256: expected.state_contract_sha256.clone(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + bound_endpoint: Some(set.candidate.clone()), + capabilities: vec![GameCultRuntimeCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + capacity, + }], + health_contract: expected.health_contract.clone(), + state: state.into(), + detail: spec.detail.clone(), + write_lease_sha256, + signer_identity_id: provider_anchor.identity_id.clone(), + publisher_sequence: sequence, + observed_at_unix_millis: OBSERVED_AT, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + activation_signer_identity_id: activation_signer.identity_id(), + activation_signature: Vec::new(), + }; + let proof = presence.canonical_proof_payload()?; + presence.signature = provider + .sign::(&proof) + .signature; + presence.activation_signature = activation_signer.sign_presence_proof(&presence)?; + let bytes = rmp_serde::to_vec(&presence)?; + + let claim = authenticate_runtime_presence_claim( + &bytes, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: OBSERVED_AT + 1_000, + maximum_age_millis: 30_000, + maximum_future_skew_millis: 0, + }, + )?; + let presence_sha256 = claim.signed_presence_sha256().to_string(); + let correlation = correlate_runtime_presence_claim(claim, &authority)?; + let codes: Vec<&str> = correlation + .disagreements() + .iter() + .map(|entry| entry.code.as_str()) + .collect(); + match disagreement { + None => ensure!(codes.is_empty(), "{name}: unexpected disagreements {codes:?}"), + Some(code) => ensure!(codes == [code], "{name}: expected only {code}, got {codes:?}"), + } + fs::write(dir.join(name), bytes)?; + Ok(presence_sha256) + }; + + if !set.stateful { + sign_vector("presence-active.bin", "active", set.minimum_capacity, SEQUENCE, None, None)?; + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + if set.minimum_capacity > 1 { + sign_vector( + "presence-active-below-minimum.bin", + "active", + set.minimum_capacity - 1, + SEQUENCE, + None, + Some("expected-capability-000-capacity"), + )?; + } + return Ok(()); + } + + // Lease-bound set. The warming vector comes first because the lease names it. + let warming_sha256 = + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + let activation_sha256 = activation.canonical_sha256()?; + let lease_for = |target: &str, warming_presence_sha256: &str| IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: target.into(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation_sha256.clone(), + state_schema_generation: expected.state_schema_generation.clone().unwrap_or_default(), + state_contract_sha256: expected.state_contract_sha256.clone().unwrap_or_default(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_presence_sha256.into(), + lease_epoch: 1, + issued_at_unix_millis: OBSERVED_AT - 1_000, + }; + let other_target_name = set.lease_other_target.clone().unwrap_or_else(|| { + spec.sets.iter().map(|other| other.target.clone()).find(|target| *target != set.target).unwrap_or_else(|| format!("{}-other", set.target)) + }); + let lease = lease_for(&set.target, &warming_sha256); + let other_warming = lease_for(&set.target, &digest('d')); + let other_target = lease_for(&other_target_name, &warming_sha256); + for (name, record) in [ + ("lease.cc", &lease), + ("lease-other-warming.cc", &other_warming), + ("lease-other-target.cc", &other_target), + ] { + let bytes = record.canonical_bytes()?; + ensure!( + IdunnProcessWriteLeaseRecord::decode_canonical(&bytes)? == *record, + "{name}: lease does not round-trip" + ); + write_record( + &dir.join(name), + &record.target, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + bytes, + )?; + } + let lease_sha256 = lease.canonical_sha256()?; + for (name, state) in [("presence-active.bin", "active"), ("presence-degraded.bin", "degraded")] { + sign_vector(name, state, set.minimum_capacity, SEQUENCE + 1, Some(lease_sha256.clone()), None)?; + } + Ok(()) +} + +fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec) -> Result<()> { + SingleFileMessagePackBackingStore::new(path).push(&CultCacheEnvelope { + key: key.into(), + r#type: kind.into(), + payload, + stored_at: STORED_AT.into(), + schema_id: Some(schema.into()), + })?; + let mut lock = path.as_os_str().to_owned(); + lock.push(".lock"); + let _ = fs::remove_file(lock); + Ok(()) +} diff --git a/packages/cultnet-rs/src/content.rs b/packages/cultnet-rs/src/content.rs new file mode 100644 index 00000000..843d4b86 --- /dev/null +++ b/packages/cultnet-rs/src/content.rs @@ -0,0 +1,426 @@ +//! CultMesh content plane: chunking, hashing, manifest shape and validation, answering one chunk +//! request, and verifying a fetched body. Mirrors `CultMeshCdn` (`CultMeshCdn.cs`) and +//! `CultMeshLegacyRudpContentServer` (`CultMeshContentSessions.cs`); the two chunk messages are +//! `CultNetMessage::ContentChunkRequest`/`ContentChunkResponse` in `contracts.rs`. +//! +//! This is the only owner of these rules in Rust. A consumer hands `fetch_content` a transport +//! closure and gets verified bytes; it never hashes, chunks or validates a manifest itself. + +use anyhow::{Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::CultNetMessage; + +/// One chunk of a packed body. `chunk_hash` is lowercase SHA-256 hex of `payload`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultMeshCdnChunk { + pub chunk_hash: String, + pub payload: Vec, +} + +/// `CultMeshCdnChunkRef`. Encodes as the reference's positional array under every encoder. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "ChunkRefWire", into = "ChunkRefWire")] +pub struct CultMeshCdnChunkRef { + pub chunk_hash: String, + pub offset: i64, + pub size_bytes: i32, + pub record_key: String, +} + +/// `CultMeshCdnArtifactManifest`. Encodes as the reference's positional array (keys 0-9). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "ManifestWire", into = "ManifestWire")] +pub struct CultMeshCdnArtifactManifest { + pub artifact_id: String, + pub kind: String, + pub version: String, + pub content_hash: String, + pub size_bytes: i64, + pub mime_type: String, + pub created_at_utc: String, + pub chunks: Vec, + pub tags: Vec, + /// Caller metadata in wire order. The reference writes a `Dictionary` in insertion order and + /// refuses a duplicate key at read, so this keeps the order it was read in and refuses a duplicate. + pub metadata: Vec<(String, String)>, +} + +// The tuple mirrors: `to_vec_named` would write a map for a named-field struct, but a tuple +// struct is an array under every serde encoder. +#[derive(Serialize, Deserialize)] +struct ChunkRefWire(String, i64, i32, String); + +#[derive(Serialize, Deserialize)] +struct ManifestWire( + String, + String, + String, + String, + i64, + String, + String, + Vec, + Vec, + #[serde(with = "ordered_map")] Vec<(String, String)>, +); + +/// A string map that keeps its key order and refuses a duplicate key, as `Dictionary` +/// deserialises. `Vec<(String, String)>` is the in-memory shape; the wire shape is a msgpack map. +mod ordered_map { + use std::fmt; + + use serde::de::{self, MapAccess, Visitor}; + use serde::{Deserializer, Serializer}; + + pub fn serialize(pairs: &[(String, String)], serializer: S) -> Result { + serializer.collect_map(pairs.iter().map(|(key, value)| (key, value))) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + struct Pairs; + impl<'de> Visitor<'de> for Pairs { + type Value = Vec<(String, String)>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of strings") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut seen = std::collections::HashSet::new(); + let mut pairs = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + return Err(de::Error::custom(format!("duplicate metadata key '{key}'"))); + } + pairs.push((key, value)); + } + Ok(pairs) + } + } + deserializer.deserialize_map(Pairs) + } +} + +impl From for ChunkRefWire { + fn from(value: CultMeshCdnChunkRef) -> Self { + Self(value.chunk_hash, value.offset, value.size_bytes, value.record_key) + } +} + +impl From for CultMeshCdnChunkRef { + fn from(value: ChunkRefWire) -> Self { + Self { + chunk_hash: value.0, + offset: value.1, + size_bytes: value.2, + record_key: value.3, + } + } +} + +impl From for ManifestWire { + fn from(value: CultMeshCdnArtifactManifest) -> Self { + Self( + value.artifact_id, + value.kind, + value.version, + value.content_hash, + value.size_bytes, + value.mime_type, + value.created_at_utc, + value.chunks, + value.tags, + value.metadata, + ) + } +} + +impl From for CultMeshCdnArtifactManifest { + fn from(value: ManifestWire) -> Self { + Self { + artifact_id: value.0, + kind: value.1, + version: value.2, + content_hash: value.3, + size_bytes: value.4, + mime_type: value.5, + created_at_utc: value.6, + chunks: value.7, + tags: value.8, + metadata: value.9, + } + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn chunk_record_key(hash: &str) -> String { + format!("mesh:cdn:chunk:{hash}") +} + +/// `CultMeshCdn.NormalizeHash`: an optional case-insensitive `sha256:` prefix is stripped, then the +/// rest is trimmed and lowercased. A blank hash is refused. +pub fn normalize_hash(hash: &str) -> Result { + if hash.trim().is_empty() { + bail!("Hash must be non-empty."); + } + let stripped = match hash.get(..7) { + Some(prefix) if prefix.eq_ignore_ascii_case("sha256:") => &hash[7..], + _ => hash, + }; + Ok(stripped.trim().to_lowercase()) +} + +/// A hash the client will put on the wire or compare against: normalized and not empty. +fn non_empty_hash(hash: &str) -> Result { + let normalized = normalize_hash(hash)?; + if normalized.is_empty() { + bail!("Hash must be non-empty."); + } + Ok(normalized) +} + +/// `CultMeshCdn.PackArtifact`: splits `bytes` into content-addressed chunks and builds the manifest. +/// An empty body packs as one zero-length chunk, as the reference does. +pub fn pack_content( + artifact_id: &str, + kind: &str, + version: &str, + mime_type: &str, + created_at_utc: &str, + bytes: &[u8], + chunk_size: usize, +) -> Result<(CultMeshCdnArtifactManifest, Vec)> { + if artifact_id.trim().is_empty() { + bail!("Artifact id must be non-empty."); + } + if chunk_size == 0 || i32::try_from(chunk_size).is_err() { + bail!("ChunkSizeBytes must be greater than zero and fit in an int."); + } + let pieces: Vec<&[u8]> = if bytes.is_empty() { + vec![bytes] + } else { + bytes.chunks(chunk_size).collect() + }; + let mut offset = 0i64; + let mut chunks = Vec::with_capacity(pieces.len()); + let mut refs = Vec::with_capacity(pieces.len()); + for piece in pieces { + let chunk_hash = sha256_hex(piece); + refs.push(CultMeshCdnChunkRef { + record_key: chunk_record_key(&chunk_hash), + chunk_hash: chunk_hash.clone(), + offset, + // A piece is at most `chunk_size` bytes, which fits an i32 (checked above). + size_bytes: piece.len() as i32, + }); + offset += piece.len() as i64; + chunks.push(CultMeshCdnChunk { + chunk_hash, + payload: piece.to_vec(), + }); + } + let manifest = CultMeshCdnArtifactManifest { + artifact_id: artifact_id.to_string(), + kind: non_blank_or(kind, "asset"), + version: version.to_string(), + content_hash: sha256_hex(bytes), + size_bytes: bytes.len() as i64, + mime_type: non_blank_or(mime_type, "application/octet-stream"), + created_at_utc: created_at_utc.to_string(), + chunks: refs, + tags: Vec::new(), + metadata: Vec::new(), + }; + validate_manifest(&manifest)?; + Ok((manifest, chunks)) +} + +fn non_blank_or(value: &str, default: &str) -> String { + if value.trim().is_empty() { + default.to_string() + } else { + value.to_string() + } +} + +/// The chunk list in offset order, as the reference reads it (`OrderBy(chunk => chunk.Offset)`). +fn in_offset_order(manifest: &CultMeshCdnArtifactManifest) -> Vec<&CultMeshCdnChunkRef> { + let mut refs: Vec<&CultMeshCdnChunkRef> = manifest.chunks.iter().collect(); + refs.sort_by_key(|chunk| chunk.offset); + refs +} + +/// `CultMeshCdn.ValidateManifestShape`: non-negative size, chunks contiguous from offset 0 once +/// sorted by offset, non-negative chunk sizes, and chunk sizes summing to the manifest size. +pub fn validate_manifest(manifest: &CultMeshCdnArtifactManifest) -> Result<()> { + if manifest.size_bytes < 0 { + bail!("CDN artifact manifest has a negative size."); + } + let mut expected_offset = 0i64; + for chunk in in_offset_order(manifest) { + if chunk.offset != expected_offset { + bail!("CDN artifact chunks are not contiguous."); + } + if chunk.size_bytes < 0 { + bail!("CDN artifact chunk has a negative size."); + } + expected_offset = expected_offset + .checked_add(i64::from(chunk.size_bytes)) + .ok_or_else(|| anyhow!("CDN artifact chunk sizes overflow."))?; + } + if expected_offset != manifest.size_bytes { + bail!("CDN artifact chunk sizes do not sum to the manifest size."); + } + Ok(()) +} + +/// `CultMeshCdn.ValidateChunk`: the chunk's size and full SHA-256 must be what the reference and +/// the chunk's own claim say. `ref_hash` is already normalized. +fn validate_chunk( + ref_hash: &str, + ref_size: i32, + claimed_hash: &str, + claimed_size: i64, + payload: &[u8], +) -> Result<()> { + if i64::try_from(payload.len()).ok() != Some(i64::from(ref_size)) || claimed_size != i64::from(ref_size) { + bail!("CDN artifact chunk payload metadata does not match its manifest reference."); + } + let actual = sha256_hex(payload); + if ref_hash != actual || normalize_hash(claimed_hash)? != actual { + bail!("CDN artifact chunk hash does not match its payload."); + } + Ok(()) +} + +/// `CultMeshLegacyRudpContentServer.HandleAsync`, in its validation order: message id, hash +/// normalisation, record-key agreement with `mesh:cdn:chunk:`, lookup, then size and hash of +/// what is served. A failure is `found: false` with an empty payload and the reference's +/// `: ` in `error`; it is never silence. `lookup` takes the normalized hash. +pub fn answer_content_chunk_request<'a>( + request: &CultNetMessage, + lookup: impl Fn(&str) -> Option<&'a [u8]>, +) -> CultNetMessage { + let CultNetMessage::ContentChunkRequest { + message_id, + chunk_hash, + record_key, + expected_size_bytes, + } = request + else { + return CultNetMessage::Error { + error: "not a cultmesh.content_chunk_request.v1 message".to_string(), + code: None, + details: None, + }; + }; + let served = (|| -> std::result::Result<(String, Vec), String> { + if message_id.trim().is_empty() { + return Err("InvalidDataException: Content chunk request requires a message identity.".to_string()); + } + // The handler normalises the wire hash, then `CreateRecordKey` normalises that result again + // (and spells its parameter `chunkHash`); the lookup, the size check and the hash check all + // use the second result, and the answer echoes the first. + let blank = |parameter: &str| format!("ArgumentException: Hash must be non-empty. (Parameter '{parameter}')"); + let hash = normalize_hash(chunk_hash).map_err(|_| blank("ChunkHash"))?; + let canonical = normalize_hash(&hash).map_err(|_| blank("chunkHash"))?; + if !record_key.trim().is_empty() && *record_key != chunk_record_key(&canonical) { + return Err("InvalidDataException: Content chunk record key disagrees with its content hash.".to_string()); + } + let payload = lookup(&canonical).ok_or_else(|| "FileNotFoundException: Content chunk is not available.".to_string())?; + validate_chunk(&canonical, *expected_size_bytes, &canonical, payload.len() as i64, payload) + .map_err(|error| format!("InvalidDataException: {error}"))?; + Ok((hash, payload.to_vec())) + })(); + match served { + Ok((hash, payload)) => CultNetMessage::ContentChunkResponse { + message_id: message_id.clone(), + found: true, + chunk_hash: hash, + size_bytes: *expected_size_bytes, + payload, + error: String::new(), + }, + Err(error) => CultNetMessage::ContentChunkResponse { + message_id: message_id.clone(), + found: false, + chunk_hash: chunk_hash.clone(), + size_bytes: 0, + payload: Vec::new(), + error, + }, + } +} + +/// The client half: fetches every chunk of `manifest` through `ask`, one in flight, in offset +/// order, and returns the body only after each chunk and then the whole body verify. `max_bytes` +/// is checked against the manifest before the first request. `ask` sends a request on the +/// caller's session and returns the answer; it owns transport, this function owns verification. +pub fn fetch_content( + manifest: &CultMeshCdnArtifactManifest, + max_bytes: u64, + mut ask: impl FnMut(CultNetMessage) -> Result, +) -> Result> { + validate_manifest(manifest)?; + if manifest.size_bytes as u64 > max_bytes { + bail!( + "CDN artifact of {} bytes exceeds the {max_bytes}-byte cap.", + manifest.size_bytes + ); + } + // Everything the client will send or compare is settled before the first request. A zero-size + // chunk is legal only as an empty body's single chunk (the reference packs it so); anywhere else + // it is a request that yields nothing, and a manifest of them is a request flood. + let ordered = in_offset_order(manifest); + if ordered.len() > 1 && ordered.iter().any(|chunk| chunk.size_bytes == 0) { + bail!("CDN artifact has a zero-size chunk beside other chunks."); + } + let content_hash = non_empty_hash(&manifest.content_hash)?; + let hashes = ordered + .iter() + .map(|chunk| non_empty_hash(&chunk.chunk_hash)) + .collect::>>()?; + let mut body = Vec::new(); + for (chunk, hash) in ordered.into_iter().zip(hashes) { + let message_id = uuid::Uuid::new_v4().simple().to_string(); + let record_key = if chunk.record_key.trim().is_empty() { + chunk_record_key(&hash) + } else { + chunk.record_key.clone() + }; + let answer = ask(CultNetMessage::ContentChunkRequest { + message_id: message_id.clone(), + chunk_hash: hash.clone(), + record_key, + expected_size_bytes: chunk.size_bytes, + })?; + let CultNetMessage::ContentChunkResponse { + message_id: answered, + found, + chunk_hash, + size_bytes, + payload, + error, + } = answer + else { + bail!("chunk '{hash}' was answered with something other than a content chunk response"); + }; + if answered != message_id { + bail!("chunk '{hash}' was answered for message '{answered}', not '{message_id}'"); + } + if !found { + bail!("content provider rejected chunk '{hash}': {error}"); + } + validate_chunk(&hash, chunk.size_bytes, &chunk_hash, i64::from(size_bytes), &payload)?; + body.extend_from_slice(&payload); + } + if sha256_hex(&body) != content_hash { + bail!("CDN artifact content hash does not match its manifest."); + } + Ok(body) +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 68edf97a..cc50171d 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -508,6 +508,26 @@ pub enum CultNetMessage { #[serde(default, skip_serializing_if = "Option::is_none")] source_runtime_id: Option, }, + /// `cultmesh.content_chunk_request.v1`. Mirrors `CultMeshContentChunkRequestMessage`; raw path. + #[serde(rename = "cultmesh.content_chunk_request.v1", rename_all = "camelCase")] + ContentChunkRequest { + message_id: String, + chunk_hash: String, + record_key: String, + expected_size_bytes: i32, + }, + /// `cultmesh.content_chunk_response.v1`. Mirrors `CultMeshContentChunkResponseMessage`. `payload` + /// is `bin`, so it travels the raw path; the serde attribute never runs on the wire. + #[serde(rename = "cultmesh.content_chunk_response.v1", rename_all = "camelCase")] + ContentChunkResponse { + message_id: String, + found: bool, + chunk_hash: String, + size_bytes: i32, + #[serde(with = "serde_bytes")] + payload: Vec, + error: String, + }, } fn messagepack_base64_encoding() -> String { @@ -529,6 +549,8 @@ pub fn parse_cultnet_message( "cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0" | "cultnet.snapshot_response_raw.v1" + | "cultmesh.content_chunk_request.v1" + | "cultmesh.content_chunk_response.v1" | "cultnet.error.v0", ) => parse_raw_cultnet_schema_message(wire_value)?, _ => { @@ -553,6 +575,8 @@ pub fn encode_cultnet_message_for_wire( CultNetMessage::DocumentPutRaw { .. } | CultNetMessage::SnapshotResponseRaw { .. } | CultNetMessage::SnapshotResponseRawV1 { .. } + | CultNetMessage::ContentChunkRequest { .. } + | CultNetMessage::ContentChunkResponse { .. } | CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message), _ => Ok(rmp_serde::from_slice(&rmp_serde::to_vec( &serde_json::to_value(message)?, @@ -777,6 +801,10 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { validate_shard_descriptor(shard)?; } } + // The reference's serializer accepts any content chunk message. It refuses a bad request in + // its handler and answers `found: false` (`answer_content_chunk_request`); a bad response is + // refused by `fetch_content`'s verification. Refusing either here would leave a peer in silence. + CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkResponse { .. } => {} CultNetMessage::OperationRequest { message_id, service_id, @@ -1044,6 +1072,23 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result Ok(CultNetMessage::ContentChunkRequest { + message_id: legacy_string_or_nil(get("messageId"), "messageId")?, + chunk_hash: legacy_string_or_nil(get("chunkHash"), "chunkHash")?, + record_key: legacy_string_or_nil(get("recordKey"), "recordKey")?, + expected_size_bytes: require_legacy_i32(get("expectedSizeBytes"), "expectedSizeBytes")?, + }), + "cultmesh.content_chunk_response.v1" => Ok(CultNetMessage::ContentChunkResponse { + message_id: require_legacy_string(get("messageId"), "messageId")?, + found: require_legacy_bool(get("found"), "found")?, + chunk_hash: require_legacy_string(get("chunkHash"), "chunkHash")?, + size_bytes: require_legacy_i32(get("sizeBytes"), "sizeBytes")?, + payload: get("payload") + .and_then(rmpv::Value::as_slice) + .ok_or_else(|| anyhow!("payload must be binary data"))? + .to_vec(), + error: require_legacy_string(get("error"), "error")?, + }), "cultnet.error.v0" => { let code = get("code") .filter(|value| !value.is_nil()) @@ -1209,6 +1254,43 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ + ( + rmpv::Value::from("schemaVersion"), + rmpv::Value::from("cultmesh.content_chunk_request.v1"), + ), + (rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())), + (rmpv::Value::from("chunkHash"), rmpv::Value::from(chunk_hash.as_str())), + (rmpv::Value::from("recordKey"), rmpv::Value::from(record_key.as_str())), + ( + rmpv::Value::from("expectedSizeBytes"), + rmpv::Value::from(*expected_size_bytes), + ), + ], + CultNetMessage::ContentChunkResponse { + message_id, + found, + chunk_hash, + size_bytes, + payload, + error, + } => vec![ + ( + rmpv::Value::from("schemaVersion"), + rmpv::Value::from("cultmesh.content_chunk_response.v1"), + ), + (rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())), + (rmpv::Value::from("found"), rmpv::Value::from(*found)), + (rmpv::Value::from("chunkHash"), rmpv::Value::from(chunk_hash.as_str())), + (rmpv::Value::from("sizeBytes"), rmpv::Value::from(*size_bytes)), + (rmpv::Value::from("payload"), rmpv::Value::Binary(payload.clone())), + (rmpv::Value::from("error"), rmpv::Value::from(error.as_str())), + ], // R-N: matches the landed C# `CultNetErrorMessage` // (src/GameCult.Networking/CultNetSchemaMessages.cs) key for key and in its declared // order - schemaVersion, error, routingHint, code, details - with every optional key @@ -1422,6 +1504,13 @@ fn require_legacy_u32(value: Option<&rmpv::Value>, field_name: &str) -> Result, field_name: &str) -> Result { + let number = value + .and_then(rmpv::Value::as_i64) + .ok_or_else(|| anyhow!("{field_name} must be an integer"))?; + i32::try_from(number).map_err(|_| anyhow!("{field_name} does not fit in i32")) +} + fn require_legacy_u64(value: Option<&rmpv::Value>, field_name: &str) -> Result { value .and_then(rmpv::Value::as_u64) @@ -1632,6 +1721,15 @@ fn require_legacy_bytes(value: Option<&rmpv::Value>, field_name: &str) -> Result Ok(URL_SAFE_NO_PAD.encode(bytes)) } +/// A string the reference reads back as null when its writer set it to null (msgpack nil), which its +/// handlers treat as blank. Only content chunk requests use it: they are answered, never dropped. +fn legacy_string_or_nil(value: Option<&rmpv::Value>, field_name: &str) -> Result { + match value { + Some(rmpv::Value::Nil) => Ok(String::new()), + other => require_legacy_string(other, field_name), + } +} + fn require_legacy_string(value: Option<&rmpv::Value>, field_name: &str) -> Result { let value = value .and_then(rmpv::Value::as_str) diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index cf858647..5bfd1857 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -1,3 +1,4 @@ +mod content; mod contracts; mod idunn_deployment_brake; mod idunn_lifecycle_brake; @@ -19,6 +20,7 @@ mod selection; mod shard_catalog; mod transport; +pub use content::*; pub use contracts::*; pub use idunn_deployment_brake::*; pub use idunn_lifecycle_brake::*; diff --git a/packages/cultnet-rs/tests/content.rs b/packages/cultnet-rs/tests/content.rs new file mode 100644 index 00000000..178ebe3c --- /dev/null +++ b/packages/cultnet-rs/tests/content.rs @@ -0,0 +1,806 @@ +//! The content plane (`src/content.rs`, the two chunk messages in `src/contracts.rs`), pinned against +//! the C# reference. `contracts/cultmesh/content-vectors.cs-written.json` is the reference's own +//! bytes (written by `CultMeshContentVectorTests`, including the answers `CultMeshLegacyRudpContentServer` +//! gives); `content-vectors.rs-written.json` is this binary's mirror, regenerated with +//! `CULTNET_WRITE_VECTORS=1`, and judged by the C# side. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use base64::Engine; +use cultnet_rs::{ + CultMeshCdnArtifactManifest, CultNetMessage, CultNetWireContract, answer_content_chunk_request, + decode_cultnet_message_from_slice, encode_cultnet_message_for_wire, encode_cultnet_message_to_vec, + fetch_content, normalize_hash, pack_content, validate_manifest, +}; +use serde::{Deserialize, Serialize}; + +const CHUNK: usize = 262_144; +const BODY_LEN: usize = 1_315_551; +const CREATED: &str = "2026-09-22T00:00:00.0000000+00:00"; +const SCHEMA_V0: CultNetWireContract = CultNetWireContract::CultNetSchemaV0; + +fn body() -> Vec { + (0..BODY_LEN).map(|i| (i % 251) as u8).collect() +} + +fn pack(bytes: &[u8]) -> (CultMeshCdnArtifactManifest, Vec) { + pack_content( + "eureka/content-vector", + "asset", + "1", + "application/x-cultnet-vector", + CREATED, + bytes, + CHUNK, + ) + .expect("packs") +} + +fn store(chunks: &[cultnet_rs::CultMeshCdnChunk]) -> HashMap> { + chunks + .iter() + .map(|chunk| (chunk.chunk_hash.clone(), chunk.payload.clone())) + .collect() +} + +fn lookup_in<'a>(store: &'a HashMap>) -> impl Fn(&str) -> Option<&'a [u8]> { + move |hash| store.get(hash).map(Vec::as_slice) +} + +fn wire(message: &CultNetMessage) -> Vec { + encode_cultnet_message_to_vec(message, SCHEMA_V0).expect("encodes") +} + +fn unwire(bytes: &[u8]) -> Result { + decode_cultnet_message_from_slice(bytes, SCHEMA_V0) +} + +fn manifest_bytes(manifest: &CultMeshCdnArtifactManifest) -> Vec { + rmp_serde::to_vec_named(manifest).expect("encodes") +} + +fn decode_manifest(bytes: &[u8]) -> CultMeshCdnArtifactManifest { + rmp_serde::from_slice(bytes).expect("decodes") +} + +/// The manifest's bytes with its metadata map replaced by `pairs`, written in exactly that order +/// (and with duplicates, if given): what a peer that is not this crate can put on the wire. +fn manifest_with_metadata(manifest: &CultMeshCdnArtifactManifest, pairs: &[(&str, &str)]) -> Vec { + let encoded = manifest_bytes(manifest); + let mut value = rmpv::decode::read_value(&mut encoded.as_slice()).expect("a msgpack value"); + let rmpv::Value::Array(fields) = &mut value else { + panic!("the manifest is a positional array"); + }; + fields[9] = rmpv::Value::Map(pairs.iter().map(|(k, v)| (rmpv::Value::from(*k), rmpv::Value::from(*v))).collect()); + let mut out = Vec::new(); + rmpv::encode::write_value(&mut out, &value).expect("writes"); + out +} + +fn request(id: &str, hash: &str, key: &str, size: i32) -> CultNetMessage { + CultNetMessage::ContentChunkRequest { + message_id: id.to_string(), + chunk_hash: hash.to_string(), + record_key: key.to_string(), + expected_size_bytes: size, + } +} + +fn response_of(message: &CultNetMessage) -> (&str, bool, &str, &[u8], &str) { + match message { + CultNetMessage::ContentChunkResponse { + message_id, + found, + chunk_hash, + payload, + error, + .. + } => (message_id, *found, chunk_hash, payload, error), + other => panic!("not a content chunk response: {other:?}"), + } +} + +/// A transport closure that answers from `chunks` through the wire, counting calls. +fn serving<'a>( + chunks: &'a HashMap>, + calls: &'a std::cell::Cell, +) -> impl FnMut(CultNetMessage) -> Result + 'a { + move |message| { + calls.set(calls.get() + 1); + let request = unwire(&wire(&message))?; + unwire(&wire(&answer_content_chunk_request(&request, lookup_in(chunks)))) + } +} + +fn flip_last_digit(hash: &str) -> String { + let last = if hash.ends_with('0') { '1' } else { '0' }; + format!("{}{last}", &hash[..hash.len() - 1]) +} + +// ------------------------------------------------------------------------------------------ +// Vector files +// ------------------------------------------------------------------------------------------ + +#[derive(Serialize, Deserialize)] +struct VectorFile { + vectors: Vec, +} + +#[derive(Serialize, Deserialize)] +struct VectorEntry { + label: String, + kind: String, + #[serde(rename = "messagePackBase64")] + message_pack_base64: String, +} + +struct Vector { + label: String, + kind: &'static str, + bytes: Vec, +} + +fn vector_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("contracts") + .join("cultmesh") + .join(name) +} + +fn read_vectors(name: &str) -> Vec<(String, String, Vec)> { + let path = vector_path(name); + let text = std::fs::read_to_string(&path).unwrap_or_else(|_| { + panic!("{} is missing - write it first (CULTNET_WRITE_VECTORS=1)", path.display()) + }); + let file: VectorFile = serde_json::from_str(&text).expect("vector file parses"); + assert!(!file.vectors.is_empty(), "the vector file must carry at least one vector"); + file.vectors + .into_iter() + .map(|v| { + let bytes = base64::engine::general_purpose::STANDARD + .decode(&v.message_pack_base64) + .expect("base64"); + (v.label, v.kind, bytes) + }) + .collect() +} + +/// The vectors this runtime writes: the same labelled set the C# writer produces, built from Rust +/// values and answered by Rust's `answer_content_chunk_request`. +fn local_vectors() -> Vec { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + let last = chunks.last().expect("chunks"); + let last_size = last.payload.len() as i32; + let key = format!("mesh:cdn:chunk:{}", last.chunk_hash); + let mut vectors = Vec::new(); + let mut push_message = |label: String, kind: &'static str, message: &CultNetMessage| { + vectors.push(Vector { label, kind, bytes: wire(message) }); + }; + let answers = [ + ("answer_found", request("vector-found", &format!("SHA256:{}", last.chunk_hash), &key, last_size)), + ("answer_not_found", request("vector-missing", &"0".repeat(64), "", 100)), + ( + "answer_record_key_disagrees", + request("vector-key", &last.chunk_hash, "mesh:cdn:chunk:other", last_size), + ), + ("answer_size_mismatch", request("vector-size", &last.chunk_hash, &key, last_size - 1)), + ("answer_blank_message_id", request("", &last.chunk_hash, &key, last_size)), + ("answer_whitespace_message_id", request(" ", &last.chunk_hash, &key, last_size)), + ("answer_blank_hash", request("vector-blank-hash", "", "", 100)), + ("answer_whitespace_hash", request("vector-space-hash", " ", "", 100)), + ("answer_prefix_only_hash", request("vector-prefix-hash", "sha256: ", "", 100)), + ("answer_negative_size", request("vector-negative", &last.chunk_hash, &key, -1)), + ( + "answer_record_key_case", + request("vector-key-case", &last.chunk_hash, &key.to_uppercase(), last_size), + ), + ("answer_blank_record_key", request("vector-key-blank", &last.chunk_hash, " ", last_size)), + ( + "answer_double_prefix", + request("vector-double", &format!("sha256:sha256:{}", last.chunk_hash), "", last_size), + ), + ]; + for (name, request) in &answers { + push_message(format!("{name}.request"), "request", request); + let answer = answer_content_chunk_request(request, lookup_in(&store)); + push_message(format!("{name}.response"), "response", &answer); + } + push_message( + "response_hash_last_digit".to_string(), + "response", + &CultNetMessage::ContentChunkResponse { + message_id: "vector-digit".to_string(), + found: true, + chunk_hash: flip_last_digit(&last.chunk_hash), + size_bytes: last_size, + payload: last.payload.clone(), + error: String::new(), + }, + ); + let mut push_manifest = |label: &str, manifest: &CultMeshCdnArtifactManifest| { + vectors.push(Vector { + label: label.to_string(), + kind: "manifest", + bytes: manifest_bytes(manifest), + }); + }; + push_manifest("manifest", &manifest); + let mut shuffled = manifest.clone(); + shuffled.chunks = [3, 0, 5, 1, 4, 2].iter().map(|&i| manifest.chunks[i].clone()).collect(); + push_manifest("manifest_out_of_order", &shuffled); + let tagged = decode_manifest(&manifest_with_metadata(&manifest, &[("zeta", "1"), ("alpha", "2"), ("mid", "3")])); + push_manifest("manifest_metadata_order", &tagged); + push_manifest("manifest_empty_body", &pack(&[]).0); + vectors +} + +fn to_json(vectors: &[Vector]) -> String { + let file = VectorFile { + vectors: vectors + .iter() + .map(|v| VectorEntry { + label: v.label.clone(), + kind: v.kind.to_string(), + message_pack_base64: base64::engine::general_purpose::STANDARD.encode(&v.bytes), + }) + .collect(), + }; + serde_json::to_string_pretty(&file).expect("serializes") +} + +/// The committed rs-written file is what this binary encodes now. Under `CULTNET_WRITE_VECTORS=1` +/// it is rewritten instead, for the C# test `RustVectorsDecodeInTheReference` to judge. +#[test] +fn rust_written_vectors_are_current() { + let fresh = local_vectors(); + let path = vector_path("content-vectors.rs-written.json"); + if std::env::var("CULTNET_WRITE_VECTORS").as_deref() == Ok("1") { + std::fs::write(&path, to_json(&fresh)).expect("writes the vector file"); + return; + } + let committed = read_vectors("content-vectors.rs-written.json"); + assert_eq!(committed.len(), fresh.len()); + for ((label, kind, bytes), fresh) in committed.iter().zip(&fresh) { + assert_eq!((label.as_str(), kind.as_str()), (fresh.label.as_str(), fresh.kind)); + assert_eq!(bytes, &fresh.bytes, "vector '{label}' drifted from this binary's encode"); + } +} + +/// Byte identity, both ways, against the reference's own bytes: every vector the reference wrote +/// decodes here and this runtime's independently built value encodes to exactly those bytes. The +/// manifest vector also pins `pack_content` (same body and options, same manifest, byte for byte). +#[test] +fn reference_vectors_encode_byte_identically() { + let local = local_vectors(); + // The nil-field vectors are bytes only the reference writes; `nil_fields_are_answered_as_blanks` judges them. + let reference: Vec<_> = read_vectors("content-vectors.cs-written.json") + .into_iter() + .filter(|(label, _, _)| !label.starts_with("nil_")) + .collect(); + assert_eq!( + reference.iter().map(|(l, _, _)| l.as_str()).collect::>(), + local.iter().map(|v| v.label.as_str()).collect::>(), + "both runtimes write the same labelled vectors" + ); + for ((label, kind, reference_bytes), mine) in reference.iter().zip(&local) { + assert_eq!(reference_bytes, &mine.bytes, "vector '{label}' is not byte identical"); + match kind.as_str() { + "request" | "response" => { + let decoded = unwire(reference_bytes).expect("the reference's bytes decode"); + assert_eq!(&wire(&decoded), reference_bytes, "vector '{label}' does not re-encode identically"); + } + "manifest" => { + let decoded: CultMeshCdnArtifactManifest = + rmp_serde::from_slice(reference_bytes).expect("the reference's manifest decodes"); + assert_eq!(&manifest_bytes(&decoded), reference_bytes, "manifest '{label}' does not re-encode identically"); + } + other => panic!("unknown vector kind {other}"), + } + } +} + +/// F1's failure mode: a chunk response carries `bin`, which the JSON-value path cannot carry. +#[test] +fn a_chunk_response_carries_bin_through_the_raw_path() { + let payload: Vec = (0..=255u8).collect(); + let message = CultNetMessage::ContentChunkResponse { + message_id: "m".to_string(), + found: true, + chunk_hash: "ab".to_string(), + size_bytes: 256, + payload: payload.clone(), + error: String::new(), + }; + let wire_value = encode_cultnet_message_for_wire(&message, SCHEMA_V0).expect("encodes"); + let entries = wire_value.as_map().expect("a map"); + let (_, value) = entries.iter().find(|(k, _)| k.as_str() == Some("payload")).expect("payload key"); + assert!(matches!(value, rmpv::Value::Binary(bytes) if *bytes == payload), "payload must be bin"); + assert_eq!(unwire(&wire(&message)).expect("decodes"), message); +} + +#[test] +fn normalize_hash_matches_the_reference() { + for (input, expected) in [ + ("SHA256:ABCdef", "abcdef"), + ("sha256:abc", "abc"), + ("sha256: ABC ", "abc"), + (" ABC ", "abc"), + (" sha256:abc", "sha256:abc"), + ("sha256:", ""), + ("plain", "plain"), + ] { + assert_eq!(normalize_hash(input).expect("normalizes"), expected, "input {input:?}"); + } + for blank in ["", " "] { + assert!(normalize_hash(blank).is_err(), "{blank:?} must be refused"); + } + // The fixture's uppercase-prefix request names the same chunk as its bare hash. + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "answer_found.request").expect("vector"); + let CultNetMessage::ContentChunkRequest { chunk_hash, .. } = unwire(bytes).expect("decodes") else { + panic!("not a request"); + }; + assert!(chunk_hash.starts_with("SHA256:")); + let (_, chunks) = pack(&body()); + assert_eq!(normalize_hash(&chunk_hash).expect("normalizes"), chunks.last().unwrap().chunk_hash); +} + +#[test] +fn a_manifest_out_of_offset_order_is_accepted_as_the_reference_accepts_it() { + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "manifest_out_of_order").expect("vector"); + let manifest: CultMeshCdnArtifactManifest = rmp_serde::from_slice(bytes).expect("decodes"); + assert_ne!(manifest.chunks[0].offset, 0, "the fixture's chunk list is shuffled"); + validate_manifest(&manifest).expect("the reference accepts it"); + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let calls = std::cell::Cell::new(0); + let fetched = fetch_content(&manifest, BODY_LEN as u64, serving(&store, &calls)).expect("fetches"); + assert_eq!(fetched, body()); + assert_eq!(calls.get(), 6); +} + +#[test] +fn a_hostile_manifest_is_refused_before_any_request() { + let (good, chunks) = pack(&body()); + let store = store(&chunks); + let mut hostile = Vec::new(); + let mut negative = good.clone(); + negative.size_bytes = -1; + hostile.push(negative); + let mut gap = good.clone(); + gap.chunks[2].offset += 1; + hostile.push(gap); + let mut overlap = good.clone(); + overlap.chunks[3].offset -= 1; + hostile.push(overlap); + let mut short = good.clone(); + short.size_bytes -= 1; + hostile.push(short); + let mut negative_chunk = good.clone(); + negative_chunk.chunks[5].size_bytes = -4831; + hostile.push(negative_chunk); + for manifest in hostile { + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&manifest, u64::MAX, serving(&store, &calls)).is_err()); + assert_eq!(calls.get(), 0, "a corrupt manifest must not make the client ask"); + } +} + +#[test] +fn a_manifest_over_the_cap_is_refused_before_any_request() { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + let size = BODY_LEN as u64; + + let calls = std::cell::Cell::new(0); + let fetched = fetch_content(&manifest, size, serving(&store, &calls)).expect("exactly the cap is accepted"); + assert_eq!(fetched.len(), BODY_LEN); + assert_eq!(calls.get(), 6); + + // One byte over, and sizes where doubling the cap would admit the body. + for cap in [size - 1, size / 2 + 1, size / 2, 0] { + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&manifest, cap, serving(&store, &calls)).is_err(), "cap {cap} must refuse"); + assert_eq!(calls.get(), 0, "cap {cap} must refuse before any request"); + } +} + +#[test] +fn a_chunk_whose_hash_differs_in_the_last_digit_is_refused() { + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "response_hash_last_digit").expect("vector"); + let CultNetMessage::ContentChunkResponse { chunk_hash: claimed, payload, .. } = unwire(bytes).expect("decodes") else { + panic!("not a response"); + }; + // A one-chunk manifest for exactly that payload: its bytes are right, only the response's claim is off. + let (manifest, chunks) = pack(&payload); + let honest = chunks[0].chunk_hash.clone(); + assert_ne!(claimed, honest); + assert_eq!(claimed[..claimed.len() - 1], honest[..honest.len() - 1], "they differ in the last digit only"); + let error = fetch_content(&manifest, u64::MAX, |message| { + let CultNetMessage::ContentChunkRequest { message_id, .. } = message else { + panic!("not a request"); + }; + let CultNetMessage::ContentChunkResponse { found, size_bytes, payload, error, .. } = + unwire(bytes).expect("decodes") + else { + panic!("not a response"); + }; + Ok(CultNetMessage::ContentChunkResponse { + message_id, + found, + chunk_hash: claimed.clone(), + size_bytes, + payload, + error, + }) + }) + .expect_err("a hash off by one digit is a different hash"); + assert!(error.to_string().contains("does not match its payload"), "{error}"); +} + +#[test] +fn a_body_whose_chunks_verify_but_whole_hash_does_not_is_refused() { + let (mut manifest, chunks) = pack(&body()); + manifest.content_hash = pack(b"another body").0.content_hash; + let store = store(&chunks); + let calls = std::cell::Cell::new(0); + let error = fetch_content(&manifest, u64::MAX, serving(&store, &calls)).expect_err("whole body must verify"); + assert_eq!(calls.get(), 6, "every chunk verified; only the whole body is wrong"); + assert!(error.to_string().contains("content hash"), "{error}"); +} + +#[test] +fn a_response_for_another_message_id_is_refused() { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + let error = fetch_content(&manifest, u64::MAX, |message| { + let mut answer = answer_content_chunk_request(&message, lookup_in(&store)); + if let CultNetMessage::ContentChunkResponse { message_id, .. } = &mut answer { + *message_id = "someone-else".to_string(); + } + Ok(answer) + }) + .expect_err("an answer to another request is not this answer"); + assert!(error.to_string().contains("someone-else"), "{error}"); +} + +#[test] +fn an_empty_body_packs_one_zero_length_chunk_and_fetches() { + let (manifest, chunks) = pack(&[]); + assert_eq!((manifest.chunks.len(), chunks.len(), manifest.size_bytes), (1, 1, 0)); + let store = store(&chunks); + let calls = std::cell::Cell::new(0); + assert_eq!(fetch_content(&manifest, 0, serving(&store, &calls)).expect("fetches"), Vec::::new()); + assert_eq!(calls.get(), 1); +} + +#[test] +fn answer_refuses_a_record_key_that_disagrees_with_its_hash() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let last = chunks.last().unwrap(); + let asked = std::cell::Cell::new(false); + let answer = answer_content_chunk_request( + &request("m", &last.chunk_hash, "mesh:cdn:chunk:other", last.payload.len() as i32), + |hash| { + asked.set(true); + lookup_in(&store)(hash) + }, + ); + let (id, found, _, payload, error) = response_of(&answer); + assert_eq!((id, found, payload.len()), ("m", false, 0)); + assert_eq!(error, "InvalidDataException: Content chunk record key disagrees with its content hash."); + assert!(!asked.get(), "the key check precedes the lookup, as in HandleAsync"); +} + +/// Every failure is an answer (`found: false`, empty payload, the reference's spelling) and equals, +/// byte for byte, what `CultMeshLegacyRudpContentServer` answered to the same request bytes. +#[test] +fn answer_serves_found_false_with_the_reference_error_spelling() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let reference = read_vectors("content-vectors.cs-written.json"); + let mut judged = 0; + for (label, _, request_bytes) in reference.iter().filter(|(l, _, _)| l.ends_with(".request")) { + let name = label.trim_end_matches(".request"); + let request = unwire(request_bytes).expect("decodes"); + let answer = answer_content_chunk_request(&request, lookup_in(&store)); + let (_, _, expected_bytes) = reference + .iter() + .find(|(l, _, _)| *l == format!("{name}.response")) + .expect("paired response"); + assert_eq!(&wire(&answer), expected_bytes, "{name}: answer differs from the reference's"); + let (_, found, _, payload, error) = response_of(&answer); + if ["answer_found", "answer_blank_record_key", "answer_double_prefix"].contains(&name) { + assert!(found && error.is_empty() && !payload.is_empty()); + } else { + assert!(!found && payload.is_empty(), "{name}"); + assert!(error.contains("Exception: "), "{name}: {error}"); + } + judged += 1; + } + assert_eq!(judged, 16); +} + +#[test] +fn gamecult_networking_contract_refuses_content_messages() { + let legacy = CultNetWireContract::GameCultNetworkingV0; + let message = request("m", "abc", "", 1); + assert!(encode_cultnet_message_for_wire(&message, legacy).is_err()); + assert!(decode_cultnet_message_from_slice(&wire(&message), legacy).is_err()); +} + +// ------------------------------------------------------------------------------------------ +// BP-1 fixes +// ------------------------------------------------------------------------------------------ + +fn chunk_ref(hash: &str, offset: i64, size: i32) -> cultnet_rs::CultMeshCdnChunkRef { + cultnet_rs::CultMeshCdnChunkRef { + chunk_hash: hash.to_string(), + offset, + size_bytes: size, + record_key: String::new(), + } +} + +/// A manifest with the given chunk list and stated size, everything else from a real pack. +fn manifest_of(size: i64, chunks: Vec) -> CultMeshCdnArtifactManifest { + let mut manifest = pack(&[]).0; + manifest.size_bytes = size; + manifest.chunks = chunks; + manifest +} + +// F1: a zero-size chunk is legal only as an empty body's single chunk. +#[test] +fn a_zero_size_chunk_flood_is_refused_before_any_request() { + let (empty, empty_chunks) = pack(&[]); + let empty_store = store(&empty_chunks); + let flood = manifest_of(0, (0..100_000).map(|_| chunk_ref(&empty.content_hash, 0, 0)).collect()); + validate_manifest(&flood).expect("the shared shape rule accepts it, as the reference does"); + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&flood, u64::MAX, serving(&empty_store, &calls)).is_err()); + assert_eq!(calls.get(), 0, "100,000 empty chunks must not become 100,000 requests"); + + // A zero-size chunk beside real ones is refused too, so skipping it is never the client's job. + let (good, chunks) = pack(&body()); + let body_store = store(&chunks); + let mut zero_inside = good.clone(); + let boundary = zero_inside.chunks[2].offset; + zero_inside.chunks.insert(2, chunk_ref(&empty.content_hash, boundary, 0)); + validate_manifest(&zero_inside).expect("shape-valid"); + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&zero_inside, u64::MAX, serving(&body_store, &calls)).is_err()); + assert_eq!(calls.get(), 0); + + // An empty body still fetches, through its one zero-length chunk. + let calls = std::cell::Cell::new(0); + assert_eq!(fetch_content(&empty, 0, serving(&empty_store, &calls)).expect("fetches"), Vec::::new()); + assert_eq!(calls.get(), 1); +} + +// F2, F5: every bad request is answered, in the reference's validation order and spelling. The +// byte-identity against the reference server's own answers is `answer_serves_found_false_...`. +#[test] +fn a_bad_request_is_answered_never_dropped() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let last = chunks.last().unwrap(); + let size = last.payload.len() as i32; + let size_mismatch = "InvalidDataException: CDN artifact chunk payload metadata does not match its manifest reference."; + for (label, bad, spelling) in [ + ( + "blank id", + request("", &last.chunk_hash, "", size), + "InvalidDataException: Content chunk request requires a message identity.", + ), + ("blank hash", request("m", "", "", size), "ArgumentException: Hash must be non-empty. (Parameter 'ChunkHash')"), + ( + "prefix-only hash", + request("m", "sha256: ", "", size), + "ArgumentException: Hash must be non-empty. (Parameter 'chunkHash')", + ), + ("negative size", request("m", &last.chunk_hash, "", -1), size_mismatch), + ] { + let answer = answer_content_chunk_request(&bad, lookup_in(&store)); + let (_, found, _, payload, error) = response_of(&answer); + assert!(!found && payload.is_empty(), "{label}"); + assert_eq!(error, spelling, "{label}"); + // And both travel: neither the request nor its answer is stopped at decode or encode. + assert_eq!(unwire(&wire(&bad)).expect("the request decodes"), bad, "{label}"); + assert_eq!(unwire(&wire(&answer)).expect("the answer encodes and decodes"), answer, "{label}"); + } +} + +// A caller that leaves a string null writes msgpack nil. The reference reads null, treats it as blank +// and answers; Rust decodes nil as blank so the same request gets the same answer, byte for byte. +#[test] +fn nil_fields_are_answered_as_blanks() { + let (_, chunks) = pack(&body()); + let store = store(&chunks); + let reference = read_vectors("content-vectors.cs-written.json"); + for (name, spelling) in [ + ("nil_message_id", "InvalidDataException: Content chunk request requires a message identity."), + ("nil_hash", "ArgumentException: Hash must be non-empty. (Parameter 'ChunkHash')"), + ("nil_record_key", "FileNotFoundException: Content chunk is not available."), + ] { + let find = |suffix: &str| { + let label = format!("{name}.{suffix}"); + reference.iter().find(|(l, _, _)| *l == label).expect("vector").2.clone() + }; + let request_bytes = find("request"); + assert!(request_bytes.contains(&0xc0), "{name}: the reference wrote nil"); + let answer = answer_content_chunk_request(&unwire(&request_bytes).expect("nil decodes"), lookup_in(&store)); + assert_eq!(wire(&answer), find("response"), "{name}: answer differs from the reference's"); + assert_eq!(response_of(&answer).4, spelling, "{name}"); + } +} + +// F3: metadata keeps the order it was read in and refuses a duplicate key. +#[test] +fn manifest_metadata_keeps_wire_order_and_refuses_a_duplicate_key() { + let reference = read_vectors("content-vectors.cs-written.json"); + let (_, _, bytes) = reference.iter().find(|(l, _, _)| l == "manifest_metadata_order").expect("vector"); + let decoded = decode_manifest(bytes); + assert_eq!(&manifest_bytes(&decoded), bytes, "zeta, alpha, mid re-encode as zeta, alpha, mid"); + + let base = pack(&body()).0; + let duplicated = manifest_with_metadata(&base, &[("k", "1"), ("other", "2"), ("k", "3")]); + assert!(rmp_serde::from_slice::(&duplicated).is_err()); + let unique = manifest_with_metadata(&base, &[("k", "1"), ("other", "2")]); + assert_eq!(manifest_bytes(&decode_manifest(&unique)), unique); + + // Metadata that is not a map is refused, and says what was expected. + let mut value = rmpv::decode::read_value(&mut manifest_bytes(&base).as_slice()).expect("a msgpack value"); + let rmpv::Value::Array(fields) = &mut value else { + panic!("the manifest is a positional array"); + }; + fields[9] = rmpv::Value::from(5); + let mut not_a_map = Vec::new(); + rmpv::encode::write_value(&mut not_a_map, &value).expect("writes"); + let error = rmp_serde::from_slice::(¬_a_map).expect_err("not a map"); + assert!(error.to_string().contains("a map of strings"), "{error}"); +} + +// F4: hashes are settled before the first request. +#[test] +fn a_blank_hash_is_refused_before_any_request() { + let (good, chunks) = pack(&body()); + let store = store(&chunks); + let mut blank_content = good.clone(); + blank_content.content_hash = " ".to_string(); + let mut prefix_content = good.clone(); + prefix_content.content_hash = "sha256: ".to_string(); + let mut blank_chunk = good.clone(); + blank_chunk.chunks[3].chunk_hash = " ".to_string(); + let mut prefix_chunk = good.clone(); + prefix_chunk.chunks[3].chunk_hash = "SHA256: ".to_string(); + for (label, manifest) in [ + ("blank content hash", blank_content), + ("prefix-only content hash", prefix_content), + ("blank chunk hash at 3", blank_chunk), + ("prefix-only chunk hash at 3", prefix_chunk), + ] { + let calls = std::cell::Cell::new(0); + assert!(fetch_content(&manifest, u64::MAX, serving(&store, &calls)).is_err(), "{label}"); + assert_eq!(calls.get(), 0, "{label}: refused before any request"); + } +} + +// F6: `pack_content` refuses a chunk size the reference's int cannot hold, without panicking. +#[test] +fn pack_content_refuses_an_unusable_chunk_size() { + for size in [0usize, i32::MAX as usize + 1, usize::MAX] { + let refused = std::panic::catch_unwind(|| pack_content("a", "", "", "", "", b"abc", size)); + assert!(matches!(refused, Ok(Err(_))), "chunk size {size} must be refused cleanly"); + } + let (manifest, _) = + pack_content("a", "", "", "", "", b"abc", i32::MAX as usize).expect("i32::MAX is a valid chunk size"); + assert_eq!(manifest.chunks.len(), 1); +} + +// F6: `PackArtifact` defaults a blank kind and mime type and otherwise uses the value untouched. +#[test] +fn pack_defaults_only_a_blank_kind_and_mime_type_and_never_trims() { + let packed = |kind: &str, mime: &str| pack_content("a", kind, "v", mime, CREATED, b"abc", 2).expect("packs").0; + for blank in ["", " ", "\t \n"] { + let manifest = packed(blank, blank); + assert_eq!( + (manifest.kind.as_str(), manifest.mime_type.as_str()), + ("asset", "application/octet-stream"), + "{blank:?}" + ); + } + let manifest = packed(" build ", " text/plain "); + assert_eq!((manifest.kind.as_str(), manifest.mime_type.as_str()), (" build ", " text/plain ")); +} + +/// The honest answer to `request`, taken apart so a test can lie in one field. +fn honest(request: &CultNetMessage, store: &HashMap>) -> (String, bool, String, i32, Vec, String) { + match answer_content_chunk_request(request, lookup_in(store)) { + CultNetMessage::ContentChunkResponse { message_id, found, chunk_hash, size_bytes, payload, error } => { + (message_id, found, chunk_hash, size_bytes, payload, error) + } + other => panic!("{other:?}"), + } +} + +// F6: the two halves of the per-chunk check, each on its own. +#[test] +fn a_chunk_whose_length_or_claim_disagrees_with_the_manifest_is_refused() { + let (manifest, chunks) = pack(&body()); + let store = store(&chunks); + // Each lie maps (honest size, honest payload) to (claimed size, payload served). + type Lie = fn(i32, Vec) -> (i32, Vec); + let lies: [(&str, Lie); 4] = [ + ("a payload of the claimed length that is not the manifest's length", |size, mut p| { + p.truncate(p.len() - 1); + (size - 1, p) + }), + ("a payload whose length is not its claimed size, the claim being the manifest's", |size, mut p| { + p.truncate(p.len() - 1); + (size, p) + }), + ("the right payload under a wrong claimed size", |size, p| (size - 1, p)), + ("a longer payload of its claimed length", |size, mut p| { + p.push(0); + (size + 1, p) + }), + ]; + for (label, lie) in lies { + let error = fetch_content(&manifest, u64::MAX, |message| { + let (message_id, found, chunk_hash, honest_size, payload, error) = honest(&message, &store); + let (size_bytes, payload) = lie(honest_size, payload); + Ok(CultNetMessage::ContentChunkResponse { message_id, found, chunk_hash, size_bytes, payload, error }) + }) + .expect_err(label); + assert!(error.to_string().contains("payload metadata does not match"), "{label}: {error}"); + } +} + +// F6: `[5@0, 0@0]` is not contiguous under the reference's stable sort by offset alone. +#[test] +fn equal_offsets_keep_their_manifest_order() { + let hash = "0".repeat(64); + let five_then_zero = manifest_of(5, vec![chunk_ref(&hash, 0, 5), chunk_ref(&hash, 0, 0)]); + assert!(validate_manifest(&five_then_zero).is_err(), "the stable sort keeps 5@0 first, so 0@0 lands at offset 5"); + let zero_then_five = manifest_of(5, vec![chunk_ref(&hash, 0, 0), chunk_ref(&hash, 0, 5)]); + validate_manifest(&zero_then_five).expect("0@0 first leaves 5@0 at offset 0"); +} + +// F6: what goes on the wire is the normalized hash and the canonical record key. +#[test] +fn the_client_sends_the_normalized_hash_and_canonical_record_key() { + let (mut manifest, chunks) = pack(&body()); + let store = store(&chunks); + let honest_hashes: Vec = manifest.chunks.iter().map(|c| c.chunk_hash.clone()).collect(); + manifest.content_hash = format!("SHA256:{}", manifest.content_hash.to_uppercase()); + for (index, chunk) in manifest.chunks.iter_mut().enumerate() { + chunk.chunk_hash = format!("sha256:{}", chunk.chunk_hash.to_uppercase()); + // Alternate chunks name their key; the rest leave it blank for the client to derive. + chunk.record_key = + if index % 2 == 0 { format!("mesh:cdn:chunk:{}", honest_hashes[index]) } else { " ".to_string() }; + } + let mut sent = Vec::new(); + let fetched = fetch_content(&manifest, u64::MAX, |message| { + sent.push(message.clone()); + Ok(answer_content_chunk_request(&message, lookup_in(&store))) + }) + .expect("fetches"); + assert_eq!(fetched, body()); + assert_eq!(sent.len(), honest_hashes.len()); + for (message, hash) in sent.iter().zip(&honest_hashes) { + let CultNetMessage::ContentChunkRequest { chunk_hash, record_key, .. } = message else { + panic!("not a request"); + }; + assert_eq!(chunk_hash, hash, "the normalized hash is what is sent"); + assert_eq!(record_key, &format!("mesh:cdn:chunk:{hash}"), "and the canonical key"); + } +} diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index f33b8f83..e72a10aa 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -15,6 +15,11 @@ "require": "./dist/contracts.js", "default": "./dist/contracts.js" }, + "./idunn-runtime": { + "types": "./dist/idunn-runtime-authority.d.ts", + "require": "./dist/idunn-runtime-authority.js", + "default": "./dist/idunn-runtime-authority.js" + }, "./authority": { "types": "./dist/cultmesh-authority.d.ts", "require": "./dist/cultmesh-authority.js", diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts new file mode 100644 index 00000000..3713363d --- /dev/null +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -0,0 +1,244 @@ +import dgram from "node:dgram"; +import { decode, encode } from "@msgpack/msgpack"; + +import { encodeCultNetMessageForWire, parseCultNetMessage } from "./contracts"; +import type { IdunnRuntimeSigner } from "./idunn-runtime-authority"; +import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; +import type { CultNetRudpPacket } from "./rudp"; + +const ODIN_CAPABILITY = "odin.verse-rendezvous"; +const MAX_LEASE_WAIT_ROUNDS = 720; + +export type IdunnRuntimePresencePublisher = { + readonly runtimeId: string; + readonly runtimeInstanceId: string; + readonly requiresWriteLease: boolean; + /** Publishes the signer's presence in the health the signer reports. */ + publish(detail: string): Promise; + /** + * Publishes warming presence until Idunn's lease names one. Call it before + * `reportHealth('active')`: once the signer reports a non-warming health, + * `publish` inside this loop throws (it needs the lease) instead of polling. + */ + waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; + assertWriteLease(): Promise; +}; + +export type IdunnRuntimePresencePublisherOptions = { + signer: IdunnRuntimeSigner; + endpoint: string; +}; + +/** + * Publishes the signer's presence to Odin over RUDP, for services that are + * CultMesh-aware and declare `odin.verse-rendezvous` in Expected. The route + * proof never passes through here: `signer.answerRouteObservation` answers + * Idunn's challenge locally and needs no Odin. Capability shortfalls are not + * judged here either; the correlation owner records a typed disagreement. + */ +export function createIdunnRuntimePresencePublisher( + options: IdunnRuntimePresencePublisherOptions, +): IdunnRuntimePresencePublisher { + const { signer } = options; + const endpoint = parseRudpEndpoint(options.endpoint); + const odinDependency = signer.authority.expected.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" + && dependency.capability === ODIN_CAPABILITY); + if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(options.endpoint)) { + throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); + } + const connectionId = 0x0d1d0002; + let messageSequence = 0; + + const publish = async (detail: string) => { + const signed = signer.sign(detail); + messageSequence += 1; + await publishDocument(endpoint, connectionId, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, + document: signed.document, + }); + return signed.canonicalSha256; + }; + + return { + runtimeId: signer.runtimeId, + runtimeInstanceId: signer.runtimeInstanceId, + requiresWriteLease: signer.requiresWriteLease, + publish, + async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { + if (!signer.requiresWriteLease) return ""; + let warmingSequence = 0; + while (!signal?.aborted) { + await publish("waiting-for-process-write-lease"); + warmingSequence += 1; + try { + return signer.assertWriteLease(); + } catch (error) { + if (warmingSequence > MAX_LEASE_WAIT_ROUNDS) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); + } + await abortableDelay(pollIntervalMs, signal); + } + throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + }, + async assertWriteLease() { + return signer.assertWriteLease(); + }, + }; +} + +async function publishDocument( + endpoint: { host: string; port: number }, + connectionId: number, + message: Record, +): Promise { + const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { + socket.off("error", reject); + resolve(); + }); + }); + const receiver = receivePackets(socket); + const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); + try { + await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); + await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); + const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); + const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); + for (const packet of packets) await sendPacket(socket, endpoint, packet); + await receiveUntil( + receiver, + session, + endpoint, + () => session.outstandingReliablePacketCount === 0, + 2000, + (frame) => { + if (frame.channelId !== "schema") return; + const response = parseCultNetMessage(decode(frame.payload)); + if (response.schemaVersion === "cultnet.error.v0") { + throw new Error(`Odin rejected runtime presence: ${response.error}`); + } + }, + ); + } finally { + receiver.close(); + socket.close(); + } +} + +type PacketReceiver = { + socket: dgram.Socket; + next(timeoutMs: number): Promise; + close(): void; +}; + +function receivePackets(socket: dgram.Socket): PacketReceiver { + const packets: CultNetRudpPacket[] = []; + const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; + const errors: Error[] = []; + const drain = () => { + while (waiters.length && (packets.length || errors.length)) { + const waiter = waiters.shift()!; + clearTimeout(waiter.timer); + if (errors.length) waiter.reject(errors.shift()!); + else waiter.resolve(packets.shift()!); + } + }; + const onMessage = (wire: Buffer) => { + try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } + drain(); + }; + const onError = (error: Error) => { errors.push(error); drain(); }; + socket.on("message", onMessage); + socket.on("error", onError); + return { + socket, + next(timeoutMs) { + if (packets.length) return Promise.resolve(packets.shift()!); + if (errors.length) return Promise.reject(errors.shift()!); + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timer: setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; + error.code = "ETIMEDOUT"; + reject(error); + }, Math.max(1, timeoutMs)), + }; + waiters.push(waiter); + }); + }, + close() { + socket.off("message", onMessage); + socket.off("error", onError); + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error("CultNet RUDP publisher closed.")); + } + }, + }; +} + +async function receiveUntil( + receiver: PacketReceiver, + session: CultNetRudpSession, + endpoint: { host: string; port: number }, + predicate: (packet: CultNetRudpPacket) => boolean, + timeoutMs: number, + onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const packet = await receiver.next(Math.min(100, deadline - Date.now())); + const received = session.receive(packet, Date.now()); + if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); + for (const frame of received.delivered) onDelivered?.(frame); + if (predicate(packet)) return; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; + } + for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); + } + throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); +} + +async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { + const wire = encodeRudpPacket(packet); + await new Promise((resolve, reject) => { + socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); + }); +} + +function parseRudpEndpoint(value: string): { host: string; port: number } { + const text = value.replace(/^rudp:\/\//i, ""); + const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); + if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); + const port = Number(match[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); + return { host: match[1]!, port }; +} + +function normalizeRudpEndpoint(value: string): string { + const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); + const parsed = parseRudpEndpoint(normalized); + return `${parsed.host.toLowerCase()}:${parsed.port}`; +} + +function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("Aborted.")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index 5ea30fff..7f7b882f 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -15,10 +15,7 @@ import { type RuntimePresenceHealth, } from "./runtime-presence-health"; import { truncateUtf8Bytes } from "./utf8-bound"; -import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetRawDocumentRecord, type CultNetSnapshotRequestMessage } from "./contracts"; -import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; -import type { CultNetRudpPacket } from "./rudp"; -import dgram from "node:dgram"; +import type { CultNetRawDocumentRecord, CultNetSnapshotRequestMessage } from "./contracts"; export const IDUNN_EXPECTED_INCARNATION_SCHEMA = "idunn.expected_incarnation.v2"; export const IDUNN_RUNTIME_ACTIVATION_SCHEMA = "idunn.runtime_activation.v2"; @@ -32,7 +29,10 @@ const PROVIDER_PROTECTOR_CONTEXT = "gamecult-provider-health-identity-v1"; const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); const CULTCACHE_STORE_FORMAT = "cultcache.store.v1"; const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); +// Limit: more than this many warmings signed between Idunn's observation and +// assertWriteLease would push the leased one out and refuse a valid lease. const MAX_RECENT_WARMING_PROOFS = 64; +const authoritiesWithSigner = new WeakSet(); const authorityKeys = new WeakMap(); type ExpectedIncarnation = { @@ -54,7 +54,6 @@ type ExpectedIncarnation = { route: { routeId: string; transport: string; stableEndpoint: string; candidateEndpoint: string } | null; capabilities: Array<{ capability: string; schema: string; compatibility: string; minimumCapacity: number }>; dependencies: Array<{ kind: string; capability: string; schema: string; compatibility: string; providerEndpoint: string | null }>; - canonicalBytes: Buffer; canonicalSha256: string; }; @@ -64,46 +63,77 @@ type RuntimeActivation = { runtimeId: string; runtimeInstanceId: string; activationSignerIdentityId: string; - activationSignerPublicKey: Buffer; + activationSignerPublicKeyHex: string; canonicalSha256: string; }; -export type IdunnRuntimeAuthority = { +type DeepReadonly = T extends readonly (infer U)[] + ? readonly DeepReadonly[] + : T extends object ? { readonly [K in keyof T]: DeepReadonly } : T; + +/** + * What Idunn admitted for this launch. Deeply frozen at load: bind, health + * contract, Expected, Activation and identities are read-only for the life of + * the process, so no caller can steer what the signer signs. + */ +export type IdunnRuntimeAuthority = DeepReadonly<{ expected: ExpectedIncarnation; activation: RuntimeActivation; providerSignerIdentityId: string; activationSignerIdentityId: string; boundEndpoint: string; processWriteLeasePath?: string; +}>; + +export type SignedRuntimePresence = { + document: CultNetRawDocumentRecord; + canonicalSha256: string; }; -export type IdunnRuntimePresencePublisher = { +/** + * Signs this launch's runtime presence and answers Idunn's route challenge. It + * owns no network: Odin publication, if a service wants it, is a separate + * consumer of `sign`. The signer is the single owner of the reported health: + * it starts `warming`, the app moves it with `reportHealth`, and every + * presence it signs, however it is carried, states that health. + */ +export type IdunnRuntimeSigner = { + readonly authority: IdunnRuntimeAuthority; readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - latestPresenceDocument(): CultNetRawDocumentRecord | null; - publishRouteObservation(request: CultNetSnapshotRequestMessage): Promise; - publish(state: RuntimePresenceHealth["state"], detail: string): Promise; - waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; - assertWriteLease(): Promise; + /** Records the app's health. Until the first call the signer reports `warming`. */ + reportHealth(state: RuntimePresenceHealth["state"]): void; + /** + * Signs one presence document in the reported health. Every call takes the + * next publisher sequence; every warming presence signed becomes a valid + * write-lease target. + */ + sign(detail: string): SignedRuntimePresence; + /** + * Answers an Idunn route challenge from local authority alone: signs a fresh + * presence in the reported health, with no network. + */ + answerRouteObservation(request: CultNetSnapshotRequestMessage): CultNetRawDocumentRecord; + /** Reads Idunn's write lease and checks it names this incarnation and a warming presence this signer signed. */ + assertWriteLease(): string; }; -export type IdunnRuntimePresencePublisherOptions = { +export type IdunnRuntimeSignerOptions = { authority: IdunnRuntimeAuthority; - endpoint: string; - healthContract: string; capabilities: RuntimePresenceHealth["capabilities"]; }; /** * Loads the root-provisioned Idunn bundle and parent-only signing descriptors. * The expected record and activation witness are accepted only when their - * exact positional contracts and mutual incarnation binding agree. + * exact positional contracts and mutual incarnation binding agree. Nothing + * here reads Odin: whether a target talks to Odin is decided by the + * publisher a service chooses to create. */ export function loadIdunnRuntimeAuthorityFromEnvironment( target: string, healthContract: string, - odinEndpoint: string, ): IdunnRuntimeAuthority { const bundle = requiredEnvironment(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT); const expected = readAuthorityRecord(path.join(bundle, "expected.cc"), "idunn.expected_incarnation", IDUNN_EXPECTED_INCARNATION_SCHEMA); @@ -113,11 +143,6 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( if (expectedValue.target !== target || expectedValue.healthContract !== healthContract) { throw new Error("Idunn Expected target or health contract does not match this runtime."); } - const odinDependency = expectedValue.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" - && dependency.capability === "odin.verse-rendezvous"); - if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(odinEndpoint)) { - throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); - } if (activationValue.expectedProjectionSha256 !== expectedValue.canonicalSha256 || activationValue.runtimeId !== expectedValue.runtimeId) { throw new Error("Idunn Activation does not bind this exact Expected incarnation."); @@ -138,7 +163,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( const activationPublicKey = rawEd25519PublicKey(crypto.createPublicKey(activationPrivateKey)); const activationSignerIdentityId = sha256Hex(Buffer.concat([ACTIVATION_ID_DOMAIN, activationPublicKey])); if (activationSignerIdentityId !== activationValue.activationSignerIdentityId - || !activationPublicKey.equals(activationValue.activationSignerPublicKey)) { + || activationPublicKey.toString("hex") !== activationValue.activationSignerPublicKeyHex) { throw new Error("Idunn activation credential does not match the root-provisioned Activation witness."); } @@ -153,7 +178,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( throw new Error("Provider identity does not match Idunn Expected."); } - const authority: IdunnRuntimeAuthority = { + const authority: IdunnRuntimeAuthority = deepFreeze({ expected: expectedValue, activation: activationValue, providerSignerIdentityId: expectedValue.expectedSignerIdentityId, @@ -162,16 +187,17 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( ...(process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] ? { processWriteLeasePath: process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] } : {}), - }; + }); authorityKeys.set(authority, { providerPrivateKey, activationPrivateKey }); return authority; } + /** * Signs the fixed Idunn v2 positional runtime-presence contract with the * provider identity and this launch's Idunn-issued activation key. */ -export function signIdunnRuntimePresence( +function signIdunnRuntimePresence( authority: IdunnRuntimeAuthority, fields: Omit, ): { presence: RuntimePresenceHealth; payload: Uint8Array; canonicalSha256: string } { @@ -205,42 +231,35 @@ export function signIdunnRuntimePresence( }; } -export function createIdunnRuntimePresencePublisher( - options: IdunnRuntimePresencePublisherOptions, -): IdunnRuntimePresencePublisher { - const endpoint = parseRudpEndpoint(options.endpoint); - const expected = options.authority.expected; - if (options.healthContract !== expected.healthContract) throw new Error("Runtime presence contract differs from Expected."); - if (options.authority.boundEndpoint !== expected.route?.candidateEndpoint) throw new Error("Runtime presence bind differs from Expected."); - for (const required of expected.capabilities) { - if (!options.capabilities.some((actual) => actual.capability === required.capability - && actual.schema === required.schema - && actual.compatibility === required.compatibility - && actual.capacity >= required.minimumCapacity)) { - throw new Error(`Runtime does not provide Expected capability ${required.capability}/${required.schema}.`); - } - } - const connectionId = 0x0d1d0002; +/** + * One signer per loaded authority: the publisher sequence and the warming proofs + * are the signer's, and a second signer would restart the sequence under the + * same runtimeInstanceId. + */ +export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): IdunnRuntimeSigner { + const { authority } = options; + if (authoritiesWithSigner.has(authority)) throw new Error("This Idunn runtime authority already has a signer."); + authoritiesWithSigner.add(authority); + const expected = authority.expected; let sequence = 0; - let latestLeaseSha256: string | null = null; - let latestPresenceDocument: CultNetRawDocumentRecord | null = null; + let health: RuntimePresenceHealth["state"] = "warming"; const recentWarmingProofs: string[] = []; - const assertWriteLease = async () => { + const assertWriteLease = () => { if (!expected.writeLeaseRequired) return ""; - const leasePath = options.authority.processWriteLeasePath; + const leasePath = authority.processWriteLeasePath; if (!leasePath) throw new Error("Idunn process write lease is required but no lease path is configured."); const record = readAuthorityRecord(leasePath, "idunn.process_write_lease", IDUNN_PROCESS_WRITE_LEASE_SCHEMA); const lease = decodeArray(record.payload, 14, "Idunn process write lease"); - assertLeaseMatches(lease, options.authority, recentWarmingProofs); - latestLeaseSha256 = prefixedSha256(Buffer.from(encode(lease))); - return latestLeaseSha256; + assertLeaseMatches(lease, authority, recentWarmingProofs); + return prefixedSha256(Buffer.from(encode(lease))); }; - const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { - const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : await assertWriteLease(); + const sign = (detail: string): SignedRuntimePresence => { + const state = health; + const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : assertWriteLease(); sequence += 1; - const signed = signIdunnRuntimePresence(options.authority, { + const signed = signIdunnRuntimePresence(authority, { capabilities: options.capabilities, state, detail: truncateUtf8Bytes(detail, RUNTIME_PRESENCE_DETAIL_MAX_BYTES), @@ -248,44 +267,35 @@ export function createIdunnRuntimePresencePublisher( publisherSequence: sequence, observedAtUnixMillis: Date.now(), }); - const document: CultNetRawDocumentRecord = { - schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, - recordKey: expected.target, - storedAt: new Date().toISOString(), - payloadEncoding: "messagepack", - payload: new Uint8Array(signed.payload), - sourceRuntimeId: expected.runtimeId, - sourceRole: "runtime-presence-health-publisher", - tags: ["cultnet.transport.rudp.v0", "runtime-presence"], - }; - await publishDocument(endpoint, connectionId, { - schemaVersion: "cultnet.document_put_raw.v0", - messageId: `runtime-presence:${expected.target}:${options.authority.activation.runtimeInstanceId}:${sequence}`, - document, - }); - latestPresenceDocument = document; if (state === "warming") { recentWarmingProofs.push(signed.canonicalSha256); if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); } - return signed.canonicalSha256; - }; - - const copyLatestPresenceDocument = (): CultNetRawDocumentRecord | null => { - if (!latestPresenceDocument) return null; return { - ...latestPresenceDocument, - payload: new Uint8Array(latestPresenceDocument.payload), - ...(latestPresenceDocument.tags ? { tags: [...latestPresenceDocument.tags] } : {}), + canonicalSha256: signed.canonicalSha256, + document: { + schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, + recordKey: expected.target, + storedAt: new Date().toISOString(), + payloadEncoding: "messagepack", + payload: new Uint8Array(signed.payload), + sourceRuntimeId: expected.runtimeId, + sourceRole: "runtime-presence-health-publisher", + tags: ["cultnet.transport.rudp.v0", "runtime-presence"], + }, }; }; return { + authority, runtimeId: expected.runtimeId, - runtimeInstanceId: options.authority.activation.runtimeInstanceId, + runtimeInstanceId: authority.activation.runtimeInstanceId, requiresWriteLease: expected.writeLeaseRequired, - latestPresenceDocument: copyLatestPresenceDocument, - async publishRouteObservation(request) { + reportHealth(state) { + health = state; + }, + sign, + answerRouteObservation(request) { if (!request.messageId || request.messageId.trim() !== request.messageId) throw new Error("Idunn route observation requires a message id."); if ((request.schemaIds && !request.schemaIds.includes(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) || (request.recordKeys && !request.recordKeys.includes(expected.target)) @@ -293,157 +303,18 @@ export function createIdunnRuntimePresencePublisher( || request.shardEpoch !== undefined) { throw new Error("Idunn route observation does not request this runtime presence document."); } - await publish("active", `route-observation:${request.messageId}`); - const document = copyLatestPresenceDocument(); - if (!document) throw new Error("Idunn route observation was published without a presence document."); - return document; - }, - publish, - async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { - if (!expected.writeLeaseRequired) return ""; - let warmingSequence = 0; - while (!signal?.aborted) { - await publish("warming", "waiting-for-process-write-lease"); - warmingSequence += 1; - try { - return await assertWriteLease(); - } catch (error) { - if (warmingSequence > 720) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); - } - await abortableDelay(pollIntervalMs, signal); - } - throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + return sign(`route-observation:${request.messageId}`).document; }, assertWriteLease, }; } -async function publishDocument( - endpoint: { host: string; port: number }, - connectionId: number, - message: Record, -): Promise { - const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); - await new Promise((resolve, reject) => { - socket.once("error", reject); - socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { - socket.off("error", reject); - resolve(); - }); - }); - const receiver = receivePackets(socket); - const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); - try { - await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); - await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); - const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); - const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); - for (const packet of packets) await sendPacket(socket, endpoint, packet); - await receiveUntil( - receiver, - session, - endpoint, - () => session.outstandingReliablePacketCount === 0, - 2000, - (frame) => { - if (frame.channelId !== "schema") return; - const response = parseCultNetMessage(decode(frame.payload)); - if (response.schemaVersion === "cultnet.error.v0") { - throw new Error(`Odin rejected runtime presence: ${response.error}`); - } - }, - ); - } finally { - receiver.close(); - socket.close(); +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); } -} - -type PacketReceiver = { - socket: dgram.Socket; - next(timeoutMs: number): Promise; - close(): void; -}; - -function receivePackets(socket: dgram.Socket): PacketReceiver { - const packets: CultNetRudpPacket[] = []; - const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; - const errors: Error[] = []; - const drain = () => { - while (waiters.length && (packets.length || errors.length)) { - const waiter = waiters.shift()!; - clearTimeout(waiter.timer); - if (errors.length) waiter.reject(errors.shift()!); - else waiter.resolve(packets.shift()!); - } - }; - const onMessage = (wire: Buffer) => { - try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } - drain(); - }; - const onError = (error: Error) => { errors.push(error); drain(); }; - socket.on("message", onMessage); - socket.on("error", onError); - return { - socket, - next(timeoutMs) { - if (packets.length) return Promise.resolve(packets.shift()!); - if (errors.length) return Promise.reject(errors.shift()!); - return new Promise((resolve, reject) => { - const waiter = { - resolve, - reject, - timer: setTimeout(() => { - const index = waiters.indexOf(waiter); - if (index >= 0) waiters.splice(index, 1); - const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; - error.code = "ETIMEDOUT"; - reject(error); - }, Math.max(1, timeoutMs)), - }; - waiters.push(waiter); - }); - }, - close() { - socket.off("message", onMessage); - socket.off("error", onError); - for (const waiter of waiters.splice(0)) { - clearTimeout(waiter.timer); - waiter.reject(new Error("CultNet RUDP publisher closed.")); - } - }, - }; -} - -async function receiveUntil( - receiver: PacketReceiver, - session: CultNetRudpSession, - endpoint: { host: string; port: number }, - predicate: (packet: CultNetRudpPacket) => boolean, - timeoutMs: number, - onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const packet = await receiver.next(Math.min(100, deadline - Date.now())); - const received = session.receive(packet, Date.now()); - if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); - for (const frame of received.delivered) onDelivered?.(frame); - if (predicate(packet)) return; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; - } - for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); - } - throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); -} - -async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { - const wire = encodeRudpPacket(packet); - await new Promise((resolve, reject) => { - socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); - }); + return value; } function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } { @@ -495,9 +366,7 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { if (!/^sha256-[0-9a-f]{64}$/u.test(hash)) throw new Error(`Expected ${name} hash is malformed.`); } if (!/^[0-9a-f]{40}$/u.test(sourceRevision)) throw new Error("Expected source revision is malformed."); - if (!route || route.transport !== "http" || !route.candidateEndpoint.startsWith("http://")) { - throw new Error("Idunn Expected must include an HTTP candidate route."); - } + if (route) validateRoute(route); return { schemaVersion: string(values[0], "Expected schema"), target, @@ -517,7 +386,6 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { route, capabilities: decodeExpectedCapabilities(values[16]), dependencies: decodeExpectedDependencies(values[17]), - canonicalBytes, canonicalSha256: prefixedSha256(canonicalBytes), }; } @@ -545,7 +413,7 @@ function decodeActivation(payload: Uint8Array): RuntimeActivation { runtimeId: string(values[2], "Activation runtime id"), runtimeInstanceId: string(values[3], "Activation instance id"), activationSignerIdentityId, - activationSignerPublicKey: Buffer.from(publicKey), + activationSignerPublicKeyHex: Buffer.from(publicKey).toString("hex"), canonicalSha256: prefixedSha256(canonicalBytes), }; } @@ -575,6 +443,24 @@ function decodeExpectedDependencies(value: unknown): ExpectedIncarnation["depend }); } +// The Rust owner (IdunnExpectedRoute::validate) accepts http, tcp and rudp +// routes; a runtime is not entitled to a narrower admission than Idunn's. +const ROUTE_SCHEMES: Record = { + http: { stable: ["http://", "https://"], candidate: ["http://"] }, + tcp: { stable: ["tcp://"], candidate: ["tcp://"] }, + rudp: { stable: ["rudp://"], candidate: ["rudp://"] }, +}; + +function validateRoute(route: NonNullable): void { + const schemes = ROUTE_SCHEMES[route.transport]; + if (!schemes) throw new Error("Idunn Expected route transport is unsupported."); + if (!schemes.stable.some((scheme) => route.stableEndpoint.startsWith(scheme)) + || !schemes.candidate.some((scheme) => route.candidateEndpoint.startsWith(scheme))) { + throw new Error(`Idunn Expected ${route.transport} route endpoints use an unsupported scheme.`); + } + if (route.stableEndpoint === route.candidateEndpoint) throw new Error("Idunn Expected stable and candidate route endpoints are identical."); +} + function decodeRoute(value: unknown): ExpectedIncarnation["route"] { if (value === null) return null; const route = array(value, 4, "Expected route"); @@ -595,7 +481,7 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { if (enrollmentNonce.length !== 32) throw new Error("Provider-health enrollment nonce has an invalid length."); const publicKey = Buffer.from(providerIdentityByteVector(publicKeyValue, "Provider-health public key")); const protectedSeed = Buffer.from(providerIdentityByteVector(protectedSeedValue, "Provider-health protected seed")); - const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim(); + const machineId = readMachineId(); if (!machineId) throw new Error("Linux machine-id is unavailable for provider-health identity."); const expectedBinding = `${PROVIDER_PROTECTOR_CONTEXT}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; if (protectorKind !== "linux_file_mode_machine_id_binding" || binding !== expectedBinding || assurance !== "os_installation_file_bound_cloneable_baseline") { @@ -616,6 +502,15 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { return key; } +// Same sources, same order as the Rust identity protector. +function readMachineId(): string { + try { + return fs.readFileSync("/etc/machine-id", "utf8").trim(); + } catch { + return fs.readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); + } +} + function readProviderHealthIdentityCredential(bytes: Buffer): Uint8Array { const decoded = decode(bytes); if (!Array.isArray(decoded) || decoded.length !== 1) { @@ -693,40 +588,12 @@ function assertLeaseMatches(values: unknown[], authority: IdunnRuntimeAuthority, if (!matches) throw new Error("Idunn process write lease does not match the current Expected incarnation."); } -function parseRudpEndpoint(value: string): { host: string; port: number } { - const text = value.replace(/^rudp:\/\//i, ""); - const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); - if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); - const port = Number(match[2]); - if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); - return { host: match[1]!, port }; -} - -function normalizeRudpEndpoint(value: string): string { - const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); - const parsed = parseRudpEndpoint(normalized); - return `${parsed.host.toLowerCase()}:${parsed.port}`; -} - function requiredEnvironment(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`${name} is required for Idunn runtime presence.`); return value; } -function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, milliseconds); - const onAbort = () => { - clearTimeout(timer); - reject(signal?.reason ?? new Error("Aborted.")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} function array(value: unknown, length: number, label: string): unknown[] { if (!Array.isArray(value) || value.length !== length) throw new Error(`${label} is not a ${length}-field positional contract.`); diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 3db890ef..8950f638 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -205,9 +205,15 @@ export { IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, IDUNN_RUNTIME_ACTIVATION_SCHEMA, - createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, type IdunnRuntimeAuthority, + type IdunnRuntimeSigner, + type IdunnRuntimeSignerOptions, + type SignedRuntimePresence, +} from "./idunn-runtime-authority"; +export { + createIdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisherOptions, -} from "./idunn-runtime-authority"; +} from "./idunn-odin-presence-publisher"; diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes new file mode 100644 index 00000000..fa1385d9 --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md new file mode 100644 index 00000000..7856c78d --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -0,0 +1,42 @@ +# Idunn runtime fixtures + +Written by the Rust owner, not by the TypeScript tests. Regenerate with +`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `9ba6ac4`; it uses +cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's). +It needs Linux and a machine-id equal to the `machine-id` file here, because the +provider identity is bound to it. On Yggdrasil: + + DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' + +then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by +`authenticate_runtime_presence_claim` and correlated by +`correlate_runtime_presence_claim` before being written; the +`below-minimum` vector correlates to exactly `expected-capability-000-capacity`. +Ed25519 is deterministic, so the TypeScript signer must reproduce them byte for byte. + +`service-stateful` is the lease-bound shape (`streampixels-service`, state lineage set, +`write_lease_required: true`). Rust issues `lease.cc`, which names the digest of its +`presence-warming.bin`; `presence-active.bin` and `presence-degraded.bin` are sequence 2 and +carry that lease's digest. `lease-other-warming.cc` names a different warming digest and +`lease-other-target.cc` names another target; the TypeScript lease check must refuse both. + +## Generating a consumer's own set + +The generator takes its specification as input, so a consumer needs no patch to CultLib. The +specification is command-line flags; `@` splices in a response file (one `--flag value` +per line, `#` comments). CultLib's own sets are `fixture.args` here: + + cargo run -q --example idunn_runtime_fixture /tmp/out @/fixture.args + +The consumer keeps its own `.args` file in its repository and runs the same command with the +same machine-id bind mount as above. Global flags: `--source-repository`, `--detail`. Each +`--set ` opens a set written to `/`, and the flags after it configure it: +`--target`, `--transport`, `--stable`, `--candidate` (required); `--capability` (default +`.api`), `--schema` (default `.v1`), `--health-contract` (default +`.runtime-health`), `--minimum-capacity` (default 1), `--stateful` with +`--state-schema-generation` and `--lease-other-target`, and repeatable +`--dependency kind=..,capability=..,schema=..,provider=..,endpoint=..` (optional `compatibility`, +`capacity`, `startup`, `authority`, `projection`). The full grammar is the header of the example. + +Every generation enrolls fresh keys, so regenerated bytes differ from the committed ones. The +committed set stays; `fixture.args` regenerates the same file list, sizes and structure. diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args b/packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args new file mode 100644 index 00000000..04dc36aa --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/fixture.args @@ -0,0 +1,35 @@ +# CultLib's own fixture sets, in the generator's response-file form: +# idunn_runtime_fixture @fixture.args +--source-repository github.com/GameCult/StreamPixels +--detail route-observation:route-challenge-1 + +--set web +--target streampixels-web +--transport http +--stable http://127.0.0.1:8830 +--candidate http://127.0.0.1:18830 + +--set service +--target streampixels-service +--transport tcp +--stable tcp://127.0.0.1:8831 +--candidate tcp://127.0.0.1:18831 +--minimum-capacity 2 +--dependency kind=shared-infrastructure,capability=odin.verse-rendezvous,schema=odin.verse-topology.v1,provider=odin,endpoint=rudp://127.0.0.1:47871 + +--set service-stateful +--target streampixels-service +--transport tcp +--stable tcp://127.0.0.1:8833 +--candidate tcp://127.0.0.1:18833 +--minimum-capacity 2 +--stateful +--state-schema-generation streampixels-state-v1 +--lease-other-target streampixels-web +--dependency kind=shared-infrastructure,capability=odin.verse-rendezvous,schema=odin.verse-topology.v1,provider=odin,endpoint=rudp://127.0.0.1:47871 + +--set rudp-route +--target streampixels-rudp +--transport rudp +--stable rudp://127.0.0.1:8832 +--candidate rudp://127.0.0.1:18832 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id new file mode 100644 index 00000000..a71716fc --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id @@ -0,0 +1 @@ +c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1 \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed new file mode 100644 index 00000000..b0f0159c --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -0,0 +1 @@ +¢_Î#Ÿ/ "Q0\ûiCâ]\Ê�¦‚†ó;ÛY¨ø„k \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc new file mode 100644 index 00000000..57e2c860 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc new file mode 100644 index 00000000..878c090b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin new file mode 100644 index 00000000..084817c3 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin new file mode 100644 index 00000000..246e99cb Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential new file mode 100644 index 00000000..d8026bb4 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed new file mode 100644 index 00000000..491dbddb --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed @@ -0,0 +1 @@ +KýaŸz´-2‰Ë¡V>)ë»xìºÚÏ®¢T =/‰* \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc new file mode 100644 index 00000000..3d670ab6 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc new file mode 100644 index 00000000..8e96b29b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc new file mode 100644 index 00000000..9f99abd0 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc new file mode 100644 index 00000000..cc21e54c Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc new file mode 100644 index 00000000..b387f6e6 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin new file mode 100644 index 00000000..b1c33139 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin new file mode 100644 index 00000000..a3abdf3a Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin new file mode 100644 index 00000000..9634ec21 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential new file mode 100644 index 00000000..3429906f Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed new file mode 100644 index 00000000..40f59dbb --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed @@ -0,0 +1 @@ +ö�„Bf®Õ=?ÞÀ�Z¹jÇâÏ&ŠÜæ3¦õÜŸf…mJ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc new file mode 100644 index 00000000..3fa6a1f3 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc new file mode 100644 index 00000000..99c564f2 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin new file mode 100644 index 00000000..4cc1442f Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin new file mode 100644 index 00000000..d28b1349 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin new file mode 100644 index 00000000..aa6f563b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential new file mode 100644 index 00000000..ccb9d640 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed new file mode 100644 index 00000000..7b6d37ff --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed @@ -0,0 +1 @@ +¯ÂOd×f¯l*oµ‘ÀFªv‰n�hLûÑpšS´p \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc new file mode 100644 index 00000000..3931baf2 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc new file mode 100644 index 00000000..dbf7153b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin new file mode 100644 index 00000000..37edbf65 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin new file mode 100644 index 00000000..55409b8d Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential new file mode 100644 index 00000000..2e617139 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 7969fde2..b5c9236a 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -1,30 +1,98 @@ -import { mock, test } from "node:test"; +import { test, type TestContext } from "node:test"; import assert from "node:assert/strict"; -import crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; import dgram from "node:dgram"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { decode, encode } from "@msgpack/msgpack"; import { createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, - signIdunnRuntimePresence, - systemdListenPidMatches, -} from "../src/idunn-runtime-authority"; + type IdunnRuntimeAuthority, +} from "../src"; +import { systemdListenPidMatches } from "../src/idunn-runtime-authority"; +import type { CultNetSnapshotRequestMessage } from "../src/contracts"; import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; -import { - runtimePresenceActivationSigningMessage, - runtimePresenceProofPayload, - runtimePresenceProviderSigningMessage, - RUNTIME_PRESENCE_SLOT, -} from "../src/runtime-presence-health"; +import { RUNTIME_PRESENCE_SLOT } from "../src/runtime-presence-health"; -const providerIdentityContext = "gamecult-provider-health-identity-v1"; -const providerIdDomain = Buffer.from("gamecult.provider-health.identity.v1\0", "utf8"); -const activationIdDomain = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); +// Every input below was written by the Rust owner; see fixtures/idunn-runtime/README.md. +// The only values this file pins are the ones the generator also pins. +const FIXTURES = path.join(__dirname, "..", "..", "test", "fixtures", "idunn-runtime"); +const OBSERVED_AT = 1_790_000_000_000; +const CHALLENGE: CultNetSnapshotRequestMessage = { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "route-challenge-1", +}; + +type FixtureSet = "web" | "service" | "rudp-route" | "service-stateful"; + +function fixtureFile(set: FixtureSet, name: string): Buffer { + return fs.readFileSync(path.join(FIXTURES, set, name)); +} + +/** The Expected fields the tests need, read from the Rust-written file rather than re-spelled. */ +function expectedFacts(set: FixtureSet) { + const store = decode(fixtureFile(set, "expected.cc")) as unknown[][][]; + const values = decode(store[2]![0]![3] as Uint8Array) as unknown[]; + const route = values[15] as string[]; + const capabilities = (values[16] as Array<[string, string, string, number]>).map(([capability, schema, compatibility, minimum]) => ({ + capability, schema, compatibility, capacity: minimum, + })); + const dependencies = values[17] as unknown[][]; + return { + target: values[1] as string, + healthContract: values[10] as string, + candidate: route[3]!, + capabilities, + odinEndpoint: dependencies.map((dependency) => dependency[9] as string)[0], + }; +} + +/** Points the process at one Rust-written bundle and its inherited descriptors, as Idunn's systemd unit would. */ +function openAuthority( + context: TestContext, + set: FixtureSet, + options: { machineId?: "etc" | "dbus"; lease?: string } = {}, +): IdunnRuntimeAuthority { + const facts = expectedFacts(set); + const originalEnvironment = { ...process.env }; + context.after(() => { + for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; + Object.assign(process.env, originalEnvironment); + }); + process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = path.join(FIXTURES, set); + process.env.GAMECULT_IDUNN_CANDIDATE_BIND = facts.candidate; + if (options.lease) process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE = path.join(FIXTURES, set, options.lease); + else delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; + process.env.LISTEN_PID = String(process.pid); + process.env.LISTEN_FDS = "2"; + process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; + const machineId = fs.readFileSync(path.join(FIXTURES, "machine-id"), "utf8"); + const realReadFileSync = fs.readFileSync.bind(fs); + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (filePath === 3) return fixtureFile(set, "activation-key.seed"); + if (filePath === 4) return fixtureFile(set, "provider-identity.credential"); + if (filePath === "/etc/machine-id") { + if (options.machineId === "dbus") throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + return machineId; + } + if (filePath === "/var/lib/dbus/machine-id") return machineId; + return realReadFileSync(filePath as never, ...args as never[]); + }); + return loadIdunnRuntimeAuthorityFromEnvironment(facts.target, facts.healthContract); +} + +function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0, lease?: string) { + const authority = openAuthority(context, set, { lease }); + const capabilities = expectedFacts(set).capabilities.map((capability) => ({ ...capability, capacity: capability.capacity + capacityDelta })); + return createIdunnRuntimeSigner({ authority, capabilities }); +} + +function freezeClock(context: TestContext): void { + context.mock.method(Date, "now", () => OBSERVED_AT); +} test("matches systemd's host PID for the private PID namespace init only", () => { assert.equal(systemdListenPidMatches(String(process.pid), process.pid), true); @@ -34,216 +102,290 @@ test("matches systemd's host PID for the private PID namespace init only", () => assert.equal(systemdListenPidMatches(undefined, 1), false); }); -test("opens Idunn identity and publishes valid CultNet data while surfacing piggybacked application errors", async (context) => { - const directory = mkdtempSync(path.join(os.tmpdir(), "cultnet-runtime-authority-")); - context.after(() => rmSync(directory, { recursive: true, force: true })); +for (const set of ["web", "service", "rudp-route"] as const) { + test(`opens the Rust-written ${set} bundle with no Odin endpoint, whatever route transport Rust admits`, (context) => { + const authority = openAuthority(context, set); + assert.equal(authority.expected.target, expectedFacts(set).target); + assert.equal(authority.boundEndpoint, expectedFacts(set).candidate); + }); +} - const machineId = "1234567890abcdef1234567890abcdef"; - const providerSeed = Buffer.alloc(32, 0x31); - const activationSeed = Buffer.alloc(32, 0x72); - const providerKey = privateKeyFromSeed(providerSeed); - const activationKey = privateKeyFromSeed(activationSeed); - const providerPublic = rawPublic(crypto.createPublicKey(providerKey)); - const activationPublic = rawPublic(crypto.createPublicKey(activationKey)); - const providerIdentity = sha256Hex(Buffer.concat([providerIdDomain, providerPublic])); - const activationIdentity = sha256Hex(Buffer.concat([activationIdDomain, activationPublic])); - const target = "streampixels-service"; - const contract = "streampixels-service.cultnet-rudp-service-health"; - const candidate = "http://127.0.0.1:18831"; - const expectedValues = [ - "idunn.expected_incarnation.v2", target, `sha256-${"1".repeat(64)}`, "incarnation-1", - `sha256-${"2".repeat(64)}`, "https://github.com/GameCult/StreamPixels.git", "a".repeat(40), - `sha256-${"3".repeat(64)}`, "streampixels-service-yggdrasil", providerIdentity, contract, - `sha256-${"4".repeat(64)}`, "service-boundary-v1", `sha256-${"5".repeat(64)}`, false, - ["streampixels-service-http", "http", "http://127.0.0.1:8831", candidate], - [["streampixels.service.api", "streampixels.api.v1", "v1", 1]], - [["shared-infrastructure", "odin.verse-rendezvous", "odin.verse-topology.v1", "v1", 1, "before-promotion", null, null, null, "rudp://127.0.0.1:17871"]], +test("a target that declares no Odin dependency opens and answers Idunn's challenge without one", (context) => { + assert.equal(expectedFacts("web").odinEndpoint, undefined); + const signer = signerFor(context, "web"); + assert.ok(signer.answerRouteObservation(CHALLENGE).payload.byteLength > 0); +}); + +test("finds the machine-id at the dbus path when /etc/machine-id is absent, as the Rust protector does", (context) => { + assert.ok(openAuthority(context, "web", { machineId: "dbus" })); +}); + +test("rejects a credential file holding more than one envelope", (context) => { + openAuthority(context, "web"); + const credential = decode(fixtureFile("web", "provider-identity.credential")) as unknown[][]; + const doubled = Buffer.from(encode([...credential, ...credential])); + const realReadFileSync = fs.readFileSync; + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => + filePath === 4 ? doubled : (realReadFileSync as (...all: unknown[]) => unknown)(filePath, ...args)); + assert.throws( + () => loadIdunnRuntimeAuthorityFromEnvironment(expectedFacts("web").target, expectedFacts("web").healthContract), + /exactly one envelope/, + ); +}); + +function presenceSlot(document: { payload: Uint8Array }, slot: number): unknown { + return (decode(document.payload) as unknown[])[slot]; +} + +// Every committed vector is asserted, dead ones included. Stateless sets sign at sequence 1. +for (const set of ["web", "service", "rudp-route"] as const) { + test(`${set}: a fresh signer answers warming, and after reportHealth('active') answers active, byte for byte as the Rust-verified vectors`, (context) => { + freezeClock(context); + assert.deepEqual(Buffer.from(signerFor(context, set).answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-warming.bin")); + const active = signerFor(context, set); + active.reportHealth("active"); + assert.deepEqual(Buffer.from(active.answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-active.bin")); + }); +} + +test("a shortfall signs the vector Rust correlates to the typed capacity disagreement", (context) => { + freezeClock(context); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile("service", "presence-active-below-minimum.bin")); +}); + +test("the signer owns the reported health: a fresh signer is warming, reportHealth moves it, the sequence keeps counting", (context) => { + freezeClock(context); + const signer = signerFor(context, "web"); + const first = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(first, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.sign("detail").document, RUNTIME_PRESENCE_SLOT.state), "warming"); + signer.reportHealth("active"); + const third = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.publisherSequence), 3); +}); + +test("answers locally: synchronously, with no socket opened", (context) => { + const sockets = context.mock.method(dgram, "createSocket"); + const signer = signerFor(context, "web"); + const answer = signer.answerRouteObservation(CHALLENGE); + assert.equal(typeof (answer as unknown as { then?: unknown }).then, "undefined"); + assert.equal(sockets.mock.callCount(), 0); +}); + +test("refuses a challenge that does not ask for this runtime's presence", (context) => { + const signer = signerFor(context, "web"); + const cases: CultNetSnapshotRequestMessage[] = [ + { ...CHALLENGE, messageId: "" }, + { ...CHALLENGE, messageId: " padded" }, + { ...CHALLENGE, recordKeys: ["another-target"] }, + { ...CHALLENGE, schemaIds: ["another.schema"] }, + { ...CHALLENGE, shardId: "shard-1" }, ]; - const expectedBytes = Buffer.from(encode(expectedValues)); - const expectedSha = `sha256-${sha256Hex(expectedBytes)}`; - const activationValues = [ - "idunn.runtime_activation.v2", expectedSha, "streampixels-service-yggdrasil", - `sha256-${"6".repeat(64)}`, activationIdentity, activationPublic, Date.now(), - "7".repeat(64), "ed25519", new Uint8Array(64).fill(0x17), - ]; - const bundle = path.join(directory, "bundle"); - fs.mkdirSync(bundle); - writeFileSync(path.join(bundle, "expected.cc"), cultCacheStore( - "idunn.expected_incarnation", "idunn.expected_incarnation.v2", "expected", expectedBytes, - )); - writeFileSync(path.join(bundle, "activation.cc"), cultCacheStore( - "idunn.runtime_activation", "idunn.runtime_activation.v2", "activation", Buffer.from(encode(activationValues)), - )); - - const binding = `${providerIdentityContext}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; - const mask = crypto.createHash("sha256").update(Buffer.concat([ - Buffer.from("gamecult-linux-service-seed-v1\0", "utf8"), - Buffer.from(providerIdentityContext, "utf8"), - Buffer.from(binding, "utf8"), - ])).digest(); - const protectedSeed = Buffer.from(providerSeed.map((value, index) => value ^ mask[index]!)); - const providerCredential = Buffer.from(encode([[ - "gamecult-provider-health-identity", - "gamecult.provider_health_identity.private.v1", - Buffer.from(encode([ - "gamecult.provider_health_identity.private.v1", providerIdentity, Array.from(providerPublic), Array.from(protectedSeed), - "linux_file_mode_machine_id_binding", binding, "v1", "os_installation_file_bound_cloneable_baseline", - "2026-01-01T00:00:00Z", Array(32).fill(0x2a), - ])), - "2026-01-01T00:00:00Z", - "gamecult.provider_health_identity.private.v1", - ]])); - let descriptorProviderCredential = providerCredential; - - const originalEnvironment = { ...process.env }; - context.after(() => { - for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; - Object.assign(process.env, originalEnvironment); - }); - process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = bundle; - process.env.GAMECULT_IDUNN_CANDIDATE_BIND = candidate; - delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; - process.env.LISTEN_PID = String(process.pid); - process.env.LISTEN_FDS = "2"; - process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; - const realReadFileSync = fs.readFileSync.bind(fs); - mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { - if (filePath === 3) return activationSeed; - if (filePath === 4) return descriptorProviderCredential; - if (filePath === "/etc/machine-id") return machineId; - return realReadFileSync(filePath as never, ...args as never[]); - }); - - const authority = loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"); - const signed = signIdunnRuntimePresence(authority, { - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - state: "warming", - detail: "starting", - writeLeaseSha256: null, - publisherSequence: 1, - observedAtUnixMillis: Date.now(), - }); - const proof = runtimePresenceProofPayload(signed.presence); - assert.equal((decode(expectedBytes) as unknown[]).length, 18); - assert.equal(signed.presence.expectedProjectionSha256, expectedSha); - const signedRecord = decode(signed.payload) as unknown[]; - assert.equal(crypto.verify(null, runtimePresenceProviderSigningMessage(proof), crypto.createPublicKey(providerKey), Buffer.from(signedRecord[21] as Uint8Array)), true); - assert.equal(crypto.verify(null, runtimePresenceActivationSigningMessage(proof), crypto.createPublicKey(activationKey), Buffer.from(signedRecord[23] as Uint8Array)), true); + for (const request of cases) assert.throws(() => signer.answerRouteObservation(request), /route observation/); +}); +async function startOdinPeer(context: TestContext, endpoint: string) { const peer = dgram.createSocket("udp4"); + const port = Number(endpoint.slice(endpoint.lastIndexOf(":") + 1)); await new Promise((resolve, reject) => { peer.once("error", reject); - peer.bind(0, "127.0.0.1", resolve); + peer.bind(port, "127.0.0.1", resolve); }); context.after(() => peer.close()); - const address = peer.address(); - assert.equal(typeof address, "object"); - const peerPort = (address as { port: number }).port; + const state = { + reject: false, + received: [] as Array<{ schemaVersion?: string; document: { payload: Uint8Array } }>, + responseAck: undefined as number | undefined, + failure: undefined as Error | undefined, + }; let serverSession: CultNetRudpSession | undefined; - let receivedMessage: unknown; - let responseAck: number | undefined; - let peerFailure: Error | undefined; - let rejectOdinAdmission = true; peer.on("message", (wire, remote) => { try { const packet = decodeRudpPacket(wire); if (packet.packetType === "connect") { serverSession = new CultNetRudpSession({ connectionId: packet.connectionId, initialSequence: 100 }); - const accept = serverSession.acceptConnect(packet, Date.now()); - peer.send(encodeRudpPacket(accept), remote.port, remote.address); + peer.send(encodeRudpPacket(serverSession.acceptConnect(packet, Date.now())), remote.port, remote.address); return; } if (!serverSession) throw new Error("Publisher sent data before establishing RUDP."); - const result = serverSession.receive(packet, Date.now()); - const frame = result.delivered.find((candidate) => candidate.channelId === "schema"); + const frame = serverSession.receive(packet, Date.now()).delivered.find((candidate) => candidate.channelId === "schema"); if (!frame) return; - receivedMessage = decode(frame.payload); - const responsePayload = encode(rejectOdinAdmission ? { - schemaVersion: "cultnet.error.v0", - error: "test admission denied", - routingHint: null, - code: null, - details: null, - } : { - schemaVersion: "cultnet.snapshot_response.v0", - messageId: "ack", - documents: [], - }); - const [response] = serverSession.sendMany("schema", responsePayload, { + state.received.push(decode(frame.payload) as never); + const [response] = serverSession.sendMany("schema", encode(state.reject + ? { schemaVersion: "cultnet.error.v0", error: "test admission denied", routingHint: null, code: null, details: null } + : { schemaVersion: "cultnet.snapshot_response.v0", messageId: "ack", documents: [] }), { reliable: true, ordered: true, nowMs: Date.now(), }); assert.ok(response); - responseAck = response.ack; + state.responseAck = response.ack; peer.send(encodeRudpPacket(response), remote.port, remote.address); } catch (error) { - peerFailure = error as Error; + state.failure = error as Error; } }); - const publisher = createIdunnRuntimePresencePublisher({ - authority, - endpoint: `rudp://127.0.0.1:${peerPort}`, - healthContract: contract, - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - }); - await assert.rejects(publisher.publish("warming", "local RUDP integration"), /Odin rejected runtime presence: test admission denied/); - assert.deepEqual(receivedMessage && (receivedMessage as { schemaVersion?: string }).schemaVersion, "cultnet.document_put_raw.v0"); - assert.ok(Buffer.from(encode(receivedMessage)).byteLength > 0); - assert.equal(responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); - assert.equal(peerFailure, undefined, peerFailure?.message); - assert.equal(publisher.latestPresenceDocument(), null, "a rejected publication must not become the snapshot source"); + return state; +} - rejectOdinAdmission = false; - await publisher.publish("warming", "accepted presence"); - const snapshotDocument = await publisher.publishRouteObservation({ - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "route-challenge-1", - schemaIds: ["gamecult.runtime_presence_health.v2"], - recordKeys: [target], - }); - const publishedDocument = (receivedMessage as { document: { payload: Uint8Array; schemaId: string; recordKey: string } }).document; - assert.ok(snapshotDocument); - assert.equal(snapshotDocument.schemaId, publishedDocument.schemaId); - assert.equal(snapshotDocument.recordKey, publishedDocument.recordKey); - assert.deepEqual(Buffer.from(snapshotDocument.payload), Buffer.from(publishedDocument.payload)); - const routePresence = decode(snapshotDocument.payload) as unknown[]; - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.state], "active"); - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.detail], "route-observation:route-challenge-1"); - snapshotDocument.payload[0] = snapshotDocument.payload[0]! ^ 0xff; - assert.deepEqual(Buffer.from(publisher.latestPresenceDocument()?.payload ?? []), Buffer.from(publishedDocument.payload), "snapshot readers cannot mutate publisher state"); +test("the Odin publisher publishes the signer's document to the endpoint Expected names and surfaces Odin's rejection", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - descriptorProviderCredential = Buffer.from(encode([ - ...(decode(providerCredential) as unknown[][]), - ...(decode(providerCredential) as unknown[][]), - ])); + await publisher.publish("accepted presence"); + assert.equal(peer.received[0]?.schemaVersion, "cultnet.document_put_raw.v0"); + const published = decode(peer.received[0]!.document.payload) as unknown[]; + assert.equal(published[RUNTIME_PRESENCE_SLOT.state], "warming"); + assert.equal(published[RUNTIME_PRESENCE_SLOT.publisherSequence], 1); + assert.equal(peer.responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); + + peer.reject = true; + signer.reportHealth("active"); + await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); + assert.equal(peer.failure, undefined, peer.failure?.message); +}); + +test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await publisher.publish("below minimum"); + const capabilities = (decode(peer.received[0]!.document.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.capabilities] as unknown[][]; + assert.equal(capabilities[0]![3], expectedFacts("service").capabilities[0]!.capacity - 1); +}); + +test("the Odin publisher takes only the endpoint Expected names", (context) => { assert.throws( - () => loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"), - /exactly one envelope/, + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service"), endpoint: "rudp://127.0.0.1:1" }), + /does not match Idunn Expected dependency authority/, + ); + assert.throws( + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "web"), endpoint: expectedFacts("service").odinEndpoint! }), + /does not match Idunn Expected dependency authority/, ); }); -function cultCacheStore(type: string, schemaId: string, key: string, payload: Uint8Array): Buffer { - return Buffer.from(encode([ - "cultcache.store.v1", - [[schemaId, type, `${schemaId}.v1`, schemaId, "{}", [schemaId], []]], - [[key, schemaId, "2026-01-01T00:00:00Z", payload]], - ])); +test("the route answer and the Odin publish report the same state, because the signer owns it", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + + await publisher.publish("first"); + assert.equal(presenceSlot(peer.received[0]!.document, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "warming"); + + signer.reportHealth("active"); + await publisher.publish("second"); + assert.equal(presenceSlot(peer.received[1]!.document, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "active"); + // One sequence across both carriers. + assert.deepEqual( + [1, 2].map((index) => presenceSlot(peer.received[index - 1]!.document, RUNTIME_PRESENCE_SLOT.publisherSequence)), + [1, 3], + ); +}); + +// The lease-bound set: Rust issued lease.cc naming presence-warming.bin's digest, and presence-active.bin +// and presence-degraded.bin carry that lease's digest. +const STATEFUL = "service-stateful" as const; + +function statefulSigner(context: TestContext, lease = "lease.cc") { + return signerFor(context, STATEFUL, 0, lease); } -function privateKeyFromSeed(seed: Uint8Array): crypto.KeyObject { - return crypto.createPrivateKey({ - key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seed)]), - format: "der", - type: "pkcs8", +test("stateful: warming, active and degraded match the Rust-verified vectors byte for byte, active and degraded bound to Rust's lease", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-warming.bin")); + signer.reportHealth("active"); + const active = signer.answerRouteObservation(CHALLENGE); + assert.deepEqual(Buffer.from(active.payload), fixtureFile(STATEFUL, "presence-active.bin")); + assert.match(String(presenceSlot(active, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)), /^sha256-[0-9a-f]{64}$/); + + const degraded = statefulSigner(context); + degraded.answerRouteObservation(CHALLENGE); + degraded.reportHealth("degraded"); + assert.deepEqual(Buffer.from(degraded.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-degraded.bin")); +}); + +test("a warming answered on the route becomes a lease target: Rust's lease naming it is accepted", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/, "no warming signed yet"); + signer.answerRouteObservation(CHALLENGE); + assert.equal(signer.assertWriteLease(), presenceSlot({ payload: fixtureFile(STATEFUL, "presence-active.bin") }, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)); +}); + +for (const [lease, reason] of [["lease-other-warming.cc", "names another warming digest"], ["lease-other-target.cc", "names another target"]] as const) { + test(`a lease that ${reason} is refused, and the signer cannot sign active under it`, (context) => { + freezeClock(context); + const signer = statefulSigner(context, lease); + signer.answerRouteObservation(CHALLENGE); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/); + signer.reportHealth("active"); + assert.throws(() => signer.answerRouteObservation(CHALLENGE), /does not match the current Expected incarnation/); }); } -function rawPublic(key: crypto.KeyObject): Buffer { - const der = key.export({ type: "spki", format: "der" }) as Buffer; - return Buffer.from(der.subarray(der.length - 32)); +test("one signer per loaded authority", (context) => { + const authority = openAuthority(context, "web"); + const capabilities = expectedFacts("web").capabilities; + createIdunnRuntimeSigner({ authority, capabilities }); + assert.throws(() => createIdunnRuntimeSigner({ authority, capabilities }), /already has a signer/); +}); + +test("the loaded authority is frozen: the bind, contract, Expected, Activation and identities cannot change, and the signer signs the loaded endpoint", (context) => { + freezeClock(context); + const authority = openAuthority(context, "web"); + const loaded = expectedFacts("web"); + const open = authority as unknown as { [key: string]: any }; + const mutations: Array<[string, () => void]> = [ + ["boundEndpoint", () => { open.boundEndpoint = "http://127.0.0.1:1"; }], + ["providerSignerIdentityId", () => { open.providerSignerIdentityId = "forged"; }], + ["activationSignerIdentityId", () => { open.activationSignerIdentityId = "forged"; }], + ["expected.healthContract", () => { open.expected.healthContract = "forged"; }], + ["expected.target", () => { open.expected.target = "forged"; }], + ["expected.canonicalSha256", () => { open.expected.canonicalSha256 = "forged"; }], + ["expected.route.candidateEndpoint", () => { open.expected.route.candidateEndpoint = "http://127.0.0.1:1"; }], + ["expected.capabilities[0].minimumCapacity", () => { open.expected.capabilities[0].minimumCapacity = 0; }], + ["expected.capabilities.push", () => { open.expected.capabilities.push({}); }], + ["activation.runtimeInstanceId", () => { open.activation.runtimeInstanceId = "forged"; }], + ["activation.canonicalSha256", () => { open.activation.canonicalSha256 = "forged"; }], + ]; + for (const [name, mutate] of mutations) assert.throws(mutate, TypeError, name); + const signer = createIdunnRuntimeSigner({ authority, capabilities: loaded.capabilities }); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.boundEndpoint), loaded.candidate); +}); + +// A web app must be able to import the signer without pulling the Odin publisher and dgram. +function loadUnderDgramStub(modulePath: string): string { + const script = [ + 'const Module = require("node:module");', + "const load = Module._load;", + 'Module._load = function (request, ...rest) { if (request === "dgram" || request === "node:dgram") throw new Error("dgram was loaded"); return load.call(this, request, ...rest); };', + "try { require(process.argv[1]); process.stdout.write(\"loaded\"); } catch (error) { process.stdout.write(\"failed: \" + error.message); }", + ].join("\n"); + const run = spawnSync(process.execPath, ["-e", script, modulePath], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + return run.stdout; } -function sha256Hex(bytes: Uint8Array): string { - return crypto.createHash("sha256").update(bytes).digest("hex"); -} +test("the idunn-runtime subpath loads the signer and loader without loading dgram; the package root does load it", () => { + const subpath = require.resolve("cultnet-ts/idunn-runtime"); + assert.equal(loadUnderDgramStub(subpath), "loaded"); + assert.equal(loadUnderDgramStub(require.resolve("cultnet-ts")), "failed: dgram was loaded"); + const exported = require(subpath) as Record; + assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); + assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); + assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); + assert.equal(exported.signIdunnRuntimePresence, undefined); + assert.equal((require("cultnet-ts") as Record).signIdunnRuntimePresence, undefined); +}); diff --git a/src/GameCult.Mesh/docs/content-sessions.md b/src/GameCult.Mesh/docs/content-sessions.md index 313a21cf..673327e2 100644 --- a/src/GameCult.Mesh/docs/content-sessions.md +++ b/src/GameCult.Mesh/docs/content-sessions.md @@ -47,6 +47,14 @@ The content protocol uses one bounded response per manifest chunk. A snapshot response containing `gamecult.mesh.cdn_artifact_chunk.v1` payload records is a legacy transport path and must not be used for bulk delivery. +The Rust runtime speaks the same chunk messages through `cultnet-rs` +`fetch_content`, which verifies every chunk and then the whole body, and +`answer_content_chunk_request`, which mirrors the legacy server's validation +order and error spelling. Its transport is an explicit closure over the session +the caller already holds, carrying the chunks as ordinary schema frames until +Rust has an authenticated content connector; the chunk bytes, manifest shape +and verification do not change when that connector arrives. + The transfer owner may keep a bounded window of requests in flight per content hash (four by default, never more than 32). Candidate chunks can arrive out of order, but only the transfer owner writes them, and it flushes and checkpoints diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md index eddb9794..ea6a6389 100644 --- a/src/GameCult.Mesh/docs/transport-planes.md +++ b/src/GameCult.Mesh/docs/transport-planes.md @@ -102,6 +102,14 @@ lower priority than TCP. Existing hot-state publishers that still package payloads as schema messages must move to the realtime connector contract; the new seam does not pretend that migration has already occurred. +The Rust runtime's content path is this compatibility path, chosen explicitly: +`cultnet-rs` sends chunk requests and reads chunk responses on the session the +client already holds, byte-identical on the wire to +`CultMeshLegacyRudpContentServer` (`fetch_content` and +`answer_content_chunk_request`). It is never an implicit default, and it lasts +until Rust has an authenticated content connector (TCP+TLS or QUIC), which will +sit behind the same `fetch_content` owner rather than beside it. + ## Security and release gate The current TCP control and content connectors are plaintext and therefore diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 86613c52..7e88c283 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -707,6 +707,7 @@ namespace GameCult.Caching.Tests } [Test] + [Platform("Win", Reason = "Holds an open FileShare.Read handle and expects a delete or replace to fail; Linux unlink and rename ignore open handles.")] public async Task DirectoryMessagePackBackingStore_Deletion_Commits_While_Locked_Old_Page_Remains_An_Orphan() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); diff --git a/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs b/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs index 41ef46ad..840110bc 100644 --- a/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs +++ b/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs @@ -37,6 +37,7 @@ namespace GameCult.Caching.Tests } [Test] + [Platform("Win", Reason = "Holds an open FileShare.Read handle and expects a delete or replace to fail; Linux unlink and rename ignore open handles.")] public async Task FailedManifestReplaceKeepsPreviousGeneration() { using var cache = Open(out _); diff --git a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs index fc99f01f..af7e7da7 100644 --- a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs +++ b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs @@ -439,6 +439,7 @@ namespace GameCult.Caching.Tests } [Test] + [Platform("Win", Reason = "Holds an open FileShare.Read handle and expects a delete or replace to fail; Linux unlink and rename ignore open handles.")] public void BatchIsAllOrNothingOnStoreFailure() { var registry = Registry(); diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs new file mode 100644 index 00000000..2fa26aa1 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +/// +/// Content-plane parity vectors shared with packages/cultnet-rs/tests/content.rs. Each runtime +/// writes its own file under contracts/cultmesh and judges both. The chunk answers in these +/// files are produced by itself, so the failure +/// spellings are the reference's own bytes. +/// +[TestFixture] +public sealed class CultMeshContentVectorTests +{ + private const int BodyLength = 1_315_551; + private const int ChunkSize = 262_144; + private const string CreatedAtUtc = "2026-09-22T00:00:00.0000000+00:00"; + private const string ArtifactId = "eureka/content-vector"; + + private sealed record Vector(string Label, string Kind, byte[] Bytes); + + [Test] + public void WriteContentVectors() + { + if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1") + Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file."); + File.WriteAllText(VectorPath("content-vectors.cs-written.json"), ToJson(BuildVectors(out _, out _))); + } + + [Test] + public void CommittedCsVectorsEqualAFreshEncode() + { + var fresh = BuildVectors(out _, out _); + var committed = ReadVectors("content-vectors.cs-written.json"); + committed.Select(v => (v.Label, v.Kind)).Should().Equal(fresh.Select(v => (v.Label, v.Kind))); + foreach (var (expected, actual) in fresh.Zip(committed)) + actual.Bytes.Should().Equal(expected.Bytes, $"vector '{actual.Label}' drifted from the reference's encode"); + } + + [Test] + public void RustVectorsDecodeInTheReference() + { + BuildVectors(out var cache, out var messages); + var rust = ReadVectors("content-vectors.rs-written.json"); + rust.Should().NotBeEmpty(); + rust.Select(v => v.Label).Should().Equal(messages.Keys, "both runtimes write the same labelled vectors"); + foreach (var vector in rust) + { + var expected = messages[vector.Label]; + switch (vector.Kind) + { + case "request": + var request = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + request.Should().BeEquivalentTo((CultMeshContentChunkRequestMessage)expected); + Reencode(request).Should().Equal(vector.Bytes, vector.Label); + break; + case "response": + var response = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + response.Should().BeEquivalentTo((CultMeshContentChunkResponseMessage)expected); + Reencode(response).Should().Equal(vector.Bytes, vector.Label); + break; + case "manifest": + var manifest = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + manifest.Should().BeEquivalentTo((CultMeshCdnArtifactManifest)expected); + Reencode(manifest).Should().Equal(vector.Bytes, vector.Label); + break; + default: + Assert.Fail($"unknown vector kind '{vector.Kind}'"); + break; + } + } + + // Rust's answers to Rust's requests must be what the reference answers to the same bytes. + foreach (var vector in rust.Where(v => v.Label.EndsWith(".request", StringComparison.Ordinal))) + { + var request = MessagePackSerializer.Deserialize(vector.Bytes, CultNetSchemaMessageSerialization.Options); + var rustAnswer = rust.Single(v => v.Label == vector.Label[..^".request".Length] + ".response"); + Reencode(Answer(cache, request)).Should().Equal(rustAnswer.Bytes, vector.Label); + } + } + + [Test] + public void PackArtifactOfTheFixtureBodyEqualsTheFixtureManifest() + { + var artifact = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()); + var committed = ReadVectors("content-vectors.cs-written.json").Single(v => v.Label == "manifest"); + Reencode(artifact.Manifest).Should().Equal(committed.Bytes); + var rust = ReadVectors("content-vectors.rs-written.json").Single(v => v.Label == "manifest"); + rust.Bytes.Should().Equal(committed.Bytes, "Rust's pack_content and the reference pack the same bytes to the same manifest"); + } + + private static byte[] Body() => + Enumerable.Range(0, BodyLength).Select(i => (byte)(i % 251)).ToArray(); + + private static CultMeshCdnPackOptions Options() => new() + { + ChunkSizeBytes = ChunkSize, + Version = "1", + MimeType = "application/x-cultnet-vector", + CreatedAtUtc = CreatedAtUtc + }; + + private static byte[] Reencode(CultMeshContentChunkRequestMessage message) => + CultNetSchemaMessageSerialization.Serialize(message); + + private static byte[] Reencode(CultMeshContentChunkResponseMessage message) => + CultNetSchemaMessageSerialization.Serialize(message); + + private static byte[] Reencode(CultMeshCdnArtifactManifest manifest) => + MessagePackSerializer.Serialize(manifest, CultNetSchemaMessageSerialization.Options); + + private static CultMeshContentChunkRequestMessage Request(string id, string hash, string key, int size) => new() + { + MessageId = id, + ChunkHash = hash, + RecordKey = key, + ExpectedSizeBytes = size + }; + + /// The cs-written file's vectors, in file order. Messages are keyed by label. + private static List BuildVectors(out CultCache cache, out Dictionary messages) + { + var artifact = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()); + var content = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( + typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk))); + CultMeshCdn.PublishAsync(content, artifact).GetAwaiter().GetResult(); + cache = content; + + var last = artifact.Chunks[^1]; + var key = "mesh:cdn:chunk:" + last.ChunkHash; + var lastDigit = last.ChunkHash[^1] == '0' ? '1' : '0'; + var vectors = new List(); + var map = new Dictionary(StringComparer.Ordinal); + messages = map; + + void AddRequest(string label, CultMeshContentChunkRequestMessage message) + { + map[label] = message; + vectors.Add(new Vector(label, "request", Reencode(message))); + } + + void AddResponse(string label, CultMeshContentChunkResponseMessage message) + { + map[label] = message; + vectors.Add(new Vector(label, "response", Reencode(message))); + } + + void AddAnswer(string name, CultMeshContentChunkRequestMessage request) + { + AddRequest(name + ".request", request); + AddResponse(name + ".response", Answer(content, request)); + } + + AddAnswer("answer_found", Request("vector-found", "SHA256:" + last.ChunkHash, key, last.SizeBytes)); + AddAnswer("answer_not_found", Request("vector-missing", new string('0', 64), string.Empty, 100)); + AddAnswer("answer_record_key_disagrees", Request("vector-key", last.ChunkHash, "mesh:cdn:chunk:other", last.SizeBytes)); + AddAnswer("answer_size_mismatch", Request("vector-size", last.ChunkHash, key, last.SizeBytes - 1)); + // Requests the reference refuses in its handler, never at deserialisation: each is answered. + AddAnswer("answer_blank_message_id", Request(string.Empty, last.ChunkHash, key, last.SizeBytes)); + AddAnswer("answer_whitespace_message_id", Request(" ", last.ChunkHash, key, last.SizeBytes)); + AddAnswer("answer_blank_hash", Request("vector-blank-hash", string.Empty, string.Empty, 100)); + AddAnswer("answer_whitespace_hash", Request("vector-space-hash", " ", string.Empty, 100)); + AddAnswer("answer_prefix_only_hash", Request("vector-prefix-hash", "sha256: ", string.Empty, 100)); + AddAnswer("answer_negative_size", Request("vector-negative", last.ChunkHash, key, -1)); + AddAnswer("answer_record_key_case", Request("vector-key-case", last.ChunkHash, key.ToUpperInvariant(), last.SizeBytes)); + AddAnswer("answer_blank_record_key", Request("vector-key-blank", last.ChunkHash, " ", last.SizeBytes)); + AddAnswer("answer_double_prefix", Request("vector-double", "sha256:sha256:" + last.ChunkHash, string.Empty, last.SizeBytes)); + + // A caller that leaves a string null writes msgpack nil; the reference reads it back as null and + // answers. These are judged by Rust decoding the reference's bytes (Rust never writes nil), so + // they stay out of the label-matched mirror set. + void AddNilAnswer(string name, CultMeshContentChunkRequestMessage request) + { + vectors.Add(new Vector(name + ".request", "request", Reencode(request))); + vectors.Add(new Vector(name + ".response", "response", Reencode(Answer(content, request)))); + } + + AddNilAnswer("nil_message_id", Request(null, last.ChunkHash, key, last.SizeBytes)); + AddNilAnswer("nil_hash", Request("vector-nil-hash", null, string.Empty, 100)); + AddNilAnswer("nil_record_key", Request("vector-nil-key", new string('0', 64), null, 100)); + AddResponse("response_hash_last_digit", new CultMeshContentChunkResponseMessage + { + MessageId = "vector-digit", + Found = true, + ChunkHash = last.ChunkHash[..^1] + lastDigit, + SizeBytes = last.SizeBytes, + Payload = last.Payload, + Error = string.Empty + }); + + void AddManifest(string label, CultMeshCdnArtifactManifest manifest) + { + map[label] = manifest; + vectors.Add(new Vector(label, "manifest", Reencode(manifest))); + } + + AddManifest("manifest", artifact.Manifest); + var shuffled = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest; + shuffled.Chunks = new[] { 3, 0, 5, 1, 4, 2 }.Select(i => shuffled.Chunks[i]).ToArray(); + AddManifest("manifest_out_of_order", shuffled); + var tagged = CultMeshCdn.PackArtifact(ArtifactId, Body(), Options()).Manifest; + tagged.Metadata = new Dictionary { ["zeta"] = "1", ["alpha"] = "2", ["mid"] = "3" }; + AddManifest("manifest_metadata_order", tagged); + AddManifest("manifest_empty_body", CultMeshCdn.PackArtifact(ArtifactId, Array.Empty(), Options()).Manifest); + return vectors; + } + + private static CultMeshContentChunkResponseMessage Answer(CultCache cache, CultMeshContentChunkRequestMessage request) + { + var host = new ReferenceHost(); + using var server = new CultMeshLegacyRudpContentServer(host, cache); + var peer = new ReferencePeer(); + host.Dispatch(request, peer).GetAwaiter().GetResult(); + return peer.Sent ?? throw new InvalidOperationException("The reference server answered with silence."); + } + + private sealed class ReferenceHost : ICultNetSchemaServer + { + private Func? _handler; + + public void OnCultNet(Func callback) + where TMessage : ICultNetSchemaMessage => + _handler = (Func)(object)callback; + + public void RemoveCultNetMessageListener(Delegate callback) + where TMessage : ICultNetSchemaMessage => _handler = null; + + public Task Dispatch(CultMeshContentChunkRequestMessage request, ICultNetSchemaServerPeer peer) => + _handler!(request, peer); + } + + private sealed class ReferencePeer : ICultNetSchemaServerPeer + { + public CultMeshContentChunkResponseMessage? Sent { get; private set; } + + public void SendCultNet(TMessage message) where TMessage : ICultNetSchemaMessage => + Sent = (CultMeshContentChunkResponseMessage)(object)message; + } + + private static string ToJson(List vectors) => JsonSerializer.Serialize( + new + { + vectors = vectors.Select(v => new + { + label = v.Label, + kind = v.Kind, + messagePackBase64 = Convert.ToBase64String(v.Bytes) + }) + }, + new JsonSerializerOptions { WriteIndented = true }); + + private static List ReadVectors(string name) + { + var path = VectorPath(name); + if (!File.Exists(path)) + Assert.Inconclusive($"{path} is missing - write it first (CULTNET_WRITE_VECTORS=1)."); + using var document = JsonDocument.Parse(File.ReadAllText(path)); + return document.RootElement.GetProperty("vectors").EnumerateArray() + .Select(v => new Vector( + v.GetProperty("label").GetString()!, + v.GetProperty("kind").GetString()!, + Convert.FromBase64String(v.GetProperty("messagePackBase64").GetString()!))) + .ToList(); + } + + private static string VectorPath(string name) => Path.Combine(RepoRoot(), "contracts", "cultmesh", name); + + private static string RepoRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); + } +}