field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 9b012edb3aa847dfa04e2d23a38d8cb3f551c307
parent 118fee1a7426341c98639a2d469611fecf1eaf5d
Author: triesap <tyson@radroots.org>
Date:   Sat, 19 Sep 2026 18:12:12 +0000

media: preserve exact upload authorization scope

- Persist redacted parent and attempt association before requesting authority
- Refuse replacement of unresolved native and foreground attempts
- Verify token scope, restart safety, legacy compatibility and relay isolation
- Regenerate exact native artifacts and retain full verification evidence

Diffstat:
MTeraFFI/provenance.json | 54+++++++++++++++++++++++++++---------------------------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 68++++++++++++++++++++++++++++++++++++++++----------------------------
MTeraFFI/source/aarch64-apple-ios-sim.json | 68++++++++++++++++++++++++++++++++++++++++----------------------------
MTeraFFI/source/aarch64-apple-ios.json | 68++++++++++++++++++++++++++++++++++++++++----------------------------
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 127++++++++++++++++++++++++++-----------------------------------------------------
Mcore/crates/tera_core/src/runtime/product_surface/outbox/media.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/outbox/native_upload.rs | 41+++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/submission.rs | 2++
Mcore/crates/tera_core/src/runtime/product_surface/submission/configuration_effect_tests.rs | 15+++++++++++++--
Acore/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs | 336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/submission/media_foreground.rs | 16++++++++--------
Mcore/crates/tera_core/src/runtime/product_surface/submission/media_runtime.rs | 14++++++++------
Mcore/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs | 4++++
Mrelease/provenance.json | 4++--
Mtest-fixtures/legacy-identifiers.v1.json | 26++++++++++++++++++++++++++
17 files changed, 771 insertions(+), 216 deletions(-)

diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -22,9 +22,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 76201200, + "bytes": 76260296, "path": "TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "3db5f2c892cd2f9a0fb58c67ce9ce38d368e925a34b4b030346d7be308e55d4d" + "sha256": "559e3ba169e0f7d242428b08043cdfa091acb84c00d7a72c642294e97cfe5ebe" }, { "bytes": 80633, @@ -37,9 +37,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 76284560, + "bytes": 76343192, "path": "TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "e11c1bbe578a43fc6abc7a22810a13106541927ded6df61b59a8ccc49c960ff0" + "sha256": "c3cd8151391cd2fbf9f9180e9a6c141b11a5b68ad689ddadb072166767c45fc8" }, { "bytes": 49312, @@ -77,34 +77,34 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 21842560, + "bytes": 21861696, "path": "native/aarch64-apple-darwin/libtera_ffi.dylib", - "sha256": "af66b29ca68dbb3bc1db6328fed6220d2653983afce45a3f1d85fec0bb513017" + "sha256": "1235e1aa48b5244bd156baccffa54da81fea517fbac766fc9da01d93e114049d" }, { - "bytes": 76201200, + "bytes": 76260296, "path": "native/aarch64-apple-ios-sim/libtera_ffi.a", - "sha256": "3db5f2c892cd2f9a0fb58c67ce9ce38d368e925a34b4b030346d7be308e55d4d" + "sha256": "559e3ba169e0f7d242428b08043cdfa091acb84c00d7a72c642294e97cfe5ebe" }, { - "bytes": 76284560, + "bytes": 76343192, "path": "native/aarch64-apple-ios/libtera_ffi.a", - "sha256": "e11c1bbe578a43fc6abc7a22810a13106541927ded6df61b59a8ccc49c960ff0" + "sha256": "c3cd8151391cd2fbf9f9180e9a6c141b11a5b68ad689ddadb072166767c45fc8" }, { - "bytes": 100086, + "bytes": 100688, "path": "source/aarch64-apple-darwin.json", - "sha256": "ac6a0cb147baf73d69ba2e4727d5e57001a061f71e0478b13d6bd7703766c401" + "sha256": "0bcd57e3c345307f2fc9a4f84e65391755e3acc4bb0fe208380ec9ed39295523" }, { - "bytes": 99930, + "bytes": 100532, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "8479928697fa2c03a2fcf518310905a196a8920c166eb725038fa92cab3f6d5f" + "sha256": "30973a056e3e260e8296a34a3c357cc2686580a361058630ed617209f5899c38" }, { - "bytes": 99926, + "bytes": 100528, "path": "source/aarch64-apple-ios.json", - "sha256": "e4d38c3fda702e45025f3ae9ddf1ada8a386cc636ab6293b53f7acbeab9912e9" + "sha256": "154fe2a72d0523d9fb1bf3077231946fd1de5548c20080f5653a3f276f75db9d" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "60e714586d3acc430daee705277449abdfebacd2" + "tree": "6eaee343a67b3d16bfc623ff9aadfb9ceb68186f" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -139,9 +139,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 76201200, + "bytes": 76260296, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64-simulator/libtera_ffi.a", - "sha256": "3db5f2c892cd2f9a0fb58c67ce9ce38d368e925a34b4b030346d7be308e55d4d" + "sha256": "559e3ba169e0f7d242428b08043cdfa091acb84c00d7a72c642294e97cfe5ebe" }, { "bytes": 80633, @@ -154,9 +154,9 @@ "sha256": "3ed9b7ece2f86e5b5d1ddc6ecbb97deb49c46ca2ea91a930eaec1b580232345c" }, { - "bytes": 76284560, + "bytes": 76343192, "path": "Tera/Frameworks/TeraFFI.xcframework/ios-arm64/libtera_ffi.a", - "sha256": "e11c1bbe578a43fc6abc7a22810a13106541927ded6df61b59a8ccc49c960ff0" + "sha256": "c3cd8151391cd2fbf9f9180e9a6c141b11a5b68ad689ddadb072166767c45fc8" }, { "bytes": 643709, @@ -174,19 +174,19 @@ "sha256": "7b3f92a57ebcc7873349403243098d5dcf993b2dbdab9ed84fea4a50c20baab0" }, { - "bytes": 100086, + "bytes": 100688, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "ac6a0cb147baf73d69ba2e4727d5e57001a061f71e0478b13d6bd7703766c401" + "sha256": "0bcd57e3c345307f2fc9a4f84e65391755e3acc4bb0fe208380ec9ed39295523" }, { - "bytes": 99930, + "bytes": 100532, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "8479928697fa2c03a2fcf518310905a196a8920c166eb725038fa92cab3f6d5f" + "sha256": "30973a056e3e260e8296a34a3c357cc2686580a361058630ed617209f5899c38" }, { - "bytes": 99926, + "bytes": 100528, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "e4d38c3fda702e45025f3ae9ddf1ada8a386cc636ab6293b53f7acbeab9912e9" + "sha256": "154fe2a72d0523d9fb1bf3077231946fd1de5548c20080f5653a3f276f75db9d" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "60e714586d3acc430daee705277449abdfebacd2" -manifest_sha256 = "e618e901823221365bdb1045ef6574be49333ab2a570642a838668d77e8c79e7" +source_tree = "6eaee343a67b3d16bfc623ff9aadfb9ceb68186f" +manifest_sha256 = "e22296b2d30d4d3707b6cd8410c8b07d307aad1d8f3936584b2b51b1ffcadf8a" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -801,10 +801,10 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 172492, - "git_blob": "ea925fed291047d334bd7bf6b3d2c1b8df834278", + "bytes": 170682, + "git_blob": "66e3bff829d50508dc63bfb3a2f1db20e32f5740", "mode": "100644", - "sha256": "053b843ca42d9bf6ed61f778722615b0f1fb49f20fa564d383c5be016ceb6acd" + "sha256": "13346fa43f3bcbb2108b8cc5e54da3b39321362ecba97c4559ec2e4afc4d2a7e" }, "core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs": { "bytes": 2094, @@ -831,10 +831,10 @@ "sha256": "a77be347ffb219b07d15b79582dfc159f5728e1f5b8c9036b9c132a64f7a9ce8" }, "core/crates/tera_core/src/runtime/product_surface/outbox/media.rs": { - "bytes": 2714, - "git_blob": "a24fb22dd3088d6609a403849203b18b8602aa05", + "bytes": 4766, + "git_blob": "3f5f336db99a9534a5a4d6a5e6cfdf99027ebe46", "mode": "100644", - "sha256": "b6878895dc35c38578cc8ee95874c019462fb4d2090c91d162b2a675f6f9584f" + "sha256": "274dfe05d188b62cd6f27d589f940bad1d24b36650fb741cc53af79501743f7f" }, "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": { "bytes": 13316, @@ -843,10 +843,16 @@ "sha256": "3c048e44551d04e607597761481c24e9bbe55f6b20358047d18c8013c093fb8f" }, "core/crates/tera_core/src/runtime/product_surface/outbox/native_upload.rs": { - "bytes": 2213, - "git_blob": "a4dbf19b42c42bbc6d67b63daec76fceeafe8b6a", + "bytes": 3894, + "git_blob": "fbebf31e5cf98fb9edf8f16e6a2617e9a4bd8757", "mode": "100644", - "sha256": "e8228c7bb7addc45330cfee47849ed2c67aac7b99d92f3781db5e67b70cfa25b" + "sha256": "73b05b1cb19d255c5ef1058ff0ec8521d6d288e25a80b323720eb2f6783dd92b" + }, + "core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs": { + "bytes": 3574, + "git_blob": "c737987867f9d7782ba38dbfea0c1be7eaa58af8", + "mode": "100644", + "sha256": "46583f521a0a326a43638334dcf43ded3da1d5621e24f7f7f4bd3af959131fde" }, "core/crates/tera_core/src/runtime/product_surface/projection.rs": { "bytes": 23423, @@ -879,10 +885,10 @@ "sha256": "9b830c7db167db51e6c193c3ddd1fac3bdcaf48b8546be8497d823aec0869f12" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 6238, - "git_blob": "b2aa98ed019ae34e780a47d4a4397cefdf05927e", + "bytes": 6282, + "git_blob": "9cfe98c92526f900e152a57da676ce9e66b3c56d", "mode": "100644", - "sha256": "c178bd2ee3dfb9e630ab31a1e863ec0645c8d2477ca9dd86c09c8b58161485ec" + "sha256": "53ccffc9251259a4c124b2f9f5fe33e2792ea332181b66b07432f1a7e2ccdd9a" }, "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { "bytes": 3427, @@ -951,10 +957,10 @@ "sha256": "a53699c1995d15d7c3b7e076db1565ccb8112085e55b39411738a013caaa58df" }, "core/crates/tera_core/src/runtime/product_surface/submission/configuration_effect_tests.rs": { - "bytes": 9189, - "git_blob": "53cc3e9f74a5d2bf5175fe85db1927e4bfe9837d", + "bytes": 9619, + "git_blob": "1b73d03af8a6045cbb1be897315a7c9f68fff3b0", "mode": "100644", - "sha256": "e05268afa4b4908b3173ac5927a122ce423a90097a494f8e9d6003e65330f7e4" + "sha256": "39dc3c3f1abb5bed27daa88caee48ec3ec6aecf86bca73dc6cb058d9fa76b9b5" }, "core/crates/tera_core/src/runtime/product_surface/submission/configuration_regression_tests.rs": { "bytes": 13472, @@ -1022,11 +1028,17 @@ "mode": "100644", "sha256": "1ba102fa9fb5628e9137c0c7de36eae42b423ba8beeb638f6eca02b0db7cb950" }, + "core/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs": { + "bytes": 12492, + "git_blob": "c49facd7826e406232e81f63614b85cc639c799f", + "mode": "100644", + "sha256": "996257e79877be6e48acf5e9c70d136ce9127ad2ae14b85c5e4b51449935bdbf" + }, "core/crates/tera_core/src/runtime/product_surface/submission/media_foreground.rs": { - "bytes": 3262, - "git_blob": "880d765e78b190e701a4b4afdec2cf7cd1c7834c", + "bytes": 3150, + "git_blob": "b91b743f03cbd6c4fa81d8bd0f4ff72d79db7dc3", "mode": "100644", - "sha256": "a87707030a182b54de78dafdcbba29cc9943a53a98d462e7da77fa1bc77211b2" + "sha256": "1e2880690f6723c9eedb3bf4742cb1609e7de1c6035459a8579feb61d2a021c7" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_foreground_tests.rs": { "bytes": 7623, @@ -1041,10 +1053,10 @@ "sha256": "02039ae3261f42c89f10748ef212d5558f91e649e3948a02e320d191ad31e000" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_runtime.rs": { - "bytes": 5191, - "git_blob": "a9d721ae71127f4b9db69fb5316d864e98f95e11", + "bytes": 5270, + "git_blob": "cea1e6b29be673460b5318a876eaae26742ad625", "mode": "100644", - "sha256": "57b05d3644243ed1b0ca33a83359961c720ae7592d52d45fea5adbcff230c7f3" + "sha256": "d4cc7e38db1c22ad854ffe6163555a491bba502d4c18f9fe2ae0abd7a4e07b79" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_test_support.rs": { "bytes": 2670, @@ -1077,10 +1089,10 @@ "sha256": "279467dfba7628620d6909d0417b8320beb2c54df0c8b2e6f7585c2d2e054705" }, "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs": { - "bytes": 7160, - "git_blob": "a57be84ee09cde0347c062fbf02b60ec5797d5e3", + "bytes": 7343, + "git_blob": "1fef4931f314c65a04b5f88ff4207c0cb3102098", "mode": "100644", - "sha256": "34c1e5aea66dc8be7568ca7fac63fefd8997a4d2332f25210cc1eadbe40339e4" + "sha256": "fbec95a059953be20e07f9e24ba56f38af831dd420c92b9a671b4fad40f39ebb" }, "core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs": { "bytes": 10995, @@ -2133,13 +2145,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 140645, - "git_blob": "648d2b9887bc5864fcae84d9725f50fe634d89b3", + "bytes": 141516, + "git_blob": "46db8eded2880dd4ba156dd1fbcd655ed0666e0d", "mode": "100644", - "sha256": "91733c94feafe7a7e0f52863d2e98471782491177e4ddf9fddb7bc2e470a2c1e" + "sha256": "a665ec09cd62714e9227b3849d813b08b07efdc047cd4ea7b714c2b53fbb70ee" } }, "policy": "staged_inputs", - "tree": "60e714586d3acc430daee705277449abdfebacd2" + "tree": "6eaee343a67b3d16bfc623ff9aadfb9ceb68186f" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -797,10 +797,10 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 172492, - "git_blob": "ea925fed291047d334bd7bf6b3d2c1b8df834278", + "bytes": 170682, + "git_blob": "66e3bff829d50508dc63bfb3a2f1db20e32f5740", "mode": "100644", - "sha256": "053b843ca42d9bf6ed61f778722615b0f1fb49f20fa564d383c5be016ceb6acd" + "sha256": "13346fa43f3bcbb2108b8cc5e54da3b39321362ecba97c4559ec2e4afc4d2a7e" }, "core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs": { "bytes": 2094, @@ -827,10 +827,10 @@ "sha256": "a77be347ffb219b07d15b79582dfc159f5728e1f5b8c9036b9c132a64f7a9ce8" }, "core/crates/tera_core/src/runtime/product_surface/outbox/media.rs": { - "bytes": 2714, - "git_blob": "a24fb22dd3088d6609a403849203b18b8602aa05", + "bytes": 4766, + "git_blob": "3f5f336db99a9534a5a4d6a5e6cfdf99027ebe46", "mode": "100644", - "sha256": "b6878895dc35c38578cc8ee95874c019462fb4d2090c91d162b2a675f6f9584f" + "sha256": "274dfe05d188b62cd6f27d589f940bad1d24b36650fb741cc53af79501743f7f" }, "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": { "bytes": 13316, @@ -839,10 +839,16 @@ "sha256": "3c048e44551d04e607597761481c24e9bbe55f6b20358047d18c8013c093fb8f" }, "core/crates/tera_core/src/runtime/product_surface/outbox/native_upload.rs": { - "bytes": 2213, - "git_blob": "a4dbf19b42c42bbc6d67b63daec76fceeafe8b6a", + "bytes": 3894, + "git_blob": "fbebf31e5cf98fb9edf8f16e6a2617e9a4bd8757", "mode": "100644", - "sha256": "e8228c7bb7addc45330cfee47849ed2c67aac7b99d92f3781db5e67b70cfa25b" + "sha256": "73b05b1cb19d255c5ef1058ff0ec8521d6d288e25a80b323720eb2f6783dd92b" + }, + "core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs": { + "bytes": 3574, + "git_blob": "c737987867f9d7782ba38dbfea0c1be7eaa58af8", + "mode": "100644", + "sha256": "46583f521a0a326a43638334dcf43ded3da1d5621e24f7f7f4bd3af959131fde" }, "core/crates/tera_core/src/runtime/product_surface/projection.rs": { "bytes": 23423, @@ -875,10 +881,10 @@ "sha256": "9b830c7db167db51e6c193c3ddd1fac3bdcaf48b8546be8497d823aec0869f12" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 6238, - "git_blob": "b2aa98ed019ae34e780a47d4a4397cefdf05927e", + "bytes": 6282, + "git_blob": "9cfe98c92526f900e152a57da676ce9e66b3c56d", "mode": "100644", - "sha256": "c178bd2ee3dfb9e630ab31a1e863ec0645c8d2477ca9dd86c09c8b58161485ec" + "sha256": "53ccffc9251259a4c124b2f9f5fe33e2792ea332181b66b07432f1a7e2ccdd9a" }, "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { "bytes": 3427, @@ -947,10 +953,10 @@ "sha256": "a53699c1995d15d7c3b7e076db1565ccb8112085e55b39411738a013caaa58df" }, "core/crates/tera_core/src/runtime/product_surface/submission/configuration_effect_tests.rs": { - "bytes": 9189, - "git_blob": "53cc3e9f74a5d2bf5175fe85db1927e4bfe9837d", + "bytes": 9619, + "git_blob": "1b73d03af8a6045cbb1be897315a7c9f68fff3b0", "mode": "100644", - "sha256": "e05268afa4b4908b3173ac5927a122ce423a90097a494f8e9d6003e65330f7e4" + "sha256": "39dc3c3f1abb5bed27daa88caee48ec3ec6aecf86bca73dc6cb058d9fa76b9b5" }, "core/crates/tera_core/src/runtime/product_surface/submission/configuration_regression_tests.rs": { "bytes": 13472, @@ -1018,11 +1024,17 @@ "mode": "100644", "sha256": "1ba102fa9fb5628e9137c0c7de36eae42b423ba8beeb638f6eca02b0db7cb950" }, + "core/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs": { + "bytes": 12492, + "git_blob": "c49facd7826e406232e81f63614b85cc639c799f", + "mode": "100644", + "sha256": "996257e79877be6e48acf5e9c70d136ce9127ad2ae14b85c5e4b51449935bdbf" + }, "core/crates/tera_core/src/runtime/product_surface/submission/media_foreground.rs": { - "bytes": 3262, - "git_blob": "880d765e78b190e701a4b4afdec2cf7cd1c7834c", + "bytes": 3150, + "git_blob": "b91b743f03cbd6c4fa81d8bd0f4ff72d79db7dc3", "mode": "100644", - "sha256": "a87707030a182b54de78dafdcbba29cc9943a53a98d462e7da77fa1bc77211b2" + "sha256": "1e2880690f6723c9eedb3bf4742cb1609e7de1c6035459a8579feb61d2a021c7" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_foreground_tests.rs": { "bytes": 7623, @@ -1037,10 +1049,10 @@ "sha256": "02039ae3261f42c89f10748ef212d5558f91e649e3948a02e320d191ad31e000" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_runtime.rs": { - "bytes": 5191, - "git_blob": "a9d721ae71127f4b9db69fb5316d864e98f95e11", + "bytes": 5270, + "git_blob": "cea1e6b29be673460b5318a876eaae26742ad625", "mode": "100644", - "sha256": "57b05d3644243ed1b0ca33a83359961c720ae7592d52d45fea5adbcff230c7f3" + "sha256": "d4cc7e38db1c22ad854ffe6163555a491bba502d4c18f9fe2ae0abd7a4e07b79" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_test_support.rs": { "bytes": 2670, @@ -1073,10 +1085,10 @@ "sha256": "279467dfba7628620d6909d0417b8320beb2c54df0c8b2e6f7585c2d2e054705" }, "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs": { - "bytes": 7160, - "git_blob": "a57be84ee09cde0347c062fbf02b60ec5797d5e3", + "bytes": 7343, + "git_blob": "1fef4931f314c65a04b5f88ff4207c0cb3102098", "mode": "100644", - "sha256": "34c1e5aea66dc8be7568ca7fac63fefd8997a4d2332f25210cc1eadbe40339e4" + "sha256": "fbec95a059953be20e07f9e24ba56f38af831dd420c92b9a671b4fad40f39ebb" }, "core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs": { "bytes": 10995, @@ -2129,13 +2141,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 140645, - "git_blob": "648d2b9887bc5864fcae84d9725f50fe634d89b3", + "bytes": 141516, + "git_blob": "46db8eded2880dd4ba156dd1fbcd655ed0666e0d", "mode": "100644", - "sha256": "91733c94feafe7a7e0f52863d2e98471782491177e4ddf9fddb7bc2e470a2c1e" + "sha256": "a665ec09cd62714e9227b3849d813b08b07efdc047cd4ea7b714c2b53fbb70ee" } }, "policy": "staged_inputs", - "tree": "60e714586d3acc430daee705277449abdfebacd2" + "tree": "6eaee343a67b3d16bfc623ff9aadfb9ceb68186f" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -797,10 +797,10 @@ "sha256": "f5d9c1bac647bf745600c2edefffa1f25172221ac9d4913b81b2b5a8fcc8b590" }, "core/crates/tera_core/src/runtime/product_surface/outbox.rs": { - "bytes": 172492, - "git_blob": "ea925fed291047d334bd7bf6b3d2c1b8df834278", + "bytes": 170682, + "git_blob": "66e3bff829d50508dc63bfb3a2f1db20e32f5740", "mode": "100644", - "sha256": "053b843ca42d9bf6ed61f778722615b0f1fb49f20fa564d383c5be016ceb6acd" + "sha256": "13346fa43f3bcbb2108b8cc5e54da3b39321362ecba97c4559ec2e4afc4d2a7e" }, "core/crates/tera_core/src/runtime/product_surface/outbox/advance.rs": { "bytes": 2094, @@ -827,10 +827,10 @@ "sha256": "a77be347ffb219b07d15b79582dfc159f5728e1f5b8c9036b9c132a64f7a9ce8" }, "core/crates/tera_core/src/runtime/product_surface/outbox/media.rs": { - "bytes": 2714, - "git_blob": "a24fb22dd3088d6609a403849203b18b8602aa05", + "bytes": 4766, + "git_blob": "3f5f336db99a9534a5a4d6a5e6cfdf99027ebe46", "mode": "100644", - "sha256": "b6878895dc35c38578cc8ee95874c019462fb4d2090c91d162b2a675f6f9584f" + "sha256": "274dfe05d188b62cd6f27d589f940bad1d24b36650fb741cc53af79501743f7f" }, "core/crates/tera_core/src/runtime/product_surface/outbox/mutation_admission_tests.rs": { "bytes": 13316, @@ -839,10 +839,16 @@ "sha256": "3c048e44551d04e607597761481c24e9bbe55f6b20358047d18c8013c093fb8f" }, "core/crates/tera_core/src/runtime/product_surface/outbox/native_upload.rs": { - "bytes": 2213, - "git_blob": "a4dbf19b42c42bbc6d67b63daec76fceeafe8b6a", + "bytes": 3894, + "git_blob": "fbebf31e5cf98fb9edf8f16e6a2617e9a4bd8757", "mode": "100644", - "sha256": "e8228c7bb7addc45330cfee47849ed2c67aac7b99d92f3781db5e67b70cfa25b" + "sha256": "73b05b1cb19d255c5ef1058ff0ec8521d6d288e25a80b323720eb2f6783dd92b" + }, + "core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs": { + "bytes": 3574, + "git_blob": "c737987867f9d7782ba38dbfea0c1be7eaa58af8", + "mode": "100644", + "sha256": "46583f521a0a326a43638334dcf43ded3da1d5621e24f7f7f4bd3af959131fde" }, "core/crates/tera_core/src/runtime/product_surface/projection.rs": { "bytes": 23423, @@ -875,10 +881,10 @@ "sha256": "9b830c7db167db51e6c193c3ddd1fac3bdcaf48b8546be8497d823aec0869f12" }, "core/crates/tera_core/src/runtime/product_surface/submission.rs": { - "bytes": 6238, - "git_blob": "b2aa98ed019ae34e780a47d4a4397cefdf05927e", + "bytes": 6282, + "git_blob": "9cfe98c92526f900e152a57da676ce9e66b3c56d", "mode": "100644", - "sha256": "c178bd2ee3dfb9e630ab31a1e863ec0645c8d2477ca9dd86c09c8b58161485ec" + "sha256": "53ccffc9251259a4c124b2f9f5fe33e2792ea332181b66b07432f1a7e2ccdd9a" }, "core/crates/tera_core/src/runtime/product_surface/submission/atomic_fault_store.rs": { "bytes": 3427, @@ -947,10 +953,10 @@ "sha256": "a53699c1995d15d7c3b7e076db1565ccb8112085e55b39411738a013caaa58df" }, "core/crates/tera_core/src/runtime/product_surface/submission/configuration_effect_tests.rs": { - "bytes": 9189, - "git_blob": "53cc3e9f74a5d2bf5175fe85db1927e4bfe9837d", + "bytes": 9619, + "git_blob": "1b73d03af8a6045cbb1be897315a7c9f68fff3b0", "mode": "100644", - "sha256": "e05268afa4b4908b3173ac5927a122ce423a90097a494f8e9d6003e65330f7e4" + "sha256": "39dc3c3f1abb5bed27daa88caee48ec3ec6aecf86bca73dc6cb058d9fa76b9b5" }, "core/crates/tera_core/src/runtime/product_surface/submission/configuration_regression_tests.rs": { "bytes": 13472, @@ -1018,11 +1024,17 @@ "mode": "100644", "sha256": "1ba102fa9fb5628e9137c0c7de36eae42b423ba8beeb638f6eca02b0db7cb950" }, + "core/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs": { + "bytes": 12492, + "git_blob": "c49facd7826e406232e81f63614b85cc639c799f", + "mode": "100644", + "sha256": "996257e79877be6e48acf5e9c70d136ce9127ad2ae14b85c5e4b51449935bdbf" + }, "core/crates/tera_core/src/runtime/product_surface/submission/media_foreground.rs": { - "bytes": 3262, - "git_blob": "880d765e78b190e701a4b4afdec2cf7cd1c7834c", + "bytes": 3150, + "git_blob": "b91b743f03cbd6c4fa81d8bd0f4ff72d79db7dc3", "mode": "100644", - "sha256": "a87707030a182b54de78dafdcbba29cc9943a53a98d462e7da77fa1bc77211b2" + "sha256": "1e2880690f6723c9eedb3bf4742cb1609e7de1c6035459a8579feb61d2a021c7" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_foreground_tests.rs": { "bytes": 7623, @@ -1037,10 +1049,10 @@ "sha256": "02039ae3261f42c89f10748ef212d5558f91e649e3948a02e320d191ad31e000" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_runtime.rs": { - "bytes": 5191, - "git_blob": "a9d721ae71127f4b9db69fb5316d864e98f95e11", + "bytes": 5270, + "git_blob": "cea1e6b29be673460b5318a876eaae26742ad625", "mode": "100644", - "sha256": "57b05d3644243ed1b0ca33a83359961c720ae7592d52d45fea5adbcff230c7f3" + "sha256": "d4cc7e38db1c22ad854ffe6163555a491bba502d4c18f9fe2ae0abd7a4e07b79" }, "core/crates/tera_core/src/runtime/product_surface/submission/media_test_support.rs": { "bytes": 2670, @@ -1073,10 +1085,10 @@ "sha256": "279467dfba7628620d6909d0417b8320beb2c54df0c8b2e6f7585c2d2e054705" }, "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs": { - "bytes": 7160, - "git_blob": "a57be84ee09cde0347c062fbf02b60ec5797d5e3", + "bytes": 7343, + "git_blob": "1fef4931f314c65a04b5f88ff4207c0cb3102098", "mode": "100644", - "sha256": "34c1e5aea66dc8be7568ca7fac63fefd8997a4d2332f25210cc1eadbe40339e4" + "sha256": "fbec95a059953be20e07f9e24ba56f38af831dd420c92b9a671b4fad40f39ebb" }, "core/crates/tera_core/src/runtime/product_surface/submission/operation_tests.rs": { "bytes": 10995, @@ -2129,13 +2141,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 140645, - "git_blob": "648d2b9887bc5864fcae84d9725f50fe634d89b3", + "bytes": 141516, + "git_blob": "46db8eded2880dd4ba156dd1fbcd655ed0666e0d", "mode": "100644", - "sha256": "91733c94feafe7a7e0f52863d2e98471782491177e4ddf9fddb7bc2e470a2c1e" + "sha256": "a665ec09cd62714e9227b3849d813b08b07efdc047cd4ea7b714c2b53fbb70ee" } }, "policy": "staged_inputs", - "tree": "60e714586d3acc430daee705277449abdfebacd2" + "tree": "6eaee343a67b3d16bfc623ff9aadfb9ceb68186f" } } diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -52,9 +52,12 @@ mod configuration; mod inventory; mod media; mod native_upload; +mod upload_attempt; pub use inventory::{ Phase1DraftListEntry, Phase1DraftPage, Phase1DraftRepairReason, Phase1DraftSummary, }; +pub use native_upload::Phase1UploadPlan; +use upload_attempt::UploadAttempt; const DRAFT_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-draft.v1"; const PROFILE_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-profile.v1"; @@ -209,6 +212,8 @@ pub struct Phase1MediaPrerequisite { upload_attempts: u8, verified_at_unix_ms: Option<u64>, orphan: Option<Phase1MediaOrphanRecord>, + #[serde(default, skip_serializing_if = "Option::is_none")] + authorization_attempt: Option<UploadAttempt>, } /// Durable, secret-safe evidence that a remote blob may be unreferenced. @@ -245,6 +250,7 @@ impl Phase1MediaPrerequisite { upload_attempts: 0, verified_at_unix_ms: None, orphan: None, + authorization_attempt: None, }; value.validate()?; Ok(value) @@ -253,6 +259,15 @@ impl Phase1MediaPrerequisite { pub(super) fn validate(&self) -> Result<(), Phase1DraftError> { let blob = BlobUrl::parse(self.url.as_str()).map_err(|_| Phase1DraftError::InvalidMedia)?; let hash = blob.hash_path().hash().to_string(); + if let Some(attempt) = &self.authorization_attempt { + attempt.validate()?; + if matches!( + self.stage, + Phase1MediaStage::Pending | Phase1MediaStage::Preparing + ) { + return Err(Phase1DraftError::InvalidMedia); + } + } if self.local_reference.is_empty() || self.local_reference.len() > DRAFT_LOCAL_REFERENCE_MAX_BYTES || self.local_reference != self.local_reference.trim() @@ -561,19 +576,6 @@ impl Phase1UploadIntent { } } -/// Immutable Rust-derived policy for one upload attempt. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Phase1UploadPlan { - pub authorization_content: String, - pub authorization_created_at_unix_s: u64, - pub authorization_lifetime_seconds: u64, - pub operation_id: SigningOperationId, - pub artifact_id: AuthoredArtifactId, - pub signing_deadline_unix_ms: u64, - pub cancellation: Phase1CancellationPolicy, - pub updated_at_unix_ms: u64, -} - /// Immutable Rust-authorized upload job handed to a native background /// transfer implementation after the durable draft enters `media_uploading`. #[derive(Clone)] @@ -869,34 +871,6 @@ impl Phase1RevisionStatus { } } -impl Phase1UploadPlan { - pub(super) fn derive( - now_unix_ms: u64, - operation_id: [u8; 16], - artifact_id: [u8; 16], - ) -> Result<Self, Phase1DraftError> { - let now_unix_s = now_unix_ms / 1_000; - if now_unix_s == 0 { - return Err(Phase1DraftError::ClockUnavailable); - } - Ok(Self { - authorization_content: BLOSSOM_AUTHORIZATION_CONTENT.to_owned(), - authorization_created_at_unix_s: now_unix_s - .saturating_sub(BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS), - authorization_lifetime_seconds: BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS, - operation_id: SigningOperationId::new(operation_id) - .map_err(|_| Phase1DraftError::InvalidDraft)?, - artifact_id: AuthoredArtifactId::new(artifact_id) - .map_err(|_| Phase1DraftError::InvalidDraft)?, - signing_deadline_unix_ms: now_unix_ms - .checked_add(BLOSSOM_SIGNING_TIMEOUT_MS) - .ok_or(Phase1DraftError::DeadlineOverflow)?, - cancellation: Phase1CancellationPolicy::LocalCooperative, - updated_at_unix_ms: now_unix_ms, - }) - } -} - /// Honest aggregate state for a local draft and its durable authored operation. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Phase1OutboxState { @@ -1577,19 +1551,17 @@ impl TeraRuntime { let transaction = blossom .prepare_upload(request) .map_err(|_| Phase1DraftError::Operation)?; - let job = self.authorize_native_upload(&transaction, &plan).await?; - let remote_url = job.remote_url(); let uploading = self - .phase1_update_draft_media_admitted( + .update_draft_media( &admission, *intent.draft_id.as_bytes(), intent.expected_revision.get(), - remote_url, - Phase1MediaStage::Uploading, - None, + transaction.expected_url().as_str(), now_unix_ms, + |media| media.reserve_upload(&plan, &transaction), ) .await?; + let job = self.authorize_native_upload(&transaction, &plan).await?; Ok((uploading, job)) } @@ -2177,41 +2149,15 @@ impl TeraRuntime { failure_code: Option<String>, updated_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { - let draft_id = - AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; - let expected = AuthoredDraftRevision::new(expected_revision) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let storage = self.storage()?; - let head = storage - .authored_draft_head(draft_id) - .await - .map_err(|_| Phase1DraftError::Storage)? - .ok_or(Phase1DraftError::NotFound)?; - if head.revision() != expected - || head.stage().is_terminal() - || matches!( - head.stage(), - AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued - ) - { - return Err(Phase1DraftError::RevisionConflict); - } - let mut payload = Phase1DraftPayload::decode(&head)?; - let media = payload - .media - .iter_mut() - .find(|media| media.url == url) - .ok_or(Phase1DraftError::InvalidMedia)?; - media.transition_requested(stage, failure_code)?; - let next_stage = draft_stage_for_media(&payload.media); - let next = head - .successor(payload.encode()?, next_stage, None, updated_at_unix_ms) - .map_err(|_| Phase1DraftError::RevisionConflict)?; - let receipt = storage - .append_authored_draft(next, Some(expected)) - .await - .map_err(map_draft_storage_error)?; - self.draft_status_from(receipt.draft().clone()).await + self.update_draft_media( + _admission, + draft_id, + expected_revision, + url, + updated_at_unix_ms, + |media| media.transition_requested(stage, failure_code), + ) + .await } /// Records the only proof that can advance media to remote-byte-verified. @@ -2635,15 +2581,26 @@ impl TeraRuntime { .prepare_upload(request) .map_err(|_| Phase1DraftError::Operation)?; let url = transaction.expected_url().as_str().to_owned(); + let plan = Phase1UploadPlan { + authorization_content: authorization_content.as_str().to_owned(), + authorization_created_at_unix_s, + authorization_lifetime_seconds, + operation_id: SigningOperationId::new(operation_id) + .map_err(|_| Phase1DraftError::InvalidMedia)?, + artifact_id: AuthoredArtifactId::new(artifact_id) + .map_err(|_| Phase1DraftError::InvalidMedia)?, + signing_deadline_unix_ms, + cancellation: signing_cancellation, + updated_at_unix_ms, + }; let uploading = self - .phase1_update_draft_media_admitted( + .update_draft_media( &admission, draft_id, expected_revision, url.as_str(), - Phase1MediaStage::Uploading, - None, updated_at_unix_ms, + |media| media.reserve_upload(&plan, &transaction), ) .await?; let revision = uploading.draft().revision().get(); diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox/media.rs b/core/crates/tera_core/src/runtime/product_surface/outbox/media.rs @@ -75,3 +75,55 @@ impl Phase1MediaPrerequisite { && self.byte_size == other.byte_size } } + +impl TeraRuntime { + #[allow(clippy::too_many_arguments)] + pub(super) async fn update_draft_media( + &self, + _admission: &crate::runtime::mutation_admission::MutationPermit<'_>, + draft_id: [u8; 16], + expected_revision: u64, + url: &str, + updated_at_unix_ms: u64, + update: impl FnOnce(&mut Phase1MediaPrerequisite) -> Result<(), Phase1DraftError>, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected + || head.stage().is_terminal() + || matches!( + head.stage(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) + { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let media = payload + .media + .iter_mut() + .find(|media| media.url == url) + .ok_or(Phase1DraftError::InvalidMedia)?; + update(media)?; + let next_stage = draft_stage_for_media(&payload.media); + let next = head + .successor(payload.encode()?, next_stage, None, updated_at_unix_ms) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = storage + .append_authored_draft(next.clone(), Some(expected)) + .await + .map_err(map_draft_storage_error)?; + if receipt.draft() != &next { + return Err(Phase1DraftError::Storage); + } + self.draft_status_from(receipt.draft().clone()).await + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox/native_upload.rs b/core/crates/tera_core/src/runtime/product_surface/outbox/native_upload.rs @@ -2,6 +2,47 @@ use super::*; +/// Immutable Rust-derived policy for one upload attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1UploadPlan { + pub authorization_content: String, + pub authorization_created_at_unix_s: u64, + pub authorization_lifetime_seconds: u64, + pub operation_id: SigningOperationId, + pub artifact_id: AuthoredArtifactId, + pub signing_deadline_unix_ms: u64, + pub cancellation: Phase1CancellationPolicy, + pub updated_at_unix_ms: u64, +} + +impl Phase1UploadPlan { + pub(in crate::runtime::product_surface) fn derive( + now_unix_ms: u64, + operation_id: [u8; 16], + artifact_id: [u8; 16], + ) -> Result<Self, Phase1DraftError> { + let now_unix_s = now_unix_ms / 1_000; + if now_unix_s == 0 { + return Err(Phase1DraftError::ClockUnavailable); + } + Ok(Self { + authorization_content: BLOSSOM_AUTHORIZATION_CONTENT.to_owned(), + authorization_created_at_unix_s: now_unix_s + .saturating_sub(BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS), + authorization_lifetime_seconds: BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS, + operation_id: SigningOperationId::new(operation_id) + .map_err(|_| Phase1DraftError::InvalidDraft)?, + artifact_id: AuthoredArtifactId::new(artifact_id) + .map_err(|_| Phase1DraftError::InvalidDraft)?, + signing_deadline_unix_ms: now_unix_ms + .checked_add(BLOSSOM_SIGNING_TIMEOUT_MS) + .ok_or(Phase1DraftError::DeadlineOverflow)?, + cancellation: Phase1CancellationPolicy::LocalCooperative, + updated_at_unix_ms: now_unix_ms, + }) + } +} + impl TeraRuntime { pub(in crate::runtime::product_surface) async fn authorize_native_upload( &self, diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs b/core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs @@ -0,0 +1,88 @@ +//! Redacted association persisted before a bearer credential can be requested. + +use super::*; +use radroots_blossom::authorization::{ + AuthorizationContent, RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, ServerDomain, +}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct UploadAttempt { + schema_version: u8, + operation_id: [u8; 16], + artifact_id: [u8; 16], + content_sha256: [u8; 32], + created_at_unix_s: u64, + expiration_unix_s: u64, + signing_deadline_unix_ms: u64, +} + +impl UploadAttempt { + fn new(plan: &Phase1UploadPlan, url: &BlobUrl) -> Result<Self, Phase1DraftError> { + let invalid = |_| Phase1DraftError::InvalidMedia; + let claim = AuthoredUploadClaim::new( + AuthorizationContent::parse(&plan.authorization_content).map_err(invalid)?, + ServerDomain::parse(url.host()).map_err(invalid)?, + url.hash_path().hash(), + plan.authorization_created_at_unix_s, + plan.authorization_lifetime_seconds, + ) + .map_err(invalid)?; + SignPolicy::new(plan.signing_deadline_unix_ms, plan.cancellation.signing()) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let value = Self { + schema_version: 1, + operation_id: *plan.operation_id.as_bytes(), + artifact_id: *plan.artifact_id.as_bytes(), + content_sha256: Sha256::digest(claim.content().as_str().as_bytes()).into(), + created_at_unix_s: claim.created_at(), + expiration_unix_s: claim.expiration(), + signing_deadline_unix_ms: plan.signing_deadline_unix_ms, + }; + value.validate()?; + Ok(value) + } + + pub(super) fn validate(&self) -> Result<(), Phase1DraftError> { + let lifetime = self.expiration_unix_s.checked_sub(self.created_at_unix_s); + if self.schema_version != 1 + || SigningOperationId::new(self.operation_id).is_err() + || AuthoredArtifactId::new(self.artifact_id).is_err() + || self.content_sha256 == [0; 32] + || self.created_at_unix_s == 0 + || self.signing_deadline_unix_ms == 0 + || !lifetime.is_some_and(|value| { + (1..=RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS).contains(&value) + }) + { + return Err(Phase1DraftError::InvalidMedia); + } + Ok(()) + } +} + +impl Phase1MediaPrerequisite { + pub(in crate::runtime::product_surface) fn reserve_upload( + &mut self, + plan: &Phase1UploadPlan, + transaction: &radroots_sdk::transport::BlossomUploadTransaction, + ) -> Result<(), Phase1DraftError> { + // Legacy uploading rows without an association remain uncertain too. + // A failure or a lost callback cannot itself authorize a replacement. + let request = transaction.request(); + if self.authorization_attempt.is_some() + || self.stage == Phase1MediaStage::Uploading + || self.orphan.is_some() + || self.url != transaction.expected_url().as_str() + || self.sha256 != request.sha256().to_string() + || self.byte_size != request.byte_size() + || self.media_type != request.media_type().as_str() + { + return Err(Phase1DraftError::InvalidMedia); + } + let attempt = UploadAttempt::new(plan, transaction.expected_url())?; + self.transition_requested(Phase1MediaStage::Uploading, None)?; + self.authorization_attempt = Some(attempt); + self.validate() + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission.rs b/core/crates/tera_core/src/runtime/product_surface/submission.rs @@ -42,6 +42,8 @@ mod fault_store; #[cfg(test)] mod inventory_tests; #[cfg(test)] +mod media_authorization_tests; +#[cfg(test)] mod media_foreground_tests; #[cfg(test)] mod media_recovery_tests; diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/configuration_effect_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/configuration_effect_tests.rs @@ -211,6 +211,17 @@ async fn configuration_change_during_upload_authorization_retains_stop_without_i tokio::time::timeout(Duration::from_secs(5), signer.entered.notified()) .await .unwrap(); + let reserved = runtime.submission_operation_status(&request).await.unwrap(); + assert_eq!( + reserved.intent().revision().get(), + before.intent().revision().get() + 1 + ); + assert_eq!( + reserved.media()[0].stage(), + crate::runtime::product_surface::Phase1MediaStage::Uploading + ); + assert_eq!(reserved.captured(), before.captured()); + assert_eq!(reserved.push(), before.push()); tokio::time::timeout( Duration::from_secs(2), configure(&runtime, "http://127.0.0.1:3001"), @@ -224,8 +235,8 @@ async fn configuration_change_during_upload_authorization_retains_stop_without_i Err(SubmissionOperationError::Stopped) )); let stopped = runtime.submission_operation_status(&request).await.unwrap(); - assert_eq!(stopped.intent(), before.intent()); - assert_eq!(stopped.media(), before.media()); + assert_eq!(stopped.intent(), reserved.intent()); + assert_eq!(stopped.media(), reserved.media()); assert_eq!(stopped.captured(), before.captured()); assert!( stopped diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs b/core/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs @@ -0,0 +1,336 @@ +use std::{sync::atomic::Ordering, time::Duration}; + +use radroots_blossom::authorization::{ + AuthorizationClaim, AuthorizationTarget, AuthorizationValidation, ServerDomain, + ServerScopeRequirement, +}; +use radroots_nostr::blossom::decode_verify_authorization_header; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use super::{media_test_support::*, operation_test_support::*, test_support::*}; +use crate::runtime::product_surface::Phase1MediaStage; + +#[tokio::test] +async fn authorization_is_associated_before_signer_and_survives_interruption_and_reopen() { + for foreground in [false, true] { + let root = tempfile::tempdir().unwrap(); + let signer = CountingSigner::new(); + let runtime = runtime(Some(root.path()), signer.clone(), "ws://127.0.0.1:19999").await; + let request = request(); + prepare(&runtime, &request, true).await; + let original = runtime.submission_operation_status(&request).await.unwrap(); + signer.pause.store(true, Ordering::SeqCst); + let task = { + let runtime = runtime.clone(); + let input = upload(&request, 1); + tokio::spawn(async move { + if foreground { + runtime.submission_upload_media(input).await.map(|_| ()) + } else { + runtime + .submission_prepare_native_upload(input) + .await + .map(|_| ()) + } + }) + }; + tokio::time::timeout(Duration::from_secs(5), signer.entered.notified()) + .await + .unwrap(); + let reserved = runtime.submission_operation_status(&request).await.unwrap(); + assert_eq!(reserved.media()[0].stage(), Phase1MediaStage::Uploading); + assert_eq!(reserved.captured(), original.captured()); + assert_eq!(reserved.push(), original.push()); + let payload: Value = serde_json::from_slice(reserved.intent().payload()).unwrap(); + let attempt = &payload["media"][0]["authorization_attempt"]; + let signing = signer.requests.lock().unwrap()[0].clone(); + assert_eq!( + attempt["operation_id"], + json!(signing.intent_id().operation_id().as_bytes()) + ); + assert_eq!( + attempt["artifact_id"], + json!(signing.intent_id().artifact_id().as_bytes()) + ); + assert_eq!(attempt["created_at_unix_s"], signing.created_at()); + assert_eq!(attempt["expiration_unix_s"], signing.created_at() + 300); + assert_eq!( + attempt["content_sha256"], + json!(Sha256::digest(signing.content()).to_vec()) + ); + assert!(!String::from_utf8_lossy(reserved.intent().payload()).contains("Nostr ")); + assert!(signing.authored_plan().is_none()); + assert!(signing.blossom_authorization_plan().is_some()); + assert_redacted(&reserved); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + runtime.shutdown().await.unwrap(); + drop(runtime); + let reopened = + self::runtime(Some(root.path()), signer.clone(), "ws://127.0.0.1:19999").await; + assert_eq!( + reopened + .submission_operation_status(&request) + .await + .unwrap(), + reserved + ); + let revision = reserved.intent().revision().get(); + assert!( + reopened + .submission_prepare_native_upload(upload(&request, revision)) + .await + .is_err() + ); + assert!( + reopened + .submission_upload_media(upload(&request, revision)) + .await + .is_err() + ); + assert_eq!(signer.count(), 1); + assert_eq!( + reopened + .submission_operation_status(&request) + .await + .unwrap(), + reserved + ); + reopened.shutdown().await.unwrap(); + } +} + +#[tokio::test] +async fn actual_http_authority_is_exact_bounded_and_cannot_be_extended_or_reissued() { + let signer = CountingSigner::new(); + let runtime = runtime(None, signer.clone(), "ws://127.0.0.1:19999").await; + let request = request(); + prepare(&runtime, &request, true).await; + let (reserved, job) = runtime + .submission_prepare_native_upload(upload(&request, 1)) + .await + .unwrap(); + let signed = signer.requests.lock().unwrap()[0].clone(); + let sha = radroots_blossom::Sha256::digest(&photo().1); + let validation = |target, server: &str, now| { + AuthorizationValidation::new( + target, + ServerDomain::parse(server).unwrap(), + ServerScopeRequirement::RequiredAnyMatch, + now, + 300, + ) + .unwrap() + }; + let valid = validation( + AuthorizationTarget::Upload(sha), + "127.0.0.1", + signed.created_at() + 5, + ); + assert!(decode_verify_authorization_header(job.authorization_header(), &valid).is_ok()); + for invalid in [ + validation( + AuthorizationTarget::Upload(radroots_blossom::Sha256::digest(b"other")), + "127.0.0.1", + signed.created_at() + 5, + ), + validation( + AuthorizationTarget::DeleteBlob(sha), + "127.0.0.1", + signed.created_at() + 5, + ), + validation( + AuthorizationTarget::Upload(sha), + "other.example", + signed.created_at() + 5, + ), + validation( + AuthorizationTarget::Upload(sha), + "127.0.0.1", + signed.created_at() + 300, + ), + ] { + assert!(decode_verify_authorization_header(job.authorization_header(), &invalid).is_err()); + } + for excessive_lifetime in [false, true] { + let mut tags = signed.tags().to_vec(); + if excessive_lifetime { + tags.iter_mut().find(|tag| tag[0] == "expiration").unwrap()[1] = + (signed.created_at() + 301).to_string(); + } else { + tags.push(tags.iter().find(|tag| tag[0] == "t").unwrap().clone()); + } + assert!( + AuthorizationClaim::parse(signed.content(), signed.created_at(), &tags) + .and_then(|claim| claim.validate(&valid)) + .is_err() + ); + } + assert_eq!( + job.operation_id(), + *signed.intent_id().operation_id().as_bytes() + ); + assert!( + runtime + .submission_prepare_native_upload(upload(&request, reserved.intent().revision().get())) + .await + .is_err() + ); + assert_eq!(signer.count(), 1); + assert_eq!( + runtime.submission_operation_status(&request).await.unwrap(), + reserved + ); + runtime.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn historical_metadata_roundtrips_and_attempt_versions_fail_closed() { + use crate::runtime::product_surface::Phase1MediaPrerequisite; + let signer = CountingSigner::new(); + let runtime = runtime(None, signer, "ws://127.0.0.1:19999").await; + let request = request(); + prepare(&runtime, &request, true).await; + let original = runtime.submission_operation_status(&request).await.unwrap(); + let pending = serde_json::to_value(&original.media()[0]).unwrap(); + assert!(pending.get("authorization_attempt").is_none()); + let historical: Phase1MediaPrerequisite = serde_json::from_value(pending.clone()).unwrap(); + historical.validate().unwrap(); + assert_eq!(serde_json::to_value(historical).unwrap(), pending); + let (reserved, _) = runtime + .submission_prepare_native_upload(upload(&request, 1)) + .await + .unwrap(); + let current = serde_json::to_value(&reserved.media()[0]).unwrap(); + for (field, invalid) in [ + ("schema_version", json!(2)), + ("operation_id", json!(vec![0; 16])), + ("artifact_id", json!(vec![0; 16])), + ("content_sha256", json!(vec![0; 32])), + ("expiration_unix_s", json!(0)), + ("signing_deadline_unix_ms", json!(0)), + ] { + let mut corrupt = current.clone(); + corrupt["authorization_attempt"][field] = invalid; + let decoded: Phase1MediaPrerequisite = serde_json::from_value(corrupt).unwrap(); + assert!(decoded.validate().is_err(), "{field}"); + } + let mut unknown = current.clone(); + unknown["authorization_attempt"]["bearer"] = json!("forbidden"); + assert!(serde_json::from_value::<Phase1MediaPrerequisite>(unknown).is_err()); + let mut legacy_uploading = current; + legacy_uploading + .as_object_mut() + .unwrap() + .remove("authorization_attempt"); + let legacy: Phase1MediaPrerequisite = serde_json::from_value(legacy_uploading).unwrap(); + legacy.validate().unwrap(); + assert_eq!(legacy.stage(), Phase1MediaStage::Uploading); + runtime.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn legacy_upload_paths_persist_before_signing_and_refuse_uncertain_replacement() { + use crate::runtime::product_surface::{Phase1DraftError, Phase1UploadIntent}; + let input = |revision| { + let (media, bytes) = photo(); + Phase1UploadIntent::new( + [84; 16], + revision, + bytes, + radroots_blossom::MediaType::parse(&media.media_type).unwrap(), + media.width, + media.height, + ) + .unwrap() + }; + for foreground in [false, true] { + let signer = CountingSigner::new(); + let runtime = runtime(None, signer.clone(), "ws://127.0.0.1:19999").await; + let request = request(); + prepare(&runtime, &request, true).await; + let capture = runtime + .submission_capture(&request, vec![photo().1]) + .await + .unwrap(); + runtime + .phase1_save_draft( + [84; 16], + capture.command().clone(), + capture.plan().created_at(), + capture.media().to_vec(), + None, + capture.reservation().reserved_at_unix_ms(), + ) + .await + .unwrap(); + assert_eq!( + runtime + .phase1_prepare_native_upload(input(99)) + .await + .err() + .unwrap(), + Phase1DraftError::RevisionConflict + ); + assert_eq!(signer.count(), 0); + signer.pause.store(true, Ordering::SeqCst); + let task = { + let runtime = runtime.clone(); + let input = input(1); + tokio::spawn(async move { + if foreground { + runtime + .phase1_upload_add_media_intent(input) + .await + .map(|_| ()) + } else { + runtime + .phase1_prepare_native_upload(input) + .await + .map(|_| ()) + } + }) + }; + tokio::time::timeout(Duration::from_secs(5), signer.entered.notified()) + .await + .unwrap(); + let reserved = runtime.phase1_draft_status([84; 16]).await.unwrap(); + assert_eq!(reserved.media()[0].stage(), Phase1MediaStage::Uploading); + let media = serde_json::to_value(&reserved.media()[0]).unwrap(); + assert_eq!( + media["authorization_attempt"]["operation_id"], + json!( + signer.requests.lock().unwrap()[0] + .intent_id() + .operation_id() + .as_bytes() + ) + ); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let revision = reserved.draft().revision().get(); + assert_eq!( + runtime + .phase1_prepare_native_upload(input(revision)) + .await + .err() + .unwrap(), + Phase1DraftError::InvalidMedia + ); + assert_eq!( + runtime + .phase1_upload_add_media_intent(input(revision)) + .await + .err() + .unwrap(), + Phase1DraftError::InvalidMedia + ); + assert_eq!(signer.count(), 1); + assert_eq!( + runtime.phase1_draft_status([84; 16]).await.unwrap().draft(), + reserved.draft() + ); + runtime.shutdown().await.unwrap(); + } +} diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/media_foreground.rs b/core/crates/tera_core/src/runtime/product_surface/submission/media_foreground.rs @@ -20,18 +20,19 @@ impl TeraRuntime { let (mut loaded, _) = self.load_submission_operation(&input.submission).await?; self.require_submission_running(&input.submission).await?; let transaction = self.materialize_submission_media(&loaded, &input, false)?; - let media = loaded.payload.media_mut(&input.reference)?; - // Neither an interrupted transfer nor a possible remote effect grants - // permission for another PUT. Reconcile that attempt before renewal. - if media.stage() == Phase1MediaStage::Uploading || media.orphan().is_some() { - return Err(E::InvalidMedia); - } - media.transition_requested(Phase1MediaStage::Uploading, None)?; let plan = Phase1UploadPlan::derive( phase1_operation_now_unix_ms()?, phase1_new_operation_id()?, phase1_new_operation_id()?, )?; + loaded + .payload + .media_mut(&input.reference)? + .reserve_upload(&plan, &transaction) + .map_err(|_| E::InvalidMedia)?; + // Commit the exact attempt before the first possibly interactive await. + self.save_submission_media(&mut loaded).await?; + self.require_submission_running(&input.submission).await?; let authorization = self .authorize_upload_transaction(&transaction, &plan) .await?; @@ -44,7 +45,6 @@ impl TeraRuntime { if blossom.config_fingerprint() != Some(transaction.config_fingerprint()) { return Err(E::MediaPolicyChanged); } - self.save_submission_media(&mut loaded).await?; self.require_submission_running(&input.submission).await?; match blossom .upload( diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/media_runtime.rs b/core/crates/tera_core/src/runtime/product_surface/submission/media_runtime.rs @@ -19,16 +19,19 @@ impl TeraRuntime { let (mut loaded, _) = self.load_submission_operation(&input.submission).await?; self.require_submission_running(&input.submission).await?; let transaction = self.materialize_submission_media(&loaded, &input, false)?; - // Validate the exact transition before invoking a potentially interactive signer. - loaded - .payload - .media_mut(&input.reference)? - .transition_requested(Phase1MediaStage::Uploading, None)?; let plan = Phase1UploadPlan::derive( phase1_operation_now_unix_ms()?, phase1_new_operation_id()?, phase1_new_operation_id()?, )?; + loaded + .payload + .media_mut(&input.reference)? + .reserve_upload(&plan, &transaction) + .map_err(|_| E::InvalidMedia)?; + // Commit the exact attempt before the first possibly interactive await. + self.save_submission_media(&mut loaded).await?; + self.require_submission_running(&input.submission).await?; let job = self.authorize_native_upload(&transaction, &plan).await?; self.require_submission_running(&input.submission).await?; let blossom = self @@ -39,7 +42,6 @@ impl TeraRuntime { if blossom.config_fingerprint() != Some(transaction.config_fingerprint()) { return Err(E::MediaPolicyChanged); } - self.save_submission_media(&mut loaded).await?; Ok(( self.submission_operation_status(&input.submission).await?, job, diff --git a/core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs b/core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs @@ -184,6 +184,10 @@ pub(super) async fn relay() -> (String, tokio::task::JoinHandle<serde_json::Valu let value: serde_json::Value = serde_json::from_str(&text).unwrap(); if value[0] == "EVENT" { let event = value[1].clone(); + assert_ne!( + event["kind"], 24242, + "HTTP authority must never reach a relay" + ); socket .send(Message::Text( serde_json::json!(["OK", event["id"], true, ""]) diff --git a/release/provenance.json b/release/provenance.json @@ -2,7 +2,7 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "7b3f92a57ebcc7873349403243098d5dcf993b2dbdab9ed84fea4a50c20baab0", - "ffi_provenance_sha256": "e618e901823221365bdb1045ef6574be49333ab2a570642a838668d77e8c79e7", + "ffi_provenance_sha256": "e22296b2d30d4d3707b6cd8410c8b07d307aad1d8f3936584b2b51b1ffcadf8a", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "b202075f3c34a3a70e8745ca6d6acab6c2cf5e2d6de9a2b2647475d73a8d3f28", @@ -22,7 +22,7 @@ "lib_revision": "963dd8dc99fc5f16ef7d7eea11f8d964770a696e", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "d5c9240384b50f006ee6e9eff886a3775546280e90a05cbe781eaa296632da26", - "tera_ffi_source_tree": "60e714586d3acc430daee705277449abdfebacd2", + "tera_ffi_source_tree": "6eaee343a67b3d16bfc623ff9aadfb9ceb68186f", "xcode_package_lock_sha256": "b6806f710734f8e628d7a7d7b97826d89eea78fba32fa9b464f513bcadd411e2" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -83,6 +83,16 @@ ] }, { + "identifier": "RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS", + "category": "shared_foundation_api", + "occurrences": [ + { + "path": "core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs", + "count": 2 + } + ] + }, + { "identifier": "RADROOTS_LIB_REVISION", "category": "shared_foundation_api", "occurrences": [ @@ -4219,6 +4229,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/submission/capture/media.rs", "count": 1 }, @@ -4227,6 +4241,10 @@ "count": 1 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs", + "count": 4 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/submission/media_foreground_tests.rs", "count": 1 }, @@ -4701,6 +4719,10 @@ "count": 2 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/submission/media_authorization_tests.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/submission/operation_test_support.rs", "count": 1 } @@ -4793,6 +4815,10 @@ "count": 3 }, { + "path": "core/crates/tera_core/src/runtime/product_surface/outbox/upload_attempt.rs", + "count": 1 + }, + { "path": "core/crates/tera_core/src/runtime/product_surface/settings.rs", "count": 23 },