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
411	// shortroomid -> room_id, inverted once so resolving each timeline PDU's
412	// room is a lookup rather than a scan of roomid_shortroomid.
413	let rooms: BTreeMap<u64, OwnedRoomId> = db["roomid_shortroomid"]
414		.stream()
415		.ignore_err()
416		.map(|(room_id, short): (&RoomId, u64)| (short, room_id.to_owned()))
417		.collect()
418		.await;
419
420	warn!("Ensuring stored PDUs carry their room_id field...");
421	let cork = db.cork_and_sync();
422
423	let pduid_pdu = &db["pduid_pdu"];
424	let timeline = pduid_pdu
425		.raw_stream()
426		.ignore_err()
427		.ready_fold((0_usize, 0_usize), |acc, (key, value)| {
428			tally(acc, inject_room_id(pduid_pdu, key, value, |_| pduid_room(&rooms, key)))
429		})
430		.await;
431
432	let outlier = &db["eventid_outlierpdu"];
433	let outliers = outlier
434		.raw_stream()
435		.ignore_err()
436		.ready_fold((0_usize, 0_usize), |acc, (key, value)| {
437			tally(acc, inject_room_id(outlier, key, value, |pdu| outlier_room(key, pdu)))
438		})
439		.await;
440
441	drop(cork);
442
443	let fixed = timeline.0.saturating_add(outliers.0);
444	let skipped = timeline.1.saturating_add(outliers.1);
445	if skipped > 0 {
446		warn!(%fixed, %skipped, "Injected room_id into stored PDUs; some were skipped");
447	} else {
448		info!(%fixed, "Ensured stored PDUs carry room_id");
449	}
450
451	Ok(())
452}
453
454fn tally((fixed, skipped): (usize, usize), result: Result<bool>) -> (usize, usize) {
455	match result {
456		| Ok(true) => (fixed.saturating_add(1), skipped),
457		| Ok(false) => (fixed, skipped),
458		| Err(e) => {
459			debug_warn!(error = %e, "skipping unreconcilable Conduit PDU");
460			(fixed, skipped.saturating_add(1))
461		},
462	}
463}
464
465/// Injects `room_id` into one PDU value that lacks it, sourcing the room from
466/// `resolve`. Returns whether the value was rewritten; `false` means it already
467/// carried a `room_id`. A cheap `HasRoomId` probe short-circuits that common
468/// case, so only the rewritten PDUs pay the full parse and re-serialize.
469fn inject_room_id(
470	map: &Arc<Map>,
471	key: &[u8],
472	value: &[u8],
473	resolve: impl FnOnce(&CanonicalJsonObject) -> Result<OwnedRoomId>,
474) -> Result<bool> {
475	let probe: HasRoomId = serde_json::from_slice(value)
476		.map_err(|e| err!(Database("Conduit PDU is not canonical JSON: {e}")))?;
477
478	if probe.room_id.is_some() {
479		return Ok(false);
480	}
481
482	let mut pdu: CanonicalJsonObject = serde_json::from_slice(value)
483		.map_err(|e| err!(Database("Conduit PDU is not canonical JSON: {e}")))?;
484
485	let room_id = resolve(&pdu)?;
486	pdu.insert("room_id".into(), CanonicalJsonValue::String(room_id.as_str().into()));
487
488	let bytes = serde_json::to_vec(&pdu)
489		.map_err(|e| err!(Database("re-serializing reconciled Conduit PDU: {e}")))?;
490
491	map.insert(key, bytes);
492
493	Ok(true)
494}
495
496/// The room of a `pduid_pdu` entry, from the short room id leading its key.
497fn pduid_room(rooms: &BTreeMap<u64, OwnedRoomId>, key: &[u8]) -> Result<OwnedRoomId> {
498	let short = key
499		.get(..8)
500		.ok_or_else(|| err!(Database("Conduit pduid is shorter than a short room id")))?;
501
502	rooms
503		.get(&utils::u64_from_u8(short))
504		.cloned()
505		.ok_or_else(|| err!(Database("Conduit pduid short room id maps to no room")))
506}
507
508/// The room of an `eventid_outlierpdu` entry that lacks `room_id`. Only a v12
509/// create event omits it, and its room id derives from the create event's own
510/// id, which is this outlier's key.
511fn outlier_room(key: &[u8], pdu: &CanonicalJsonObject) -> Result<OwnedRoomId> {
512	let is_create = matches!(
513		pdu.get("type"),
514		Some(CanonicalJsonValue::String(kind)) if kind == "m.room.create"
515	);
516
517	if !is_create {
518		return Err!(Database("Conduit outlier lacks room_id and is not a create event"));
519	}
520
521	let event_id = <&EventId>::try_from(str::from_utf8(key)?)
522		.map_err(|_| err!(Database("Conduit outlier key is not a valid event id")))?;
523
524	RoomId::new_v2(event_id.localpart())
525		.map_err(|e| err!(Database("deriving room id from create event id: {e}")))
526}
527
528/// Imports Conduit's pending knocks. Conduit names the columns
529/// `roomuserid_knockcount` / `userroomid_knockstate`; tuwunel renamed them to
530/// `*knocked*` but kept the byte layout (`room_id 0xff user_id` -> u64 count;
531/// `user_id 0xff room_id` -> JSON stripped state), so each row copies verbatim.
532/// Imported once: tuwunel clears a knock on the user's join or leave, so a
533/// re-import would resurrect a knock the user has already resolved.
534pub(super) async fn migrate_conduit_knocks(services: &Services) -> Result {
535	let knocks = copy_cf(services, "roomuserid_knockcount", "roomuserid_knockedcount").await?;
536	copy_cf(services, "userroomid_knockstate", "userroomid_knockedstate").await?;
537
538	if knocks > 0 {
539		warn!(%knocks, "Imported Conduit knocks");
540	}
541
542	Ok(())
543}
544
545/// Splits Conduit's conflated highlight-count column. Conduit opens
546/// `roomuserid_lastnotificationread` against the `userroomid_highlightcount`
547/// tree (a copy-paste in its schema), so one column holds both stores:
548/// highlight counts keyed `user_id 0xff room_id` and last-notification-read
549/// tokens keyed `room_id 0xff user_id`. tuwunel keeps the two in separate
550/// columns with those same byte layouts, so every room-keyed (last-read) row
551/// moves verbatim into `roomuserid_lastnotificationread`, leaving the
552/// user-keyed highlight rows in place. The orderings never collide: a user id
553/// leads with `@`, a room id with `!`. Absent any room-keyed row the column is
554/// not aliased, so this returns early and is safe to run on a native database.
555pub(super) async fn migrate_conduit_highlight_split(services: &Services) -> Result {
556	let db = &services.db;
557	let highlight = db["userroomid_highlightcount"].clone();
558
559	// A room-keyed (last-read) row leads with '!'; without one the column is a
560	// plain highlight column needing no split.
561	if pin!(highlight.raw_keys_prefix(b"!"))
562		.next()
563		.await
564		.is_none()
565	{
566		return Ok(());
567	}
568
569	let lastread = db["roomuserid_lastnotificationread"].clone();
570	let cork = db.cork_and_sync();
571	let moved = highlight
572		.raw_stream()
573		.ignore_err()
574		.ready_fold(0_usize, |moved, (key, value)| {
575			if key.first() == Some(&b'!') {
576				lastread.insert(key, value);
577				highlight.remove(key);
578				moved.saturating_add(1)
579			} else {
580				moved
581			}
582		})
583		.await;
584
585	drop(cork);
586
587	if moved > 0 {
588		warn!(%moved, "Split Conduit last-notification-read rows out of the highlight-count column");
589	}
590
591	Ok(())
592}
593
594/// Copies every row of one column verbatim into another whose key and value
595/// share the same byte layout, so neither needs reserialization.
596async fn copy_cf(
597	services: &Services,
598	source_name: &'static str,
599	target_name: &'static str,
600) -> Result<usize> {
601	let db = &services.db;
602	let Some(source) = db.open_cf(source_name)? else {
603		return Ok(0);
604	};
605
606	let target = &db[target_name];
607	let cork = db.cork_and_sync();
608	let copied = source
609		.raw_stream()
610		.ignore_err()
611		.ready_fold(0_usize, |copied, (key, value)| {
612			target.insert(key, value);
613			copied.saturating_add(1)
614		})
615		.await;
616
617	drop(cork);
618
619	Ok(copied)
620}
621
622#[cfg(test)]
623mod tests {
624	use std::path::Path;
625
626	use super::{
627		HasRoomId, conduit_media_key, conduit_media_path, parse_conduit_media_value, sha256_hex,
628	};
629
630	#[test]
631	fn conduit_media_path_deep_matches_conduit_default() {
632		// Conduit default Deep { length: 2, depth: 2 }: two 2-char segments of the
633		// 64-char digest, then the remaining 60 characters.
634		let hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
635		let path = conduit_media_path(Path::new("/db/media"), 2, 2, hex);
636
637		assert_eq!(
638			path,
639			Path::new(
640				"/db/media/01/23/456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
641			)
642		);
643	}
644
645	#[test]
646	fn conduit_media_path_flat_is_unsharded() {
647		let path = conduit_media_path(Path::new("/db/media"), 0, 2, "abcdef");
648
649		assert_eq!(path, Path::new("/db/media/abcdef"));
650	}
651
652	#[test]
653	fn conduit_media_key_deep_joins_shards_with_slash() {
654		// The object key carries no media_dir; the source provider's base_path
655		// supplies any prefix. Same shard segments as the Deep on-disk path.
656		let hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
657		let key = conduit_media_key(2, 2, hex);
658
659		assert_eq!(key, "01/23/456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
660	}
661
662	#[test]
663	fn conduit_media_key_flat_is_bare_digest() {
664		assert_eq!(conduit_media_key(0, 2, "abcdef"), "abcdef");
665	}
666
667	#[test]
668	fn sha256_hex_encodes_lowercase_padded() {
669		assert_eq!(sha256_hex(&[0x00, 0x0F, 0xFF, 0xA5]), "000fffa5");
670	}
671
672	#[test]
673	fn conduit_media_value_ignores_unauthenticated_flag() {
674		// Conduit's media-auth migration appends a trailing 0xff after content_type.
675		let mut value = vec![7_u8; 32];
676		value.extend_from_slice(b"pic.png");
677		value.push(0xFF);
678		value.extend_from_slice(b"image/png");
679		value.push(0xFF);
680
681		let (sha256, filename, content_type) = parse_conduit_media_value(&value).unwrap();
682
683		assert_eq!(sha256, [7_u8; 32].as_slice());
684		assert_eq!(filename, Some("pic.png"));
685		assert_eq!(content_type, Some("image/png"));
686	}
687
688	#[test]
689	fn conduit_media_value_empty_filename_is_none() {
690		let mut value = vec![0_u8; 32];
691		value.push(0xFF);
692		value.extend_from_slice(b"image/png");
693
694		let (_, filename, content_type) = parse_conduit_media_value(&value).unwrap();
695
696		assert_eq!(filename, None);
697		assert_eq!(content_type, Some("image/png"));
698	}
699
700	#[test]
701	fn has_room_id_probe_detects_presence() {
702		let with_room_id = br#"{"room_id":"!r:server","type":"m.room.message"}"#;
703		let without_room_id = br#"{"type":"m.room.create","sender":"@u:server"}"#;
704
705		let present: HasRoomId = serde_json::from_slice(with_room_id).unwrap();
706		let absent: HasRoomId = serde_json::from_slice(without_room_id).unwrap();
707
708		assert!(present.room_id.is_some());
709		assert!(absent.room_id.is_none());
710	}
711}