1use crate::archive::{ArchiveFileEntry, ArchiveReader, infer_object_id_from_snapshot_path};
2use anyhow::{Context, Result, anyhow};
3use anytype_rpc::anytype::SnapshotWithType;
4use chrono::{DateTime, FixedOffset, Utc};
5use prost::Message;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use std::{
10 fmt::Write as _,
11 fs::File,
12 io::{self, Read, Seek},
13 path::{Path, PathBuf},
14};
15
16pub const MANIFEST_NAME: &str = "manifest.json";
17pub const MANIFEST_SIDECAR_SUFFIX: &str = ".manifest.json";
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ObjectDescriptor {
21 pub id: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub new_id: Option<String>,
24 pub name: Option<String>,
25 pub r#type: Option<String>,
26 pub last_modified: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Manifest {
31 pub schema_version: u32,
32 pub tool: String,
33 pub created_at: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub created_at_display: Option<String>,
36 pub source_space_id: String,
37 pub source_space_name: String,
38 pub format: String,
39 pub object_count: usize,
40 pub objects: Vec<ObjectDescriptor>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub mode: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub since: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub since_display: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub until: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub until_display: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub type_ids: Option<Vec<String>>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub archive_size: Option<u64>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub archive_sha256: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ObjectImportError {
61 pub id: String,
62 pub name: Option<String>,
63 pub r#type: Option<String>,
64 pub last_modified: Option<String>,
65 pub error_code: String,
66 pub message: String,
67 pub status: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ImportReport {
72 pub archive: String,
73 pub space_id: String,
74 pub attempted: usize,
75 pub imported: usize,
76 pub failed: usize,
77 pub success: Vec<ObjectDescriptor>,
78 pub errors: Vec<ObjectImportError>,
79 pub summary: Vec<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub event_progress: Option<ImportEventProgressReport>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ImportEventProgressReport {
86 pub processes_started: usize,
87 pub processes_done: usize,
88 pub process_updates: usize,
89 pub import_finish_events: usize,
90 pub import_finish_objects: i64,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub last_process_id: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub last_process_state: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub last_progress_done: Option<i64>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub last_progress_total: Option<i64>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub last_progress_message: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub last_process_error: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize)]
106pub struct ManifestSummary {
107 pub schema_version: u32,
108 pub tool: String,
109 pub created_at: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub created_at_display: Option<String>,
112 pub source_space_id: String,
113 pub source_space_name: String,
114 pub format: String,
115 pub object_count: usize,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub mode: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub since: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub since_display: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub until: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub until_display: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub type_ids: Option<Vec<String>>,
128}
129
130#[derive(Debug, Clone, Serialize)]
131pub struct ExpandedSnapshotEntry {
132 pub path: String,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub id: Option<String>,
135 pub status: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub unreadable_reason: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub sb_type: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub name: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub object_type: Option<Value>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub layout: Option<i64>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub layout_name: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub archived: Option<bool>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub created_date: Option<Value>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub last_modified_date: Option<Value>,
154}
155
156pub fn proto_value_to_json(value: &prost_types::Value) -> Value {
157 use prost_types::value::Kind;
158 match value.kind.as_ref() {
159 None | Some(Kind::NullValue(_)) => Value::Null,
160 Some(Kind::NumberValue(n)) => {
161 serde_json::Number::from_f64(*n).map_or(Value::Null, Value::Number)
162 }
163 Some(Kind::StringValue(s)) => Value::String(s.clone()),
164 Some(Kind::BoolValue(b)) => Value::Bool(*b),
165 Some(Kind::StructValue(s)) => {
166 let mut map = serde_json::Map::<String, Value>::new();
167 for (k, v) in &s.fields {
168 map.insert(k.clone(), proto_value_to_json(v));
169 }
170 Value::Object(map)
171 }
172 Some(Kind::ListValue(list)) => {
173 Value::Array(list.values.iter().map(proto_value_to_json).collect())
174 }
175 }
176}
177
178pub fn normalize_jsonpb_value(value: &Value) -> Value {
179 let Some(obj) = value.as_object() else {
180 return value.clone();
181 };
182 if let Some(v) = obj.get("stringValue").and_then(Value::as_str) {
183 return Value::String(v.to_string());
184 }
185 if let Some(v) = obj.get("numberValue").and_then(Value::as_f64) {
186 return serde_json::Number::from_f64(v).map_or(Value::Null, Value::Number);
187 }
188 if let Some(v) = obj.get("boolValue").and_then(Value::as_bool) {
189 return Value::Bool(v);
190 }
191 if obj.get("nullValue").is_some() {
192 return Value::Null;
193 }
194 if let Some(fields) = obj
195 .get("structValue")
196 .and_then(Value::as_object)
197 .and_then(|o| o.get("fields"))
198 .and_then(Value::as_object)
199 {
200 let mut out = serde_json::Map::<String, Value>::new();
201 for (k, v) in fields {
202 out.insert(k.clone(), normalize_jsonpb_value(v));
203 }
204 return Value::Object(out);
205 }
206 if let Some(values) = obj
207 .get("listValue")
208 .and_then(Value::as_object)
209 .and_then(|o| o.get("values"))
210 .and_then(Value::as_array)
211 {
212 return Value::Array(values.iter().map(normalize_jsonpb_value).collect());
213 }
214 value.clone()
215}
216
217#[allow(clippy::cast_possible_truncation)]
218pub fn value_as_i64(value: &Value) -> Option<i64> {
219 value
220 .as_i64()
221 .or_else(|| value.as_f64().map(|n| n as i64))
222 .or_else(|| value.as_str().and_then(|s| s.parse::<i64>().ok()))
223}
224
225pub fn value_as_bool(value: &Value) -> Option<bool> {
226 value
227 .as_bool()
228 .or_else(|| value.as_i64().map(|n| n != 0))
229 .or_else(|| {
230 value.as_str().and_then(|s| match s {
231 "true" | "1" => Some(true),
232 "false" | "0" => Some(false),
233 _ => None,
234 })
235 })
236}
237
238pub fn derive_layout_name(layout: Option<i64>) -> Option<String> {
239 let layout = layout?;
240 let parsed =
241 anytype_rpc::model::object_type::Layout::try_from(i32::try_from(layout).ok()?).ok()?;
242 Some(parsed.as_str_name().to_string())
243}
244
245pub fn parse_snapshot_details_from_pb(
246 bytes: &[u8],
247) -> Result<(Option<String>, serde_json::Map<String, Value>)> {
248 let snapshot = SnapshotWithType::decode(bytes).context("failed to decode protobuf snapshot")?;
249 let sb_type = anytype_rpc::model::SmartBlockType::try_from(snapshot.sb_type)
250 .ok()
251 .map(|s| s.as_str_name().to_string());
252 let data = snapshot
253 .snapshot
254 .and_then(|s| s.data)
255 .ok_or_else(|| anyhow!("snapshot payload missing data"))?;
256 let details = data
257 .details
258 .ok_or_else(|| anyhow!("snapshot payload missing details"))?;
259 let mut map = serde_json::Map::new();
260 for (k, v) in &details.fields {
261 map.insert(k.clone(), proto_value_to_json(v));
262 }
263 Ok((sb_type, map))
264}
265
266pub fn parse_snapshot_details_from_pb_json(
267 bytes: &[u8],
268) -> Result<(Option<String>, serde_json::Map<String, Value>)> {
269 let root: Value = serde_json::from_slice(bytes).context("invalid pb-json")?;
270 let sb_type = root
271 .get("sbType")
272 .and_then(Value::as_str)
273 .map(ToString::to_string)
274 .or_else(|| {
275 root.get("sbType")
276 .and_then(Value::as_i64)
277 .and_then(|n| i32::try_from(n).ok())
278 .and_then(|n| anytype_rpc::model::SmartBlockType::try_from(n).ok())
279 .map(|s| s.as_str_name().to_string())
280 });
281 let details = root
282 .get("snapshot")
283 .and_then(|v| v.get("data"))
284 .and_then(|v| v.get("details"))
285 .ok_or_else(|| anyhow!("pb-json snapshot missing snapshot.data.details"))?;
286 let fields = if let Some(fields) = details.get("fields").and_then(Value::as_object) {
287 fields
288 } else {
289 details
290 .as_object()
291 .ok_or_else(|| anyhow!("pb-json snapshot details is not an object"))?
292 };
293 let mut map = serde_json::Map::new();
294 for (k, v) in fields {
295 map.insert(k.clone(), normalize_jsonpb_value(v));
296 }
297 Ok((sb_type, map))
298}
299
300pub fn detail_value<'a>(
301 details: &'a serde_json::Map<String, Value>,
302 key: &str,
303) -> Option<&'a Value> {
304 details.get(key)
305}
306
307pub fn build_expanded_entry_from_details(
308 path: &str,
309 id_from_path: Option<String>,
310 sb_type: Option<String>,
311 details: &serde_json::Map<String, Value>,
312) -> ExpandedSnapshotEntry {
313 let id = detail_value(details, "id")
314 .and_then(Value::as_str)
315 .map(ToString::to_string)
316 .or(id_from_path);
317 let name = detail_value(details, "name")
318 .and_then(Value::as_str)
319 .map(ToString::to_string);
320 let object_type = detail_value(details, "type").cloned();
321 let layout = detail_value(details, "layout")
322 .and_then(value_as_i64)
323 .or_else(|| detail_value(details, "resolvedLayout").and_then(value_as_i64));
324 let layout_name = derive_layout_name(layout);
325 let archived = detail_value(details, "isArchived").and_then(value_as_bool);
326 let created_date = detail_value(details, "createdDate").cloned();
327 let last_modified_date = detail_value(details, "lastModifiedDate").cloned();
328 ExpandedSnapshotEntry {
329 path: path.to_string(),
330 id,
331 status: "ok".to_string(),
332 unreadable_reason: None,
333 sb_type,
334 name,
335 object_type,
336 layout,
337 layout_name,
338 archived,
339 created_date,
340 last_modified_date,
341 }
342}
343
344pub fn parse_expanded_entries(
345 reader: &ArchiveReader,
346 files: &[ArchiveFileEntry],
347) -> Vec<ExpandedSnapshotEntry> {
348 let mut out = Vec::new();
349 for file in files {
350 let lower = file.path.to_ascii_lowercase();
351 #[allow(clippy::case_sensitive_file_extension_comparisons)]
353 if !(lower.ends_with(".pb") || lower.ends_with(".pb.json")) {
354 continue;
355 }
356 let id_from_path = infer_object_id_from_snapshot_path(&file.path);
357 let bytes = match reader.read_bytes(&file.path) {
358 Ok(bytes) => bytes,
359 Err(err) => {
360 out.push(ExpandedSnapshotEntry {
361 path: file.path.clone(),
362 id: id_from_path,
363 status: "unreadable".to_string(),
364 unreadable_reason: Some(format!("failed to read file: {err}")),
365 sb_type: None,
366 name: None,
367 object_type: None,
368 layout: None,
369 layout_name: None,
370 archived: None,
371 created_date: None,
372 last_modified_date: None,
373 });
374 continue;
375 }
376 };
377
378 let parsed = if lower.ends_with(".pb.json") {
379 parse_snapshot_details_from_pb_json(&bytes)
380 } else {
381 parse_snapshot_details_from_pb(&bytes)
382 };
383 match parsed {
384 Ok((sb_type, details)) => {
385 out.push(build_expanded_entry_from_details(
386 &file.path,
387 id_from_path,
388 sb_type,
389 &details,
390 ));
391 }
392 Err(err) => {
393 out.push(ExpandedSnapshotEntry {
394 path: file.path.clone(),
395 id: id_from_path,
396 status: "unreadable".to_string(),
397 unreadable_reason: Some(err.to_string()),
398 sb_type: None,
399 name: None,
400 object_type: None,
401 layout: None,
402 layout_name: None,
403 archived: None,
404 created_date: None,
405 last_modified_date: None,
406 });
407 }
408 }
409 }
410 out
411}
412
413pub fn read_manifest_from_reader(reader: &ArchiveReader) -> (Option<Manifest>, Option<String>) {
414 let Ok(Some(manifest_bytes)) = reader.read_bytes_if_exists(MANIFEST_NAME) else {
415 return (None, None);
416 };
417 match serde_json::from_slice::<Manifest>(&manifest_bytes) {
418 Ok(manifest) => (Some(manifest), None),
419 Err(err) => (None, Some(format!("invalid manifest json: {err}"))),
420 }
421}
422
423pub fn manifest_sidecar_path(archive_path: &Path) -> PathBuf {
424 let base_name = archive_path
425 .file_name()
426 .and_then(|v| v.to_str())
427 .unwrap_or("archive");
428 let sidecar_name = format!("{base_name}{MANIFEST_SIDECAR_SUFFIX}");
429 archive_path
430 .parent()
431 .unwrap_or_else(|| Path::new("."))
432 .join(sidecar_name)
433}
434
435pub fn read_manifest_from_sidecar(archive_path: &Path) -> (Option<Manifest>, Option<String>) {
436 let sidecar = manifest_sidecar_path(archive_path);
437 let bytes = match std::fs::read(&sidecar) {
438 Ok(bytes) => bytes,
439 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (None, None),
440 Err(err) => {
441 return (
442 None,
443 Some(format!(
444 "failed to read sidecar manifest {}: {err}",
445 sidecar.display()
446 )),
447 );
448 }
449 };
450 match serde_json::from_slice::<Manifest>(&bytes) {
451 Ok(manifest) => match validate_archive_binding(archive_path, &manifest) {
452 Ok(()) => (Some(manifest), None),
453 Err(error) => (None, Some(error)),
454 },
455 Err(err) => (
456 None,
457 Some(format!(
458 "invalid sidecar manifest {}: {err}",
459 sidecar.display()
460 )),
461 ),
462 }
463}
464
465pub fn archive_binding(path: &Path) -> io::Result<(u64, String)> {
466 let mut file = File::open(path)?;
467 archive_binding_from_file(&mut file)
468}
469
470pub fn archive_binding_from_file(file: &mut File) -> io::Result<(u64, String)> {
471 file.rewind()?;
472 let mut size = 0_u64;
473 let mut digest = Sha256::new();
474 let mut buffer = vec![0_u8; 64 * 1024];
475 loop {
476 let read = file.read(&mut buffer)?;
477 if read == 0 {
478 break;
479 }
480 let read = u64::try_from(read).map_err(|_| io::Error::other("archive size overflow"))?;
481 size = size
482 .checked_add(read)
483 .ok_or_else(|| io::Error::other("archive size overflow"))?;
484 let read = usize::try_from(read).map_err(|_| io::Error::other("archive size overflow"))?;
485 digest.update(&buffer[..read]);
486 }
487 let mut encoded = String::with_capacity(64);
488 for byte in digest.finalize() {
489 write!(&mut encoded, "{byte:02x}")
490 .map_err(|_| io::Error::other("archive digest encoding failed"))?;
491 }
492 file.rewind()?;
493 Ok((size, encoded))
494}
495
496fn validate_archive_binding(
497 archive_path: &Path,
498 manifest: &Manifest,
499) -> std::result::Result<(), String> {
500 match (&manifest.archive_size, &manifest.archive_sha256) {
501 (None, None) => Ok(()),
502 (Some(expected_size), Some(expected_digest)) => {
503 let (actual_size, actual_digest) = archive_binding(archive_path)
504 .map_err(|_| "sidecar archive binding could not be verified".to_string())?;
505 if actual_size != *expected_size || actual_digest != *expected_digest {
506 return Err(
507 "sidecar archive binding does not match the selected archive".to_string(),
508 );
509 }
510 Ok(())
511 }
512 _ => Err("sidecar archive binding is incomplete".to_string()),
513 }
514}
515
516pub fn read_manifest_prefer_sidecar(
517 archive_path: &Path,
518 reader: &ArchiveReader,
519) -> (Option<Manifest>, Option<String>) {
520 let (sidecar_manifest, sidecar_error) = read_manifest_from_sidecar(archive_path);
521 if sidecar_manifest.is_some() || sidecar_error.is_some() {
522 return (sidecar_manifest, sidecar_error);
523 }
524 read_manifest_from_reader(reader)
525}
526
527fn offset_label(offset: FixedOffset) -> String {
528 let seconds = offset.local_minus_utc();
529 if seconds == 0 {
530 return "UTC".to_string();
531 }
532 let sign = if seconds >= 0 { '+' } else { '-' };
533 let abs = seconds.unsigned_abs();
534 let hours = abs / 3600;
535 let minutes = (abs % 3600) / 60;
536 format!("{sign}{hours:02}:{minutes:02}")
537}
538
539fn format_datetime_with_tz(dt: DateTime<FixedOffset>) -> String {
540 format!(
541 "{} {}",
542 dt.format("%Y-%m-%d %H:%M:%S"),
543 offset_label(*dt.offset())
544 )
545}
546
547fn format_utc_datetime_with_tz(dt: DateTime<Utc>) -> String {
548 format!("{} UTC", dt.format("%Y-%m-%d %H:%M:%S"))
549}
550
551pub fn format_datetime_display(value: &str) -> Option<String> {
552 DateTime::parse_from_rfc3339(value)
553 .ok()
554 .map(format_datetime_with_tz)
555}
556
557pub fn format_last_modified(value: Option<&Value>) -> Option<String> {
558 let value = value?;
559 if let Some(text) = value.as_str() {
560 if let Ok(parsed) = DateTime::parse_from_rfc3339(text) {
561 return Some(format_datetime_with_tz(parsed));
562 }
563 return Some(text.to_string());
564 }
565 if let Some(raw) = value.as_i64() {
566 let dt = if raw > 2_000_000_000_000 {
567 DateTime::<Utc>::from_timestamp_millis(raw)
568 } else {
569 DateTime::<Utc>::from_timestamp(raw, 0)
570 };
571 return dt.map(format_utc_datetime_with_tz);
572 }
573 #[allow(clippy::cast_possible_truncation)]
574 if let Some(raw) = value.as_f64() {
575 return format_last_modified(Some(&Value::Number(serde_json::Number::from(raw as i64))));
576 }
577 Some(value.to_string())
578}
579
580pub fn manifest_summary(manifest: &Manifest) -> ManifestSummary {
581 ManifestSummary {
582 schema_version: manifest.schema_version,
583 tool: manifest.tool.clone(),
584 created_at: manifest.created_at.clone(),
585 created_at_display: manifest.created_at_display.clone(),
586 source_space_id: manifest.source_space_id.clone(),
587 source_space_name: manifest.source_space_name.clone(),
588 format: manifest.format.clone(),
589 object_count: manifest.object_count,
590 mode: manifest.mode.clone(),
591 since: manifest.since.clone(),
592 since_display: manifest.since_display.clone(),
593 until: manifest.until.clone(),
594 until_display: manifest.until_display.clone(),
595 type_ids: manifest.type_ids.clone(),
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use std::fs;
602
603 use super::*;
604
605 #[test]
606 fn parse_pb_json_accepts_details_fields_wrapper() {
607 let json = serde_json::json!({
608 "sbType": "Page",
609 "snapshot": {
610 "data": {
611 "details": {
612 "fields": {
613 "name": {"stringValue": "Wrapped"},
614 "layout": {"numberValue": 1.0},
615 "isArchived": {"boolValue": false}
616 }
617 }
618 }
619 }
620 });
621 let bytes = serde_json::to_vec(&json).unwrap();
622 let (sb_type, details) = parse_snapshot_details_from_pb_json(&bytes).unwrap();
623 assert_eq!(sb_type.as_deref(), Some("Page"));
624 assert_eq!(details.get("name").and_then(Value::as_str), Some("Wrapped"));
625 assert_eq!(details.get("layout").and_then(value_as_i64), Some(1));
626 assert_eq!(
627 details.get("isArchived").and_then(value_as_bool),
628 Some(false)
629 );
630 }
631
632 #[test]
633 fn parse_pb_json_accepts_direct_details_object() {
634 let json = serde_json::json!({
635 "sbType": "Page",
636 "snapshot": {
637 "data": {
638 "details": {
639 "id": "bafyreitest",
640 "name": "Templates",
641 "layout": 0,
642 "isArchived": false,
643 "type": "bafyreitype"
644 }
645 }
646 }
647 });
648 let bytes = serde_json::to_vec(&json).unwrap();
649 let (sb_type, details) = parse_snapshot_details_from_pb_json(&bytes).unwrap();
650 assert_eq!(sb_type.as_deref(), Some("Page"));
651 assert_eq!(
652 details.get("name").and_then(Value::as_str),
653 Some("Templates")
654 );
655 assert_eq!(details.get("layout").and_then(value_as_i64), Some(0));
656 assert_eq!(
657 details.get("isArchived").and_then(value_as_bool),
658 Some(false)
659 );
660 assert_eq!(
661 details.get("type").and_then(Value::as_str),
662 Some("bafyreitype")
663 );
664 }
665
666 #[test]
667 fn manifest_sidecar_path_uses_sibling_name() {
668 let zip_path = Path::new("/tmp/archive.zip");
669 assert_eq!(
670 manifest_sidecar_path(zip_path),
671 Path::new("/tmp/archive.zip.manifest.json")
672 );
673
674 let dir_path = Path::new("/tmp/archive-dir");
675 assert_eq!(
676 manifest_sidecar_path(dir_path),
677 Path::new("/tmp/archive-dir.manifest.json")
678 );
679 }
680
681 #[test]
682 fn read_manifest_prefers_sidecar_over_archive_manifest() {
683 let temp = tempfile::tempdir().unwrap();
684 let archive_dir = temp.path().join("backup-dir");
685 fs::create_dir_all(&archive_dir).unwrap();
686
687 fs::write(archive_dir.join(MANIFEST_NAME), b"{not-json").unwrap();
688
689 let sidecar = manifest_sidecar_path(&archive_dir);
690 let manifest = Manifest {
691 schema_version: 1,
692 tool: "anyback/test".to_string(),
693 created_at: "2026-02-16T00:00:00Z".to_string(),
694 created_at_display: None,
695 source_space_id: "space-id".to_string(),
696 source_space_name: "space-name".to_string(),
697 format: "pb".to_string(),
698 object_count: 0,
699 objects: Vec::new(),
700 mode: Some("full".to_string()),
701 since: None,
702 since_display: None,
703 until: None,
704 until_display: None,
705 type_ids: None,
706 archive_size: None,
707 archive_sha256: None,
708 };
709 fs::write(&sidecar, serde_json::to_vec(&manifest).unwrap()).unwrap();
710
711 let reader = ArchiveReader::from_path(&archive_dir).unwrap();
712 let (resolved, err) = read_manifest_prefer_sidecar(&archive_dir, &reader);
713 assert!(err.is_none());
714 assert_eq!(resolved.unwrap().tool, "anyback/test");
715 }
716}