iris-chat 0.1.45

Iris Chat command line client and shared encrypted chat core
Documentation
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;

const IRIS_LOGO_PNG: &[u8] =
    include_bytes!("../../../../android/app/src/main/res/drawable-nodpi/iris_logo.png");
const IRIS_LOGO_SVG: &[u8] = include_bytes!("../../../../assets/iris-chat-logo.svg");

fn serve_one_blossom_upload(status: &str) -> (String, thread::JoinHandle<Vec<u8>>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind local Blossom test server");
    let address = listener.local_addr().expect("local Blossom address");
    let status = status.to_string();
    let server = thread::spawn(move || {
        let (mut stream, _) = listener.accept().expect("accept Blossom upload");
        let mut request = Vec::new();
        let mut buffer = [0_u8; 8192];
        let mut expected_length = None;

        loop {
            let count = stream.read(&mut buffer).expect("read Blossom upload");
            if count == 0 {
                break;
            }
            request.extend_from_slice(&buffer[..count]);

            if expected_length.is_none() {
                if let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
                    let headers = String::from_utf8_lossy(&request[..header_end]);
                    let content_length = headers.lines().find_map(|line| {
                        let (name, value) = line.split_once(':')?;
                        name.eq_ignore_ascii_case("content-length")
                            .then(|| value.trim().parse::<usize>().ok())
                            .flatten()
                    });
                    expected_length = content_length.map(|length| header_end + 4 + length);
                }
            }

            if expected_length.is_some_and(|length| request.len() >= length) {
                break;
            }
        }

        let response =
            format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
        stream
            .write_all(response.as_bytes())
            .expect("write Blossom response");
        request
    });
    (format!("http://{address}"), server)
}

#[tokio::test]
async fn rejected_blossom_upload_is_not_reported_as_stored() {
    let hash = hashtree_core::sha256(IRIS_LOGO_PNG);
    let hash_hex = to_hex(&hash);
    shared_chunk_cache_write().remove(&hash_hex);
    let (server_url, server) = serve_one_blossom_upload("403 Forbidden");
    let store = UploadingBlossomStore::new(nostr::Keys::generate(), vec![], vec![server_url], None);

    let result = store.put(hash, IRIS_LOGO_PNG.to_vec()).await;

    let request = server.join().expect("join local Blossom test server");
    assert!(String::from_utf8_lossy(&request).starts_with("PUT /upload HTTP/1.1"));
    assert!(result.is_err(), "rejected remote upload must fail");
    assert!(!shared_chunk_cache_read().contains_key(&hash_hex));
}

#[tokio::test]
async fn retryable_blossom_failure_is_not_hidden_behind_long_retries() {
    let mut logo_with_marker = IRIS_LOGO_PNG.to_vec();
    logo_with_marker.extend_from_slice(b"-retry-test");
    let hash = hashtree_core::sha256(&logo_with_marker);
    let (server_url, server) = serve_one_blossom_upload("503 Service Unavailable");
    let store = UploadingBlossomStore::new(nostr::Keys::generate(), vec![], vec![server_url], None);

    let result = tokio::time::timeout(
        std::time::Duration::from_secs(5),
        store.put(hash, logo_with_marker),
    )
    .await
    .expect("a failed attachment upload must not enter a long retry loop");

    server.join().expect("join local Blossom test server");
    assert!(
        result.is_err(),
        "retryable remote failure must fail the send"
    );
}

#[tokio::test]
async fn confirmed_blossom_upload_is_cached_with_matching_bytes() {
    let hash = hashtree_core::sha256(IRIS_LOGO_SVG);
    let hash_hex = to_hex(&hash);
    shared_chunk_cache_write().remove(&hash_hex);
    let (server_url, server) = serve_one_blossom_upload("201 Created");
    let progress = Arc::new(AtomicU64::new(0));
    let store = UploadingBlossomStore::new(
        nostr::Keys::generate(),
        vec![],
        vec![server_url],
        Some(progress.clone()),
    );

    let result = store.put(hash, IRIS_LOGO_SVG.to_vec()).await;

    let request = server.join().expect("join local Blossom test server");
    let body_start = request
        .windows(4)
        .position(|part| part == b"\r\n\r\n")
        .expect("upload headers")
        + 4;
    assert_eq!(&request[body_start..], IRIS_LOGO_SVG);
    assert!(result.expect("confirmed remote upload"));
    assert_eq!(
        shared_chunk_cache_read().get(&hash_hex).map(Vec::as_slice),
        Some(IRIS_LOGO_SVG)
    );
    assert_eq!(progress.load(Ordering::Relaxed), IRIS_LOGO_SVG.len() as u64);
}

#[tokio::test]
#[ignore = "publishes an encrypted fixture to the configured Blossom server"]
async fn real_blossom_round_trip_survives_sender_cache_clear() {
    let dir = tempfile::tempdir().expect("attachment tempdir");
    let path = dir.path().join("iris-logo.png");
    fs::write(&path, IRIS_LOGO_PNG).expect("write Iris logo fixture");
    let keys = nostr::Keys::generate();

    let nhash = upload_file_to_hashtree(keys.secret_key().to_secret_hex().as_str(), &path, None)
        .await
        .expect("upload Iris logo to configured Blossom server");

    let uploaded = nhash_decode(&nhash).expect("decode uploaded nhash");
    shared_chunk_cache_write().remove(&to_hex(&uploaded.hash));
    *attachment_blob_store()
        .write()
        .unwrap_or_else(|poison| poison.into_inner()) = None;

    let downloaded = download_hashtree_attachment_base64(&nhash)
        .await
        .expect("download Iris logo without sender cache");
    let downloaded = base64::engine::general_purpose::STANDARD
        .decode(downloaded)
        .expect("decode downloaded logo");
    assert_eq!(downloaded, IRIS_LOGO_PNG);
}