Skip to main content

tuwunel_service/migrations/
conduit.rs

1use std::{
2	collections::BTreeMap,
3	iter::from_fn,
4	path::{Path, PathBuf},
5	pin::pin,
6	sync::Arc,
7	time::Duration,
8};
9
10use bytes::Bytes;
11use futures::{StreamExt, TryStreamExt};
12use object_store::Error as ObjectStoreError;
13use ruma::{
14	CanonicalJsonObject, CanonicalJsonValue, EventId, Mxc, OwnedRoomId, OwnedUserId, RoomId,
15	ServerName, UserId,
16};
17use serde::{Deserialize, de::IgnoredAny};
18use tokio::time::sleep;
19use tuwunel_core::{
20	Err, Error, Result, debug_warn, err, error, info,
21	itertools::Itertools,
22	utils,
23	utils::{ReadyExt, content_disposition::make_content_disposition, stream::TryIgnore},
24	warn,
25};
26use tuwunel_database::{Map, SEP};
27
28use crate::{Services, storage::Provider};
29
30/// Presence probe: `room_id` parses to `Some` when the stored PDU carries it.
31#[derive(Deserialize)]
32struct HasRoomId {
33	room_id: Option<IgnoredAny>,
34}
35
36/// Where a Conduit database kept its original media files: on the local
37/// filesystem (the default), or in an object store named by
38/// `conduit_source_media_provider` for a Conduit that backed its media with S3.
39enum MediaSource {
40	Filesystem(PathBuf),
41	Provider(Arc<Provider>),
42}
43
44/// One parsed `servernamemediaid_metadata` entry, borrowing the raw key/value.
45struct ConduitMediaEntry<'a> {
46	server_name: &'a ServerName,
47	media_id: &'a str,
48	sha256: &'a [u8],
49	filename: Option<&'a str>,
50	content_type: Option<&'a str>,
51}
52
53/// Resolves the configured Conduit media source. A named provider must already
54/// be defined under `[storage_provider]`; its absence is an operator error that
55/// aborts the import rather than silently dropping every file.
56fn media_source(services: &Services) -> Result<MediaSource> {
57	let config = &services.server.config;
58
59	match config.conduit_source_media_provider.as_deref() {
60		| Some(name) => services
61			.storage
62			.provider(name)
63			.map(|provider| MediaSource::Provider(provider.clone())),
64		| None => {
65			let media_dir = config
66				.conduit_source_media_path
67				.clone()
68				.unwrap_or_else(|| config.database_path.join("media"));
69
70			Ok(MediaSource::Filesystem(media_dir))
71		},
72	}
73}
74
75/// Attempts to read each original from a source storage provider before the
76/// import gives up: one initial try plus one retry. The object store performs
77/// its own internal retries under each attempt, so a transient provider blip
78/// rarely reaches this outer limit.
79const PROVIDER_READ_ATTEMPTS: u32 = 2;
80
81/// Pause between provider read retries, giving a transient fault time to clear.
82const PROVIDER_READ_RETRY_DELAY: Duration = Duration::from_secs(2);
83
84/// Imports the original media files of a Conduit database, re-uploading each
85/// `servernamemediaid_metadata` entry through `media.create`.
86///
87/// Malformed entries, unreadable filesystem files, missing provider objects,
88/// and moderator-blocked media are skipped. Persistent source-provider faults,
89/// blocklist read errors, and destination creation failures abort without
90/// setting the completion marker; a restart safely overwrites deterministic
91/// partial metadata and provider objects.
92pub(super) async fn migrate_conduit_media(services: &Services) -> Result {
93	let db = &services.db;
94	let config = &services.server.config;
95
96	let Some(metadata) = db.open_cf("servernamemediaid_metadata")? else {
97		warn!("Conduit database has no media metadata; nothing to import.");
98		return Ok(());
99	};
100
101	let owners = db.open_cf("servernamemediaid_userlocalpart")?;
102	let owners = owners.as_ref();
103
104	let blocklist = db.open_cf("blocked_servername_mediaid")?;
105	let blocklist = blocklist.as_ref();
106
107	let depth = config.conduit_media_directory_depth;
108	let length = config.conduit_media_directory_length;
109	let source = media_source(services)?;
110
111	warn!("Importing Conduit media originals into tuwunel's key-addressed store...");
112
113	let cork = db.cork_and_sync();
114	let (imported, skipped, blocked) = metadata
115		.raw_stream()
116		.ignore_err()
117		.map(Ok::<_, Error>)
118		.try_fold(
119			(0_usize, 0_usize, 0_usize),
120			async |(imported, skipped, blocked), (key, value)| {
121				if conduit_media_blocked(blocklist, key).await? {
122					return Ok((imported, skipped, blocked.saturating_add(1)));
123				}
124
125				let imported_entry =
126					import_conduit_original(services, owners, &source, depth, length, key, value)
127						.await?;
128
129				Ok(if imported_entry {
130					(imported.saturating_add(1), skipped, blocked)
131				} else {
132					(imported, skipped.saturating_add(1), blocked)
133				})
134			},
135		)
136		.await?;
137
138	drop(cork);
139
140	if blocked > 0 {
141		warn!(%blocked, "Skipped Conduit media blocked by a moderator; not imported");
142	}
143
144	if skipped > 0 {
145		warn!(%imported, %skipped, "Imported Conduit media originals; some files were skipped");
146	} else {
147		info!(%imported, "Imported Conduit media originals");
148	}
149
150	Ok(())
151}
152
153/// Imports one `servernamemediaid_metadata` entry and reports whether it was
154/// stored.
155///
156/// `false` denotes an intentional source-entry skip. Destination creation
157/// errors propagate so the caller can withhold the completion marker.
158async fn import_conduit_original(
159	services: &Services,
160	owners: Option<&Arc<Map>>,
161	source: &MediaSource,
162	depth: u8,
163	length: u8,
164	key: &[u8],
165	value: &[u8],
166) -> Result<bool> {
167	let entry = match parse_conduit_media_entry(key, value) {
168		| Ok(entry) => entry,
169		| Err(e) => {
170			debug_warn!(error = %e, "skipping unimportable Conduit media entry");
171			return Ok(false);
172		},
173	};
174
175	let Some(file) = read_conduit_original(source, depth, length, entry.sha256).await? else {
176		return Ok(false);
177	};
178
179	let content_disposition = make_content_disposition(None, entry.content_type, entry.filename);
180	let owner = conduit_media_owner(owners, key, entry.server_name).await;
181	let mxc = Mxc {
182		server_name: entry.server_name,
183		media_id: entry.media_id,
184	};
185
186	services
187		.media
188		.create(&mxc, owner.as_deref(), Some(&content_disposition), entry.content_type, &file)
189		.await?;
190
191	Ok(true)
192}
193
194/// Parses a `servernamemediaid_metadata` entry: the key is
195/// `servername 0xff media_id`, the value is the digest, filename and
196/// content type.
197fn parse_conduit_media_entry<'a>(
198	key: &'a [u8],
199	value: &'a [u8],
200) -> Result<ConduitMediaEntry<'a>> {
201	let Some(sep) = key.iter().position(|&byte| byte == SEP) else {
202		return Err!(Database("Conduit media key has no server-name separator"));
203	};
204	let server_name = <&ServerName>::try_from(str::from_utf8(&key[..sep])?)
205		.map_err(|_| err!(Database("Conduit media key has an invalid server name")))?;
206
207	let media_id = str::from_utf8(&key[sep.saturating_add(1)..])?;
208
209	let (sha256, filename, content_type) = parse_conduit_media_value(value)?;
210
211	Ok(ConduitMediaEntry {
212		server_name,
213		media_id,
214		sha256,
215		filename,
216		content_type,
217	})
218}
219
220/// Reads one Conduit original, named by its content digest, from the configured
221/// media source. A filesystem file that cannot be read is reported as `None`
222/// (skipped, like a dangling metadata row). A source storage provider is
223/// retried on a transient fault; a persistent one returns `Err` so the import
224/// aborts instead of dropping reachable media.
225async fn read_conduit_original(
226	source: &MediaSource,
227	depth: u8,
228	length: u8,
229	sha256: &[u8],
230) -> Result<Option<Bytes>> {
231	let sha256_hex = sha256_hex(sha256);
232	match source {
233		| MediaSource::Filesystem(media_dir) => {
234			let path = conduit_media_path(media_dir, depth, length, &sha256_hex);
235			match tokio::fs::read(&path).await {
236				| Ok(file) => Ok(Some(file.into())),
237				| Err(e) => {
238					debug_warn!(?path, error = %e, "skipping unreadable Conduit media file");
239					Ok(None)
240				},
241			}
242		},
243		| MediaSource::Provider(provider) =>
244			read_provider_original(provider, &conduit_media_key(depth, length, &sha256_hex)).await,
245	}
246}
247
248/// Reads one original from the source storage provider. An absent object is
249/// skipped (`Ok(None)`) like a dangling filesystem row; a transient fault is
250/// retried up to `PROVIDER_READ_ATTEMPTS` times, and a persistent one aborts
251/// the import with an `Err`.
252async fn read_provider_original(provider: &Arc<Provider>, key: &str) -> Result<Option<Bytes>> {
253	let mut attempt = 0_u32;
254	loop {
255		attempt = attempt.saturating_add(1);
256		match provider.get(key).await {
257			| Ok(file) => return Ok(Some(file)),
258			| Err(e) if is_missing_object(&e) => {
259				debug_warn!(%key, error = %e, "skipping missing Conduit media object");
260				return Ok(None);
261			},
262			| Err(e) if attempt >= PROVIDER_READ_ATTEMPTS => {
263				error!(
264					%key,
265					attempts = PROVIDER_READ_ATTEMPTS,
266					error = %e,
267					"Aborting the Conduit media import: source storage provider unreachable. No \
268					 media has been imported in a way that needs cleanup; once the provider is \
269					 reachable, restart tuwunel to resume the import from the beginning."
270				);
271				return Err(e);
272			},
273			| Err(e) => {
274				warn!(%key, attempt, error = %e, "Reading Conduit media object failed; retrying");
275				sleep(PROVIDER_READ_RETRY_DELAY).await;
276			},
277		}
278	}
279}
280
281/// Whether a provider read failed because the object is absent (a 404 or a
282/// dangling metadata row), which is skipped rather than retried. tuwunel's
283/// `Error::is_not_found` does not cover the object-store variant, so match it
284/// directly.
285fn is_missing_object(error: &Error) -> bool {
286	matches!(error, Error::ObjectStore(ObjectStoreError::NotFound { .. }))
287}
288
289/// Splits a `servernamemediaid_metadata` value into its digest, filename, and
290/// content type. The value is `sha256(32) | filename | 0xff | content_type`
291/// with an optional trailing `0xff` that Conduit's media-auth migration appends
292/// to flag unauthenticated access; that flag is ignored.
293fn parse_conduit_media_value(value: &[u8]) -> Result<(&[u8], Option<&str>, Option<&str>)> {
294	let (sha256, rest) = value
295		.split_at_checked(32)
296		.ok_or_else(|| err!(Database("Conduit media value shorter than a SHA-256 digest")))?;
297
298	// Take filename and content_type, ignoring the optional trailing 0xff flag.
299	let mut parts = rest.split(|&byte| byte == SEP);
300	let filename = parts.next().unwrap_or_default();
301	let Some(content_type) = parts.next() else {
302		return Err!(Database("Conduit media value has no content-type separator"));
303	};
304	let filename = str::from_utf8(filename)?;
305	let content_type = str::from_utf8(content_type)?;
306	let filename = (!filename.is_empty()).then_some(filename);
307	let content_type = (!content_type.is_empty()).then_some(content_type);
308
309	Ok((sha256, filename, content_type))
310}
311
312/// The local owner of a Conduit media entry from
313/// `servernamemediaid_userlocalpart`; `None` for remote media, which has no
314/// such entry.
315async fn conduit_media_owner(
316	owners: Option<&Arc<Map>>,
317	key: &[u8],
318	server_name: &ServerName,
319) -> Option<OwnedUserId> {
320	let localpart = owners?.get(key).await.ok()?;
321
322	UserId::parse_with_server_name(str::from_utf8(&localpart).ok()?, server_name).ok()
323}
324
325/// Whether a Conduit media entry was blocked by a moderator. Conduit keeps the
326/// file and refuses it only at read time (`blocked_servername_mediaid`);
327/// tuwunel has no per-media blocklist, so importing a blocked original would
328/// serve it again. The blocklist key is `server_name 0xff media_id`, the same
329/// bytes as the `servernamemediaid_metadata` key, so the entry's raw key probes
330/// it directly. Only a clean miss imports; a hard read error aborts the import
331/// (like an unreachable source) rather than silently re-serving blocked media.
332async fn conduit_media_blocked(blocklist: Option<&Arc<Map>>, key: &[u8]) -> Result<bool> {
333	let Some(blocklist) = blocklist else {
334		return Ok(false);
335	};
336
337	match blocklist.exists(key).await {
338		| Ok(()) => Ok(true),
339		| Err(e) if e.is_not_found() => Ok(false),
340		| Err(e) => Err(e),
341	}
342}
343
344/// Reconstructs the on-disk path of a Conduit content-addressed media file from
345/// the lowercase SHA-256 hex digest naming it, matching Conduit's
346/// `split_media_path`: `media_dir` joined with the digest's shard segments.
347fn conduit_media_path(media_dir: &Path, depth: u8, length: u8, sha256_hex: &str) -> PathBuf {
348	let mut path = media_dir.to_path_buf();
349	path.extend(conduit_shards(depth, length, sha256_hex));
350	path
351}
352
353/// The object-store key of a Conduit content-addressed media object, the same
354/// shard segments as `conduit_media_path` joined by `/`. The source provider's
355/// `base_path` supplies any bucket prefix (Conduit's `media.path`).
356fn conduit_media_key(depth: u8, length: u8, sha256_hex: &str) -> String {
357	conduit_shards(depth, length, sha256_hex).join("/")
358}
359
360/// Splits a lowercase SHA-256 hex digest into Conduit's shard segments: `depth`
361/// segments of `length` characters then the remainder, or the whole digest when
362/// `depth` is zero (a flat layout). `config::check` bounds `depth * length`
363/// below the digest length so the segments never overrun it.
364fn conduit_shards(depth: u8, length: u8, sha256_hex: &str) -> impl Iterator<Item = &str> {
365	let mut rest = Some(sha256_hex);
366	let mut remaining = depth;
367	from_fn(move || {
368		let current = rest?;
369		if remaining == 0 {
370			rest = None;
371			return Some(current);
372		}
373
374		remaining = remaining.saturating_sub(1);
375		match current.split_at_checked(length.into()) {
376			| Some((segment, next)) => {
377				rest = Some(next);
378				Some(segment)
379			},
380			| None => {
381				rest = None;
382				Some(current)
383			},
384		}
385	})
386}
387
388/// Lowercase hex digest, matching the names Conduit gives its media files.
389fn sha256_hex(digest: &[u8]) -> String {
390	const HEX: &[u8; 16] = b"0123456789abcdef";
391
392	let mut out = String::with_capacity(digest.len().saturating_mul(2));
393	for &byte in digest {
394		out.push(char::from(HEX[usize::from(byte >> 4)]));
395		out.push(char::from(HEX[usize::from(byte & 0x0F)]));
396	}
397
398	out
399}
400
401/// Injects `room_id` into stored PDUs that lack it. Runs once on every database
402/// (marker-gated by the caller); a native tuwunel DB always serializes the
403/// field, so it no-ops there. Only a room v12 (`hydra`) create event imported
404/// from Conduit omits it, deriving its room from the event's own id per
405/// MSC4291. Scans the `pduid_pdu` timeline (room from the key's leading short
406/// room id) and `eventid_outlierpdu` (room from the create event's own id, the
407/// outlier key).
408pub(super) async fn migrate_conduit_pdus(services: &Services) -> Result {
409	let db = &services.db;
410	let progress = &services.server.progress;
411
412	// shortroomid -> room_id, inverted once so resolving each timeline PDU's
413	// room is a lookup rather than a scan of roomid_shortroomid.
414	let rooms: BTreeMap<u64, OwnedRoomId> = db["roomid_shortroomid"]
415		.stream()
416		.ignore_err()
417		.map(|(room_id, short): (&RoomId, u64)| (short, room_id.to_owned()))
418		.collect()
419		.await;
420
421	warn!("Ensuring stored PDUs carry their room_id field...");
422	let cork = db.cork_and_sync();
423
424	let pduid_pdu = &db["pduid_pdu"];
425
426	progress.enter("timeline rows");
427	let timeline = pduid_pdu
428		.raw_stream()
429		.ignore_err()
430		.ready_fold((0_usize, 0_usize), |acc, (key, value)| {
431			progress.advance();
432
433			tally(acc, inject_room_id(pduid_pdu, key, value, |_| pduid_room(&rooms, key)))
434		})
435		.await;
436
437	let outlier = &db["eventid_outlierpdu"];
438
439	progress.enter("outlier rows");
440	let outliers = outlier
441		.raw_stream()
442		.ignore_err()
443		.ready_fold((0_usize, 0_usize), |acc, (key, value)| {
444			progress.advance();
445
446			tally(acc, inject_room_id(outlier, key, value, |pdu| outlier_room(key, pdu)))
447		})
448		.await;
449
450	drop(cork);
451
452	let fixed = timeline.0.saturating_add(outliers.0);
453	let skipped = timeline.1.saturating_add(outliers.1);
454	if skipped > 0 {
455		warn!(%fixed, %skipped, "Injected room_id into stored PDUs; some were skipped");
456	} else {
457		info!(%fixed, "Ensured stored PDUs carry room_id");
458	}
459
460	Ok(())
461}
462
463fn tally((fixed, skipped): (usize, usize), result: Result<bool>) -> (usize, usize) {
464	match result {
465		| Ok(true) => (fixed.saturating_add(1), skipped),
466		| Ok(false) => (fixed, skipped),
467		| Err(e) => {
468			debug_warn!(error = %e, "skipping unreconcilable Conduit PDU");
469			(fixed, skipped.saturating_add(1))
470		},
471	}
472}
473
474/// Injects `room_id` into one PDU value that lacks it, sourcing the room from
475/// `resolve`. Returns whether the value was rewritten; `false` means it already
476/// carried a `room_id`. A cheap `HasRoomId` probe short-circuits that common
477/// case, so only the rewritten PDUs pay the full parse and re-serialize.
478fn inject_room_id(
479	map: &Arc<Map>,
480	key: &[u8],
481	value: &[u8],
482	resolve: impl FnOnce(&CanonicalJsonObject) -> Result<OwnedRoomId>,
483) -> Result<bool> {
484	let probe: HasRoomId = serde_json::from_slice(value)
485		.map_err(|e| err!(Database("Conduit PDU is not canonical JSON: {e}")))?;
486
487	if probe.room_id.is_some() {
488		return Ok(false);
489	}
490
491	let mut pdu: CanonicalJsonObject = serde_json::from_slice(value)
492		.map_err(|e| err!(Database("Conduit PDU is not canonical JSON: {e}")))?;
493
494	let room_id = resolve(&pdu)?;
495	pdu.insert("room_id".into(), CanonicalJsonValue::String(room_id.as_str().into()));
496
497	let bytes = serde_json::to_vec(&pdu)
498		.map_err(|e| err!(Database("re-serializing reconciled Conduit PDU: {e}")))?;
499
500	map.insert(key, bytes);
501
502	Ok(true)
503}
504
505/// The room of a `pduid_pdu` entry, from the short room id leading its key.
506fn pduid_room(rooms: &BTreeMap<u64, OwnedRoomId>, key: &[u8]) -> Result<OwnedRoomId> {
507	let short = key
508		.get(..8)
509		.ok_or_else(|| err!(Database("Conduit pduid is shorter than a short room id")))?;
510
511	rooms
512		.get(&utils::u64_from_u8(short))
513		.cloned()
514		.ok_or_else(|| err!(Database("Conduit pduid short room id maps to no room")))
515}
516
517/// The room of an `eventid_outlierpdu` entry that lacks `room_id`. Only a v12
518/// create event omits it, and its room id derives from the create event's own
519/// id, which is this outlier's key.
520fn outlier_room(key: &[u8], pdu: &CanonicalJsonObject) -> Result<OwnedRoomId> {
521	let is_create = matches!(
522		pdu.get("type"),
523		Some(CanonicalJsonValue::String(kind)) if kind == "m.room.create"
524	);
525
526	if !is_create {
527		return Err!(Database("Conduit outlier lacks room_id and is not a create event"));
528	}
529
530	let event_id = <&EventId>::try_from(str::from_utf8(key)?)
531		.map_err(|_| err!(Database("Conduit outlier key is not a valid event id")))?;
532
533	RoomId::new_v2(event_id.localpart())
534		.map_err(|e| err!(Database("deriving room id from create event id: {e}")))
535}
536
537/// Imports Conduit's pending knocks. Conduit names the columns
538/// `roomuserid_knockcount` / `userroomid_knockstate`; tuwunel renamed them to
539/// `*knocked*` but kept the byte layout (`room_id 0xff user_id` -> u64 count;
540/// `user_id 0xff room_id` -> JSON stripped state), so each row copies verbatim.
541/// Imported once: tuwunel clears a knock on the user's join or leave, so a
542/// re-import would resurrect a knock the user has already resolved.
543pub(super) async fn migrate_conduit_knocks(services: &Services) -> Result {
544	let knocks = copy_cf(services, "roomuserid_knockcount", "roomuserid_knockedcount").await?;
545	copy_cf(services, "userroomid_knockstate", "userroomid_knockedstate").await?;
546
547	if knocks > 0 {
548		warn!(%knocks, "Imported Conduit knocks");
549	}
550
551	Ok(())
552}
553
554/// Splits Conduit's conflated highlight-count column. Conduit opens
555/// `roomuserid_lastnotificationread` against the `userroomid_highlightcount`
556/// tree (a copy-paste in its schema), so one column holds both stores:
557/// highlight counts keyed `user_id 0xff room_id` and last-notification-read
558/// tokens keyed `room_id 0xff user_id`. tuwunel keeps the two in separate
559/// columns with those same byte layouts, so every room-keyed (last-read) row
560/// moves verbatim into `roomuserid_lastnotificationread`, leaving the
561/// user-keyed highlight rows in place. The orderings never collide: a user id
562/// leads with `@`, a room id with `!`. Absent any room-keyed row the column is
563/// not aliased, so this returns early and is safe to run on a native database.
564pub(super) async fn migrate_conduit_highlight_split(services: &Services) -> Result {
565	let db = &services.db;
566	let highlight = db["userroomid_highlightcount"].clone();
567
568	// A room-keyed (last-read) row leads with '!'; without one the column is a
569	// plain highlight column needing no split.
570	if pin!(highlight.raw_keys_prefix(b"!"))
571		.next()
572		.await
573		.is_none()
574	{
575		return Ok(());
576	}
577
578	let lastread = db["roomuserid_lastnotificationread"].clone();
579	let cork = db.cork_and_sync();
580	let moved = highlight
581		.raw_stream()
582		.ignore_err()
583		.ready_fold(0_usize, |moved, (key, value)| {
584			if key.first() == Some(&b'!') {
585				lastread.insert(key, value);
586				highlight.remove(key);
587				moved.saturating_add(1)
588			} else {
589				moved
590			}
591		})
592		.await;
593
594	drop(cork);
595
596	if moved > 0 {
597		warn!(%moved, "Split Conduit last-notification-read rows out of the highlight-count column");
598	}
599
600	Ok(())
601}
602
603/// Copies every row of one column verbatim into another whose key and value
604/// share the same byte layout, so neither needs reserialization.
605async fn copy_cf(
606	services: &Services,
607	source_name: &'static str,
608	target_name: &'static str,
609) -> Result<usize> {
610	let db = &services.db;
611	let Some(source) = db.open_cf(source_name)? else {
612		return Ok(0);
613	};
614
615	let target = &db[target_name];
616	let cork = db.cork_and_sync();
617	let copied = source
618		.raw_stream()
619		.ignore_err()
620		.ready_fold(0_usize, |copied, (key, value)| {
621			target.insert(key, value);
622			copied.saturating_add(1)
623		})
624		.await;
625
626	drop(cork);
627
628	Ok(copied)
629}
630
631#[cfg(test)]
632mod tests {
633	use std::path::Path;
634
635	use super::{
636		HasRoomId, conduit_media_key, conduit_media_path, parse_conduit_media_value, sha256_hex,
637	};
638
639	#[test]
640	fn conduit_media_path_deep_matches_conduit_default() {
641		// Conduit default Deep { length: 2, depth: 2 }: two 2-char segments of the
642		// 64-char digest, then the remaining 60 characters.
643		let hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
644		let path = conduit_media_path(Path::new("/db/media"), 2, 2, hex);
645
646		assert_eq!(
647			path,
648			Path::new(
649				"/db/media/01/23/456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
650			)
651		);
652	}
653
654	#[test]
655	fn conduit_media_path_flat_is_unsharded() {
656		let path = conduit_media_path(Path::new("/db/media"), 0, 2, "abcdef");
657
658		assert_eq!(path, Path::new("/db/media/abcdef"));
659	}
660
661	#[test]
662	fn conduit_media_key_deep_joins_shards_with_slash() {
663		// The object key carries no media_dir; the source provider's base_path
664		// supplies any prefix. Same shard segments as the Deep on-disk path.
665		let hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
666		let key = conduit_media_key(2, 2, hex);
667
668		assert_eq!(key, "01/23/456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
669	}
670
671	#[test]
672	fn conduit_media_key_flat_is_bare_digest() {
673		assert_eq!(conduit_media_key(0, 2, "abcdef"), "abcdef");
674	}
675
676	#[test]
677	fn sha256_hex_encodes_lowercase_padded() {
678		assert_eq!(sha256_hex(&[0x00, 0x0F, 0xFF, 0xA5]), "000fffa5");
679	}
680
681	#[test]
682	fn conduit_media_value_ignores_unauthenticated_flag() {
683		// Conduit's media-auth migration appends a trailing 0xff after content_type.
684		let mut value = vec![7_u8; 32];
685		value.extend_from_slice(b"pic.png");
686		value.push(0xFF);
687		value.extend_from_slice(b"image/png");
688		value.push(0xFF);
689
690		let (sha256, filename, content_type) = parse_conduit_media_value(&value).unwrap();
691
692		assert_eq!(sha256, [7_u8; 32].as_slice());
693		assert_eq!(filename, Some("pic.png"));
694		assert_eq!(content_type, Some("image/png"));
695	}
696
697	#[test]
698	fn conduit_media_value_empty_filename_is_none() {
699		let mut value = vec![0_u8; 32];
700		value.push(0xFF);
701		value.extend_from_slice(b"image/png");
702
703		let (_, filename, content_type) = parse_conduit_media_value(&value).unwrap();
704
705		assert_eq!(filename, None);
706		assert_eq!(content_type, Some("image/png"));
707	}
708
709	#[test]
710	fn has_room_id_probe_detects_presence() {
711		let with_room_id = br#"{"room_id":"!r:server","type":"m.room.message"}"#;
712		let without_room_id = br#"{"type":"m.room.create","sender":"@u:server"}"#;
713
714		let present: HasRoomId = serde_json::from_slice(with_room_id).unwrap();
715		let absent: HasRoomId = serde_json::from_slice(without_room_id).unwrap();
716
717		assert!(present.room_id.is_some());
718		assert!(absent.room_id.is_none());
719	}
720}