//! M10: fail-closed file transfer — frames, names, assemble, slash parse. use onionwire::dispatch::{Kind, classify}; use onionwire::file::{self, MAX_BYTES}; use onionwire::tui::{SlashCmd, parse_cmd}; #[test] fn encode_decode_roundtrip_one_chunk() { let body = b"hello file"; let chunks = file::chunks("note.txt", body).unwrap(); assert_eq!(chunks.len(), 1); let encoded = file::encode(&chunks[0]); let decoded = file::decode(&encoded).expect("decode"); assert_eq!(decoded.filename, "note.txt"); assert_eq!(decoded.idx, 0); assert_eq!(decoded.total, 1); assert_eq!(decoded.data, body); assert_eq!(decoded.sha256, chunks[0].sha256); assert_eq!(decoded.xfer_id, chunks[0].xfer_id); } #[test] fn chat_is_not_file() { assert_eq!(file::decode(b"hello wire"), None); } #[test] fn name_with_dotdot_or_slash_rejected() { assert!(file::chunks("../secret", b"x").is_err()); assert!(file::chunks("a/b", b"x").is_err()); assert!(file::safe_name("..").is_err()); assert!(file::safe_name("foo/bar").is_err()); assert!(file::safe_name("a\0b").is_err()); assert!(file::safe_name("ok.txt").is_ok()); } #[test] fn oversize_rejected_before_send() { let too_big = vec![0u8; MAX_BYTES + 1]; assert!(file::chunks("big.bin", &too_big).is_err()); assert!(file::chunks("ok.bin", &vec![0u8; MAX_BYTES]).is_ok()); } #[test] fn assemble_two_chunks_writes_file_and_matches_hash() { let dir = tempfile::tempdir().unwrap(); let body = vec![7u8; 80_000]; let chunks = file::chunks("pic.bin", &body).unwrap(); assert!(chunks.len() >= 2, "expected split, got {}", chunks.len()); let mut inbox = file::Inbox::new(dir.path()); let fp = "aabbccddeeff"; let mut done = None; for c in &chunks { done = inbox.ingest(fp, c).unwrap(); } let path = done.expect("assembled path"); assert_eq!(std::fs::read(&path).unwrap(), body); assert!(path.ends_with("pic.bin")); assert!(path.to_string_lossy().contains(fp)); } #[test] fn bad_hash_leaves_no_inbox_file() { let dir = tempfile::tempdir().unwrap(); let chunks = file::chunks("evil.bin", b"payload").unwrap(); let mut bad = chunks[0].clone(); bad.sha256 = [0u8; 32]; let mut inbox = file::Inbox::new(dir.path()); let fp = "deadbeef"; assert!(inbox.ingest(fp, &bad).is_err()); let dest = dir.path().join(fp).join("evil.bin"); assert!(!dest.exists(), "hash mismatch must not write inbox file"); let partials: Vec<_> = std::fs::read_dir(dir.path()) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().starts_with(".partial-")) .collect(); assert!( partials.is_empty(), "partial must be deleted on hash mismatch" ); } #[test] fn ingest_rejects_oversize_and_deletes_partial() { let dir = tempfile::tempdir().unwrap(); let mut inbox = file::Inbox::new(dir.path()); let mut chunk = file::chunks("fat.bin", b"x").unwrap().remove(0); chunk.total = 2; chunk.idx = 0; chunk.data = vec![1u8; MAX_BYTES + 1]; assert!(inbox.ingest("aa", &chunk).is_err()); assert!(!dir.path().join("aa").join("fat.bin").exists()); let leftover: Vec<_> = std::fs::read_dir(dir.path()) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().starts_with(".partial-")) .collect(); assert!(leftover.is_empty()); } #[test] fn ingest_rejects_cumulative_oversize() { let dir = tempfile::tempdir().unwrap(); let mut inbox = file::Inbox::new(dir.path()); let mut a = file::chunks("fat.bin", b"x").unwrap().remove(0); a.total = 2; a.idx = 0; a.data = vec![1u8; MAX_BYTES - 10]; assert_eq!(inbox.ingest("aa", &a).unwrap(), None); let mut b = a.clone(); b.idx = 1; b.data = vec![1u8; 11]; assert!(inbox.ingest("aa", &b).is_err()); assert!(!dir.path().join("aa").join("fat.bin").exists()); } #[test] fn slash_file_parses_path_and_empty_is_none() { assert_eq!( parse_cmd("/file /tmp/a"), Some(SlashCmd::File { path: "/tmp/a".into() }) ); assert_eq!(parse_cmd("/file"), None); } #[test] fn dispatch_fil_is_file_not_chat() { assert_eq!(classify(b"fil abc"), Kind::File); assert_eq!(classify(b"hello wire"), Kind::Chat); }