1use iroh_blobs::{BlobFormat, Hash};
6
7use crate::artifact_announcement::{ArtifactAnnouncement, ArtifactAnnouncementError};
8use crate::id::{Blake3Hex, NodeIdHex};
9use crate::igc::g_record_present;
10use crate::metadata::{FlightMetadata, MetadataError};
11use crate::node::{IgcIrohNode, NodeError};
12use crate::store::{IndexRecord, IndexRecordSource, PublicationMode};
13use crate::util::canonical_utc_now;
14
15#[derive(Debug, thiserror::Error)]
18pub enum PublishError {
19 #[error("node error: {0}")]
20 Node(#[from] NodeError),
21 #[error("store: {0}")]
22 Store(#[from] crate::store::StoreError),
23 #[error("announcement: {0}")]
24 Announcement(String),
25 #[error("metadata: {0}")]
26 Metadata(#[from] MetadataError),
27 #[error("failed to add blob to iroh store: {0}")]
28 BlobAdd(String),
29 #[error("failed to broadcast announcement: {0}")]
30 Broadcast(String),
31}
32
33impl From<ArtifactAnnouncementError> for PublishError {
34 fn from(error: ArtifactAnnouncementError) -> Self {
35 Self::Announcement(error.to_string())
36 }
37}
38
39#[derive(Debug, Clone)]
43pub struct PublishResult {
44 pub igc_hash: Blake3Hex,
46 pub meta_hash: Blake3Hex,
48 pub igc_ticket: String,
50 pub meta_ticket: String,
52 pub g_record_present: bool,
54}
55
56#[derive(Debug, Clone)]
58pub struct ProtectedPublishResult {
59 pub raw_igc_hash: Blake3Hex,
61 pub protected_hash: Blake3Hex,
63 pub protected_ticket: String,
65 pub raw_companion_ticket: String,
67 pub g_record_present: bool,
69}
70
71#[derive(Debug, Clone)]
73pub struct PrivatePublishResult {
74 pub raw_igc_hash: Blake3Hex,
76 pub raw_igc_ticket: String,
78 pub g_record_present: bool,
80}
81
82pub async fn publish(
94 node: &IgcIrohNode,
95 igc_bytes: Vec<u8>,
96 original_filename: Option<&str>,
97) -> Result<PublishResult, PublishError> {
98 let (igc_hash, igc_hash_bytes, g_record_present) = raw_igc_identity(&igc_bytes);
100
101 let (meta_hash, meta_bytes) = match node
103 .store()
104 .latest_local_publish(&igc_hash, node.node_id())?
105 {
106 Some(existing) => match node.store().get(&existing.meta_hash).await? {
107 Some(meta_bytes) => {
108 tracing::debug!(%igc_hash, meta_hash = %existing.meta_hash, "reusing existing local metadata blob");
109 (existing.meta_hash, meta_bytes)
110 }
111 None => build_metadata_blob(
112 &igc_bytes,
113 igc_hash.clone(),
114 original_filename,
115 node.node_id().clone(),
116 )?,
117 },
118 None => build_metadata_blob(
119 &igc_bytes,
120 igc_hash.clone(),
121 original_filename,
122 node.node_id().clone(),
123 )?,
124 };
125 let meta_hash_blake3 = blake3::hash(&meta_bytes);
126 let meta_hash_bytes = *meta_hash_blake3.as_bytes();
127
128 node.store().put(&igc_bytes).await?;
130 node.store().put(&meta_bytes).await?;
131
132 let igc_ticket = import_and_ticket(node, igc_bytes.clone(), igc_hash_bytes).await?;
133 let meta_ticket = import_and_ticket(node, meta_bytes.clone(), meta_hash_bytes).await?;
134
135 let announcement = ArtifactAnnouncement::signed(
137 &node.node_secret_key(),
138 igc_hash.clone(),
139 PublicationMode::Public,
140 vec![igc_ticket.clone()],
141 node.node_id().clone(),
142 None,
143 Vec::new(),
144 Some(g_record_present),
145 canonical_utc_now(),
146 )?;
147 broadcast_artifact_announcement(node, &announcement).await?;
148
149 tracing::info!(%igc_hash, %meta_hash, "published flight");
150
151 let recorded_at = canonical_utc_now();
153 node.store()
154 .append_index_if_absent(&IndexRecord {
155 source: IndexRecordSource::LocalPublish,
156 igc_hash: igc_hash.clone(),
157 meta_hash: meta_hash.clone(),
158 node_id: node.node_id().clone(),
159 igc_ticket: igc_ticket.clone(),
160 meta_ticket: meta_ticket.clone(),
161 recorded_at,
162 })
163 .await?;
164
165 Ok(PublishResult {
166 igc_hash,
167 meta_hash,
168 igc_ticket,
169 meta_ticket,
170 g_record_present,
171 })
172}
173
174pub async fn publish_protected(
179 node: &IgcIrohNode,
180 igc_bytes: Vec<u8>,
181) -> Result<ProtectedPublishResult, PublishError> {
182 let (raw_igc_hash, raw_igc_hash_bytes, g_record_present) = raw_igc_identity(&igc_bytes);
183
184 let sanitized_igc_bytes = sanitize_protected_igc(&igc_bytes);
185 let protected_hash_blake3 = blake3::hash(&sanitized_igc_bytes);
186 let protected_hash_bytes = *protected_hash_blake3.as_bytes();
187 let protected_hash = Blake3Hex::from_hash(protected_hash_blake3);
188
189 node.store().put(&igc_bytes).await?;
190 node.store().put(&sanitized_igc_bytes).await?;
191
192 let protected_ticket =
193 import_and_ticket(node, sanitized_igc_bytes, protected_hash_bytes).await?;
194 let raw_companion_ticket = import_and_ticket(node, igc_bytes, raw_igc_hash_bytes).await?;
195
196 let announcement = ArtifactAnnouncement::signed(
197 &node.node_secret_key(),
198 raw_igc_hash.clone(),
199 PublicationMode::Protected,
200 vec![protected_ticket.clone()],
201 node.node_id().clone(),
202 Some(protected_hash.clone()),
203 Vec::new(),
204 Some(g_record_present),
205 canonical_utc_now(),
206 )?;
207 broadcast_artifact_announcement(node, &announcement).await?;
208
209 tracing::info!(%raw_igc_hash, %protected_hash, "published protected flight");
210
211 Ok(ProtectedPublishResult {
212 raw_igc_hash,
213 protected_hash,
214 protected_ticket,
215 raw_companion_ticket,
216 g_record_present,
217 })
218}
219
220pub async fn publish_private(
223 node: &IgcIrohNode,
224 igc_bytes: Vec<u8>,
225) -> Result<PrivatePublishResult, PublishError> {
226 let (raw_igc_hash, raw_igc_hash_bytes, g_record_present) = raw_igc_identity(&igc_bytes);
227
228 node.store().put(&igc_bytes).await?;
229 let raw_igc_ticket = import_and_ticket(node, igc_bytes, raw_igc_hash_bytes).await?;
230
231 let announcement = ArtifactAnnouncement::signed(
232 &node.node_secret_key(),
233 raw_igc_hash.clone(),
234 PublicationMode::Private,
235 vec![raw_igc_ticket.clone()],
236 node.node_id().clone(),
237 None,
238 Vec::new(),
239 Some(g_record_present),
240 canonical_utc_now(),
241 )?;
242 broadcast_artifact_announcement(node, &announcement).await?;
243
244 tracing::info!(%raw_igc_hash, "published private flight");
245
246 Ok(PrivatePublishResult {
247 raw_igc_hash,
248 raw_igc_ticket,
249 g_record_present,
250 })
251}
252
253fn raw_igc_identity(igc_bytes: &[u8]) -> (Blake3Hex, [u8; 32], bool) {
256 let hash = blake3::hash(igc_bytes);
257 (
258 Blake3Hex::from_hash(hash),
259 *hash.as_bytes(),
260 g_record_present(igc_bytes),
261 )
262}
263
264async fn import_and_ticket(
267 node: &IgcIrohNode,
268 bytes: Vec<u8>,
269 hash_bytes: [u8; 32],
270) -> Result<String, PublishError> {
271 let _tag = node
274 .fs_store
275 .blobs()
276 .add_bytes(bytes)
277 .temp_tag()
278 .await
279 .map_err(|e| PublishError::BlobAdd(e.to_string()))?;
280
281 make_ticket(node, hash_bytes).await
282}
283
284async fn make_ticket(node: &IgcIrohNode, hash_bytes: [u8; 32]) -> Result<String, PublishError> {
286 let hash = Hash::from_bytes(hash_bytes);
287 let addr = node.endpoint.addr();
288 let ticket = iroh_blobs::ticket::BlobTicket::new(addr, hash, BlobFormat::Raw);
289 Ok(ticket.to_string())
290}
291
292async fn broadcast_artifact_announcement(
293 node: &IgcIrohNode,
294 ann: &ArtifactAnnouncement,
295) -> Result<(), PublishError> {
296 let announcement_bytes = ann.to_gossip_bytes()?;
297 node.announce_sender()
298 .broadcast(announcement_bytes.into())
299 .await
300 .map_err(|e| PublishError::Broadcast(e.to_string()))
301}
302
303fn build_metadata_blob(
305 igc_bytes: &[u8],
306 igc_hash: Blake3Hex,
307 original_filename: Option<&str>,
308 node_id: NodeIdHex,
309) -> Result<(Blake3Hex, Vec<u8>), PublishError> {
310 let meta =
311 FlightMetadata::from_igc_bytes(igc_bytes, igc_hash, original_filename, Some(node_id));
312 meta.validate()?;
313 let meta_bytes = meta.to_blob_bytes()?;
314 let meta_hash = Blake3Hex::from_hash(blake3::hash(&meta_bytes));
315 Ok((meta_hash, meta_bytes))
316}
317
318pub fn sanitize_protected_igc(input: &[u8]) -> Vec<u8> {
320 let mut output = Vec::with_capacity(input.len());
321 let mut start = 0usize;
322
323 while start < input.len() {
324 let mut end = start;
325 while end < input.len() && input[end] != b'\n' {
326 end += 1;
327 }
328 if end < input.len() {
329 end += 1;
330 }
331 rewrite_igc_line(&input[start..end], &mut output);
332 start = end;
333 }
334
335 output
336}
337
338fn rewrite_igc_line(line: &[u8], output: &mut Vec<u8>) {
339 let (body, ending) = match line.strip_suffix(b"\r\n") {
340 Some(body) => (body, &b"\r\n"[..]),
341 None => match line.strip_suffix(b"\n") {
342 Some(body) => (body, &b"\n"[..]),
343 None => (line, &b""[..]),
344 },
345 };
346
347 if let Some(prefix) = protected_rewrite_prefix(body) {
348 output.extend_from_slice(prefix);
349 output.extend_from_slice(b":REDACTED");
350 output.extend_from_slice(ending);
351 } else {
352 output.extend_from_slice(line);
353 }
354}
355
356fn protected_rewrite_prefix(line_body: &[u8]) -> Option<&'static [u8]> {
357 const PREFIXES: [&[u8]; 7] = [
358 b"HFPLT",
359 b"HFCID",
360 b"HFGID",
361 b"HFRFW",
362 b"HFFTYFRTYPE",
363 b"HOPLT",
364 b"HOCID",
365 ];
366
367 PREFIXES
368 .into_iter()
369 .find(|prefix| line_body.starts_with(prefix))
370}
371
372#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::artifact_announcement::signing_payload;
378 use crate::id::{Blake3Hex, NodeIdHex};
379
380 #[test]
381 fn build_metadata_blob_produces_canonical_metadata() {
382 let (meta_hash, meta_bytes) = build_metadata_blob(
383 b"HFDTE020714\r\nB1300004730000N00837000EA0030003000\r\n",
384 Blake3Hex::parse("a".repeat(64)).unwrap(),
385 Some("test.igc"),
386 NodeIdHex::parse("c".repeat(64)).unwrap(),
387 )
388 .unwrap();
389 assert_eq!(meta_hash.len(), 64);
390 let meta: FlightMetadata = serde_json::from_slice(&meta_bytes).unwrap();
391 assert_eq!(meta.schema, "igc-net/metadata");
392 assert!(meta.validate().is_ok());
393 }
394
395 #[test]
396 fn sanitize_protected_igc_rewrites_only_listed_headers_and_preserves_endings() {
397 let input = b"HFPLTPILOT:Alice\r\nHFCIDCOMPETITION:ABC\nHFDTE020714\r\nB1300004730000N00837000EA0030003000\r\nLXXXHFPLTKEEP\r\nHOCIDXYZ";
398
399 let sanitized = sanitize_protected_igc(input);
400
401 assert_eq!(
402 sanitized,
403 b"HFPLT:REDACTED\r\nHFCID:REDACTED\nHFDTE020714\r\nB1300004730000N00837000EA0030003000\r\nLXXXHFPLTKEEP\r\nHOCID:REDACTED"
404 );
405 }
406
407 #[test]
408 fn sanitize_protected_igc_preserves_line_count() {
409 let input = b"HFPLTPILOT:Alice\nHFGIDGLIDER:XYZ\nB1300004730000N00837000EA0030003000\n";
410 let sanitized = sanitize_protected_igc(input);
411
412 assert_eq!(
413 input.iter().filter(|byte| **byte == b'\n').count(),
414 sanitized.iter().filter(|byte| **byte == b'\n').count()
415 );
416 }
417
418 #[test]
419 fn protected_artifact_announcement_uses_mode_aware_shape_and_node_signature() {
420 let node_key = iroh::SecretKey::from_bytes(&[7; 32]);
421 let raw_igc_hash = Blake3Hex::parse("a".repeat(64)).unwrap();
422 let protected_hash = Blake3Hex::parse("b".repeat(64)).unwrap();
423 let node_id = NodeIdHex::from_public_key(node_key.public());
424
425 let announcement = ArtifactAnnouncement::signed(
426 &node_key,
427 raw_igc_hash.clone(),
428 PublicationMode::Protected,
429 vec!["protected-ticket".to_string()],
430 node_id.clone(),
431 Some(protected_hash.clone()),
432 vec!["raw-companion-ticket".to_string()],
433 Some(true),
434 "2026-05-01T09:14:00Z".to_string(),
435 )
436 .unwrap();
437
438 assert_eq!(announcement.schema, "igc-net/announcement");
439 assert_eq!(announcement.raw_igc_hash, raw_igc_hash);
440 assert_eq!(announcement.publication_mode, PublicationMode::Protected);
441 assert_eq!(announcement.tickets, vec!["protected-ticket"]);
442 assert_eq!(announcement.protected_hash, Some(protected_hash));
443 assert_eq!(announcement.companion_tickets, vec!["raw-companion-ticket"]);
444
445 let bytes = announcement.to_gossip_bytes().unwrap();
446 let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
447 assert!(value.get("igc_ticket").is_none());
448 assert!(value.get("meta_ticket").is_none());
449 assert!(value.get("raw_igc_hash").is_some());
450
451 let signing_bytes = signing_payload(
452 &announcement.record_id,
453 &announcement.raw_igc_hash,
454 &announcement.publication_mode,
455 &announcement.tickets,
456 &announcement.node_id,
457 announcement.protected_hash.as_ref(),
458 &announcement.companion_tickets,
459 announcement.g_record_present,
460 &announcement.created_at,
461 )
462 .unwrap();
463 let signature_bytes: [u8; 64] = hex::decode(&announcement.signature)
464 .unwrap()
465 .try_into()
466 .unwrap();
467 let signature = iroh::Signature::from_bytes(&signature_bytes);
468 node_key
469 .public()
470 .verify(&signing_bytes, &signature)
471 .unwrap();
472 assert_eq!(announcement.node_id, node_id);
473 }
474}