lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

file_metadata.rs (13681B)


      1 #![cfg(feature = "serde_json")]
      2 
      3 use radroots_events::{
      4     farm_crdt::RadrootsFarmCrdtDocumentKind,
      5     farm_file::{RadrootsFarmFileDimensions, RadrootsFarmFileMetadata, RadrootsFarmFileSource},
      6     farm_workspace::RadrootsFarmWorkspaceRef,
      7     file_metadata::RadrootsFileMetadata,
      8     kinds::{KIND_POST, KIND_PUBLIC_FILE_METADATA},
      9     social::{RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail},
     10     tags::{
     11         TAG_ALT, TAG_DIMENSIONS, TAG_FALLBACK, TAG_MAGNET, TAG_MIME, TAG_ORIGINAL_SHA256,
     12         TAG_SERVICE, TAG_SHA256, TAG_SIZE, TAG_SUMMARY, TAG_THUMB, TAG_URL,
     13     },
     14 };
     15 use radroots_events_codec::{
     16     error::{EventEncodeError, EventParseError},
     17     farm_file::{
     18         decode::farm_file_metadata_from_event, encode::to_wire_parts as farm_file_to_wire_parts,
     19     },
     20     file_metadata::{
     21         decode::{data_from_event, file_metadata_from_event, parsed_from_event},
     22         encode::{file_metadata_build_tags, to_wire_parts, to_wire_parts_with_kind},
     23     },
     24 };
     25 
     26 const VALID_HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
     27 const OTHER_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
     28 
     29 fn sample_metadata() -> RadrootsFileMetadata {
     30     RadrootsFileMetadata {
     31         url: "https://media.example.test/field.jpg".to_string(),
     32         mime_type: "image/jpeg".to_string(),
     33         sha256: VALID_HASH.to_string(),
     34         original_sha256: Some(OTHER_HASH.to_string()),
     35         size: Some(4096),
     36         dimensions: Some(RadrootsSocialMediaDimensions {
     37             width: 1200,
     38             height: 800,
     39         }),
     40         blurhash: Some("L6PZfSi_.AyE_3t7t7R**0o#DgR4".to_string()),
     41         thumbnails: Some(vec![RadrootsSocialMediaThumbnail {
     42             url: "https://media.example.test/field-thumb.jpg".to_string(),
     43             dimensions: Some(RadrootsSocialMediaDimensions {
     44                 width: 320,
     45                 height: 200,
     46             }),
     47         }]),
     48         summary: Some("Field image".to_string()),
     49         alt: Some("Rows of greens after harvest".to_string()),
     50         fallback: Some("https://backup.example.test/field.jpg".to_string()),
     51         magnet: Some("magnet:?xt=urn:btih:example".to_string()),
     52         content_hashes: Some(vec![format!("sha256:{VALID_HASH}")]),
     53         services: Some(vec!["https://media.example.test".to_string()]),
     54         content: Some("Harvest block photo".to_string()),
     55     }
     56 }
     57 
     58 fn sample_farm_file_metadata() -> RadrootsFarmFileMetadata {
     59     RadrootsFarmFileMetadata {
     60         d_tag: "BBBBBBBBBBBBBBBBBBBBBA".to_string(),
     61         workspace: RadrootsFarmWorkspaceRef {
     62             pubkey: "workspace_pubkey".to_string(),
     63             d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
     64         },
     65         farm_group_id: "field-group".to_string(),
     66         owner_document_id: "CCCCCCCCCCCCCCCCCCCCCA".to_string(),
     67         owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask,
     68         caption: Some("Private crop photo".to_string()),
     69         url: "https://media.example.test/private.jpg".to_string(),
     70         mime_type: "image/jpeg".to_string(),
     71         sha256: VALID_HASH.to_string(),
     72         original_sha256: None,
     73         size_bytes: Some(2048),
     74         dimensions: Some(RadrootsFarmFileDimensions { w: 800, h: 600 }),
     75         blurhash: None,
     76         thumb: None,
     77         image: None,
     78         alt: Some("Private rows".to_string()),
     79         fallbacks: Vec::new(),
     80     }
     81 }
     82 
     83 fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
     84     tags.iter().any(|tag| {
     85         tag.first().map(|entry| entry.as_str()) == Some(key)
     86             && tag.get(1).map(|entry| entry.as_str()) == Some(value)
     87     })
     88 }
     89 
     90 #[test]
     91 fn file_metadata_to_wire_parts_roundtrips_nip94_tags() {
     92     let metadata = sample_metadata();
     93     let parts = to_wire_parts(&metadata).unwrap();
     94 
     95     assert_eq!(parts.kind, KIND_PUBLIC_FILE_METADATA);
     96     assert_eq!(parts.content, "Harvest block photo");
     97     assert!(has_tag(
     98         &parts.tags,
     99         TAG_URL,
    100         "https://media.example.test/field.jpg"
    101     ));
    102     assert!(has_tag(&parts.tags, TAG_MIME, "image/jpeg"));
    103     assert!(has_tag(&parts.tags, TAG_SHA256, VALID_HASH));
    104     assert!(has_tag(&parts.tags, TAG_ORIGINAL_SHA256, OTHER_HASH));
    105     assert!(has_tag(&parts.tags, TAG_SIZE, "4096"));
    106     assert!(has_tag(&parts.tags, TAG_DIMENSIONS, "1200x800"));
    107     assert!(has_tag(
    108         &parts.tags,
    109         TAG_THUMB,
    110         "https://media.example.test/field-thumb.jpg"
    111     ));
    112     assert!(has_tag(&parts.tags, TAG_SUMMARY, "Field image"));
    113     assert!(has_tag(
    114         &parts.tags,
    115         TAG_ALT,
    116         "Rows of greens after harvest"
    117     ));
    118     assert!(has_tag(
    119         &parts.tags,
    120         TAG_FALLBACK,
    121         "https://backup.example.test/field.jpg"
    122     ));
    123     assert!(has_tag(
    124         &parts.tags,
    125         TAG_MAGNET,
    126         "magnet:?xt=urn:btih:example"
    127     ));
    128     assert!(has_tag(
    129         &parts.tags,
    130         "i",
    131         format!("sha256:{VALID_HASH}").as_str()
    132     ));
    133     assert!(has_tag(
    134         &parts.tags,
    135         TAG_SERVICE,
    136         "https://media.example.test"
    137     ));
    138 
    139     let decoded = file_metadata_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    140     assert_eq!(decoded.url, "https://media.example.test/field.jpg");
    141     assert_eq!(decoded.mime_type, "image/jpeg");
    142     assert_eq!(decoded.sha256, VALID_HASH);
    143     assert_eq!(decoded.original_sha256.as_deref(), Some(OTHER_HASH));
    144     assert_eq!(decoded.size, Some(4096));
    145     assert_eq!(decoded.dimensions.as_ref().map(|dim| dim.width), Some(1200));
    146     assert_eq!(
    147         decoded
    148             .thumbnails
    149             .as_ref()
    150             .map(|thumbnails| thumbnails[0].url.as_str()),
    151         Some("https://media.example.test/field-thumb.jpg")
    152     );
    153     assert_eq!(decoded.content.as_deref(), Some("Harvest block photo"));
    154 }
    155 
    156 #[test]
    157 fn file_metadata_encode_handles_minimal_optional_shape_and_invalid_dimensions() {
    158     let mut metadata = sample_metadata();
    159     metadata.original_sha256 = None;
    160     metadata.size = None;
    161     metadata.dimensions = None;
    162     metadata.thumbnails = None;
    163     metadata.summary = None;
    164     metadata.alt = None;
    165     metadata.fallback = None;
    166     metadata.magnet = None;
    167     metadata.content_hashes = None;
    168     metadata.services = None;
    169     metadata.content = None;
    170 
    171     let parts = to_wire_parts(&metadata).unwrap();
    172     assert_eq!(parts.content, "");
    173     assert!(
    174         !parts
    175             .tags
    176             .iter()
    177             .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SIZE))
    178     );
    179     assert!(
    180         !parts
    181             .tags
    182             .iter()
    183             .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_DIMENSIONS))
    184     );
    185     assert!(
    186         !parts
    187             .tags
    188             .iter()
    189             .any(|tag| { tag.first().map(|value| value.as_str()) == Some(TAG_ORIGINAL_SHA256) })
    190     );
    191 
    192     let mut metadata = sample_metadata();
    193     metadata.dimensions = Some(RadrootsSocialMediaDimensions {
    194         width: 0,
    195         height: 800,
    196     });
    197     assert!(matches!(
    198         file_metadata_build_tags(&metadata),
    199         Err(EventEncodeError::InvalidField("dimensions"))
    200     ));
    201 
    202     let mut metadata = sample_metadata();
    203     metadata.dimensions = Some(RadrootsSocialMediaDimensions {
    204         width: 1200,
    205         height: 0,
    206     });
    207     assert!(matches!(
    208         file_metadata_build_tags(&metadata),
    209         Err(EventEncodeError::InvalidField("dimensions"))
    210     ));
    211 }
    212 
    213 #[test]
    214 fn file_metadata_public_and_private_kind1063_contracts_do_not_cross_decode() {
    215     let public = to_wire_parts(&sample_metadata()).unwrap();
    216     let decoded_public =
    217         file_metadata_from_event(public.kind, &public.tags, &public.content).unwrap();
    218     assert_eq!(decoded_public.url, "https://media.example.test/field.jpg");
    219     assert!(matches!(
    220         farm_file_metadata_from_event(public.kind, &public.tags, &public.content),
    221         Err(EventParseError::MissingTag("d"))
    222     ));
    223 
    224     let mut private_metadata = sample_farm_file_metadata();
    225     private_metadata.thumb = Some(RadrootsFarmFileSource {
    226         url: "https://media.example.test/private-thumb.jpg".to_string(),
    227         mime_type: Some("image/jpeg".to_string()),
    228         dimensions: Some(RadrootsFarmFileDimensions { w: 320, h: 240 }),
    229     });
    230     private_metadata.image = Some(RadrootsFarmFileSource {
    231         url: "https://media.example.test/private-image.jpg".to_string(),
    232         mime_type: Some("image/jpeg".to_string()),
    233         dimensions: None,
    234     });
    235     let private = farm_file_to_wire_parts(&private_metadata).unwrap();
    236     let decoded_private =
    237         farm_file_metadata_from_event(private.kind, &private.tags, &private.content).unwrap();
    238     assert_eq!(decoded_private.owner_document_id, "CCCCCCCCCCCCCCCCCCCCCA");
    239     assert_eq!(
    240         decoded_private.thumb.and_then(|source| source.dimensions),
    241         Some(RadrootsFarmFileDimensions { w: 320, h: 240 })
    242     );
    243     assert_eq!(
    244         decoded_private
    245             .image
    246             .map(|source| (source.url, source.dimensions)),
    247         Some((
    248             "https://media.example.test/private-image.jpg".to_string(),
    249             None
    250         ))
    251     );
    252     assert!(matches!(
    253         file_metadata_from_event(private.kind, &private.tags, &private.content),
    254         Err(EventParseError::InvalidTag("radroots:owner_document"))
    255     ));
    256 }
    257 
    258 #[test]
    259 fn file_metadata_codec_requires_kind_required_tags_and_hash_shape() {
    260     let mut metadata = sample_metadata();
    261     metadata.url = "ipfs://field.jpg".to_string();
    262     assert!(matches!(
    263         file_metadata_build_tags(&metadata),
    264         Err(EventEncodeError::InvalidField("url"))
    265     ));
    266 
    267     let mut metadata = sample_metadata();
    268     metadata.sha256 = "ABC".to_string();
    269     assert!(matches!(
    270         to_wire_parts(&metadata),
    271         Err(EventEncodeError::InvalidField("sha256"))
    272     ));
    273 
    274     assert!(matches!(
    275         to_wire_parts_with_kind(&sample_metadata(), KIND_POST),
    276         Err(EventEncodeError::InvalidKind(KIND_POST))
    277     ));
    278     assert!(matches!(
    279         file_metadata_from_event(KIND_POST, &[], ""),
    280         Err(EventParseError::InvalidKind {
    281             expected: "1063",
    282             got: KIND_POST
    283         })
    284     ));
    285 
    286     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    287     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_URL));
    288     assert!(matches!(
    289         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    290         Err(EventParseError::MissingTag(TAG_URL))
    291     ));
    292 
    293     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    294     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_MIME));
    295     assert!(matches!(
    296         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    297         Err(EventParseError::MissingTag(TAG_MIME))
    298     ));
    299 
    300     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    301     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_SHA256));
    302     assert!(matches!(
    303         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    304         Err(EventParseError::MissingTag(TAG_SHA256))
    305     ));
    306 
    307     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    308     let hash_tag = tags
    309         .iter_mut()
    310         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SHA256))
    311         .expect("x tag");
    312     hash_tag[1] = "not-a-hash".to_string();
    313     assert!(matches!(
    314         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    315         Err(EventParseError::InvalidTag(TAG_SHA256))
    316     ));
    317 
    318     let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap();
    319     let size_tag = tags
    320         .iter_mut()
    321         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SIZE))
    322         .expect("size tag");
    323     size_tag[1] = "not-a-number".to_string();
    324     assert!(matches!(
    325         file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""),
    326         Err(EventParseError::InvalidNumber(TAG_SIZE, _))
    327     ));
    328 }
    329 
    330 #[test]
    331 fn file_metadata_decode_handles_minimal_public_tags() {
    332     let tags = vec![
    333         vec![
    334             TAG_URL.to_string(),
    335             "https://media.example.test/min.jpg".to_string(),
    336         ],
    337         vec![TAG_MIME.to_string(), "image/jpeg".to_string()],
    338         vec![TAG_SHA256.to_string(), VALID_HASH.to_string()],
    339     ];
    340     let decoded = file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, "").unwrap();
    341 
    342     assert_eq!(decoded.url, "https://media.example.test/min.jpg");
    343     assert_eq!(decoded.mime_type, "image/jpeg");
    344     assert_eq!(decoded.sha256, VALID_HASH);
    345     assert_eq!(decoded.original_sha256, None);
    346     assert_eq!(decoded.size, None);
    347     assert_eq!(decoded.dimensions, None);
    348     assert_eq!(decoded.thumbnails, None);
    349     assert_eq!(decoded.content_hashes, None);
    350     assert_eq!(decoded.services, None);
    351     assert_eq!(decoded.content, None);
    352 }
    353 
    354 #[test]
    355 fn file_metadata_wrappers_preserve_event_metadata() {
    356     let metadata = sample_metadata();
    357     let parts = to_wire_parts(&metadata).unwrap();
    358     let data = data_from_event(
    359         "file_id".to_string(),
    360         "author".to_string(),
    361         90,
    362         parts.kind,
    363         parts.content.clone(),
    364         parts.tags.clone(),
    365     )
    366     .unwrap();
    367 
    368     assert_eq!(data.id, "file_id");
    369     assert_eq!(data.kind, KIND_PUBLIC_FILE_METADATA);
    370     assert_eq!(data.data.url, "https://media.example.test/field.jpg");
    371 
    372     let parsed = parsed_from_event(
    373         "file_id".to_string(),
    374         "author".to_string(),
    375         90,
    376         parts.kind,
    377         parts.content,
    378         parts.tags,
    379         "sig".to_string(),
    380     )
    381     .unwrap();
    382 
    383     assert_eq!(parsed.event.created_at, 90);
    384     assert_eq!(parsed.event.sig, "sig");
    385     assert_eq!(parsed.data.data.sha256, VALID_HASH);
    386 }