Skip to main content

igc_net/
publish.rs

1//! Publish a raw IGC file to the igc-net network.
2//!
3//! See the igc-net protocol specification for the announcement wire format.
4
5use 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// ── Error type ────────────────────────────────────────────────────────────────
16
17#[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// ── Result type ───────────────────────────────────────────────────────────────
40
41/// Result of a successful publish.
42#[derive(Debug, Clone)]
43pub struct PublishResult {
44    /// 64-char BLAKE3 hex of the raw IGC file.
45    pub igc_hash: Blake3Hex,
46    /// 64-char BLAKE3 hex of the metadata JSON blob.
47    pub meta_hash: Blake3Hex,
48    /// Serialised `BlobTicket` for the raw IGC file.
49    pub igc_ticket: String,
50    /// Serialised `BlobTicket` for the metadata blob.
51    pub meta_ticket: String,
52    /// True when the raw IGC bytes contain at least one G-record line.
53    pub g_record_present: bool,
54}
55
56/// Result of publishing a protected flight.
57#[derive(Debug, Clone)]
58pub struct ProtectedPublishResult {
59    /// 64-char BLAKE3 hex of the raw IGC file.
60    pub raw_igc_hash: Blake3Hex,
61    /// 64-char BLAKE3 hex of the sanitized public IGC artifact.
62    pub protected_hash: Blake3Hex,
63    /// Serialised `BlobTicket` for the sanitized public artifact.
64    pub protected_ticket: String,
65    /// Serialised `BlobTicket` for the raw companion artifact.
66    pub raw_companion_ticket: String,
67    /// True when the raw IGC bytes contain at least one G-record line.
68    pub g_record_present: bool,
69}
70
71/// Result of publishing a private flight existence record.
72#[derive(Debug, Clone)]
73pub struct PrivatePublishResult {
74    /// 64-char BLAKE3 hex of the raw IGC file.
75    pub raw_igc_hash: Blake3Hex,
76    /// Serialised `BlobTicket` for the restricted raw IGC artifact.
77    pub raw_igc_ticket: String,
78    /// True when the raw IGC bytes contain at least one G-record line.
79    pub g_record_present: bool,
80}
81
82// ── publish() ─────────────────────────────────────────────────────────────────
83
84/// Publish a raw IGC file to the igc-net gossip network.
85///
86/// # Steps
87/// 1. BLAKE3(igc_bytes) → `igc_hash`
88/// 2. Derive `g_record_present`
89/// 3. Reuse or build the local metadata blob
90/// 4. Store blobs locally and in iroh-blobs
91/// 5. Broadcast a public artifact announcement
92/// 6. Update local store records
93pub async fn publish(
94    node: &IgcIrohNode,
95    igc_bytes: Vec<u8>,
96    original_filename: Option<&str>,
97) -> Result<PublishResult, PublishError> {
98    // ── 1-2. Compute content hash and signature-presence flag ────────────────
99    let (igc_hash, igc_hash_bytes, g_record_present) = raw_igc_identity(&igc_bytes);
100
101    // ── 3. Reuse existing local metadata when possible ───────────────────────
102    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    // ── 4. Store locally and register with iroh-blobs ────────────────────────
129    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    // ── 5. Build and broadcast artifact announcement ─────────────────────────
136    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    // ── 6. Update local store records ────────────────────────────────────────
152    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
174/// Publish a protected flight to local blob storage and iroh-blobs.
175///
176/// The public announcement points to the sanitized artifact. Raw companion
177/// tickets are returned to the local gRPC caller and omitted from gossip.
178pub 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
220/// Publish a private flight to local blob storage and announce its restricted
221/// raw artifact locator.
222pub 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
253// ── Helpers ───────────────────────────────────────────────────────────────────
254
255fn 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
264/// Import bytes into iroh-blobs so they can be served to peers.
265/// Returns a `BlobTicket` string.
266async fn import_and_ticket(
267    node: &IgcIrohNode,
268    bytes: Vec<u8>,
269    hash_bytes: [u8; 32],
270) -> Result<String, PublishError> {
271    // Add to iroh-blobs — it will compute the BLAKE3 hash internally and store.
272    // We hold a temp_tag to keep the blob alive during this session.
273    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
284/// Create a `BlobTicket` string for a blob already in the iroh-blobs store.
285async 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
303/// Find the `meta_hash` for a known `igc_hash` from the local index.
304fn 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
318/// Apply the normative protected-mode IGC sanitization rewrite table.
319pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
373
374#[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}