1use std::fs::File;
23use std::io::{self, Read, Seek, Write};
24
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use zip::write::SimpleFileOptions;
28use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
29
30use opys_core::Manifest;
31
32mod blob;
33mod localized;
34mod option;
35
36pub use blob::{blob_id, blob_id_of, BlobSource, Blobs};
37pub use localized::{is_language_tag, Localized};
38pub use option::{Choice, Label, OptionDef, Options};
39
40pub const BUNDLE_FORMAT: u32 = 1;
43
44const HEAD_ENTRY: &str = "opys.json";
45const MANIFEST_ENTRY: &str = "manifest.json";
46
47fn blob_entry(id: &str) -> String {
48 format!("blobs/{id}")
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum BundleError {
53 #[error("not a bundle: {0}")]
54 Zip(#[from] zip::result::ZipError),
55 #[error(transparent)]
56 Io(#[from] io::Error),
57 #[error("not a bundle: it has no `{0}`")]
58 MissingEntry(&'static str),
59 #[error("`{entry}` does not parse: {source}")]
60 Json {
61 entry: &'static str,
62 #[source]
63 source: serde_json::Error,
64 },
65 #[error(
66 "bundle format {found} is not one this reader knows (it reads format {BUNDLE_FORMAT})"
67 )]
68 Format { found: u64 },
69 #[error("the manifest names blob {0}, and nothing holds it")]
70 MissingBlob(String),
71 #[error("blob {id} does not hold what its name says: its bytes hash to {found}")]
72 BlobMismatch { id: String, found: String },
73 #[error("cannot read blob {id} from {path}: {source}")]
74 BlobFile {
75 id: String,
76 path: String,
77 #[source]
78 source: io::Error,
79 },
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87#[serde(try_from = "HeadWire", into = "HeadWire")]
88pub struct Head {
89 pub format: u32,
90 pub locale: Option<String>,
94 pub options: Options,
97}
98
99impl Head {
100 pub fn new(locale: Option<String>, options: Options) -> Result<Self, String> {
103 if let Some(bad) = locale.as_deref().filter(|tag| !is_language_tag(tag)) {
104 return Err(format!("the head's locale, `{bad}`, is not a language tag"));
105 }
106 for text in options.texts() {
107 let Localized::Translated(map) = text else {
108 continue;
109 };
110 let languages = || map.keys().cloned().collect::<Vec<_>>().join("`, `");
111 match &locale {
112 None => {
113 return Err(format!(
114 "a text is translated (`{}`), and the head names no locale to fall back to",
115 languages()
116 ))
117 }
118 Some(locale) if !map.contains_key(locale) => {
119 return Err(format!(
120 "a text is translated (`{}`) and not into the head's locale, `{locale}`",
121 languages()
122 ))
123 }
124 Some(_) => {}
125 }
126 }
127 Ok(Head {
128 format: BUNDLE_FORMAT,
129 locale,
130 options,
131 })
132 }
133}
134
135impl Default for Head {
136 fn default() -> Self {
137 Head {
138 format: BUNDLE_FORMAT,
139 locale: None,
140 options: Options::default(),
141 }
142 }
143}
144
145#[derive(Serialize, Deserialize)]
149pub(crate) struct HeadWire {
150 format: u32,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 locale: Option<String>,
153 #[serde(default, skip_serializing_if = "Options::is_empty")]
154 options: Options,
155}
156
157impl TryFrom<HeadWire> for Head {
158 type Error = String;
159
160 fn try_from(raw: HeadWire) -> Result<Self, Self::Error> {
161 let head = Head::new(raw.locale, raw.options)?;
162 Ok(Head {
163 format: raw.format,
164 ..head
165 })
166 }
167}
168
169impl From<Head> for HeadWire {
170 fn from(head: Head) -> Self {
171 HeadWire {
172 format: head.format,
173 locale: head.locale,
174 options: head.options,
175 }
176 }
177}
178
179#[derive(Deserialize)]
188struct FormatProbe {
189 format: u64,
190}
191
192fn parse_head(bytes: &[u8]) -> Result<Head, BundleError> {
193 let json = |source| BundleError::Json {
194 entry: HEAD_ENTRY,
195 source,
196 };
197 let probe: FormatProbe = serde_json::from_slice(bytes).map_err(json)?;
198 if probe.format != u64::from(BUNDLE_FORMAT) {
199 return Err(BundleError::Format {
200 found: probe.format,
201 });
202 }
203 serde_json::from_slice(bytes).map_err(json)
204}
205
206fn parse_manifest(bytes: &[u8]) -> Result<Manifest, BundleError> {
207 serde_json::from_slice(bytes).map_err(|source| BundleError::Json {
208 entry: MANIFEST_ENTRY,
209 source,
210 })
211}
212
213fn read_entry<R: Read + Seek>(
214 archive: &mut ZipArchive<R>,
215 name: &'static str,
216) -> Result<Vec<u8>, BundleError> {
217 let mut entry = match archive.by_name(name) {
218 Ok(entry) => entry,
219 Err(zip::result::ZipError::FileNotFound) => return Err(BundleError::MissingEntry(name)),
220 Err(other) => return Err(other.into()),
221 };
222 let mut bytes = Vec::with_capacity(entry.size() as usize);
223 entry.read_to_end(&mut bytes)?;
224 Ok(bytes)
225}
226
227pub fn read_bundle_head<R: Read + Seek>(reader: R) -> Result<Head, BundleError> {
230 let mut archive = ZipArchive::new(reader)?;
231 parse_head(&read_entry(&mut archive, HEAD_ENTRY)?)
232}
233
234#[derive(Debug, Clone, PartialEq)]
236pub enum Front {
237 Head(Head),
238 Needs(u64),
241}
242
243const LOCAL_HEADER: [u8; 4] = *b"PK\x03\x04";
244const LOCAL_HEADER_LEN: usize = 30;
245const SIZES_AFTER_DATA: u16 = 1 << 3;
247
248fn not_a_bundle(why: &'static str) -> BundleError {
249 BundleError::Zip(zip::result::ZipError::InvalidArchive(why))
250}
251
252pub fn head_from_front(front: &[u8]) -> Result<Front, BundleError> {
261 let Some(header) = front.get(..LOCAL_HEADER_LEN) else {
262 return Ok(Front::Needs(LOCAL_HEADER_LEN as u64));
263 };
264 if header[..4] != LOCAL_HEADER {
265 return Err(not_a_bundle("it does not begin with a zip entry"));
266 }
267 let short = |at: usize| u16::from_le_bytes([header[at], header[at + 1]]);
268 let long = |at: usize| {
269 u32::from_le_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]])
270 };
271 let (flags, method, size) = (short(6), short(8), u64::from(long(22)));
272 let (name_len, extra_len) = (usize::from(short(26)), usize::from(short(28)));
273
274 let name_end = LOCAL_HEADER_LEN + name_len;
275 let Some(name) = front.get(LOCAL_HEADER_LEN..name_end) else {
276 return Ok(Front::Needs(name_end as u64));
277 };
278 if name != HEAD_ENTRY.as_bytes() {
279 return Err(BundleError::MissingEntry(HEAD_ENTRY));
280 }
281 if method != 0 || flags & SIZES_AFTER_DATA != 0 {
282 return Err(not_a_bundle("its head is not stored"));
283 }
284 let start = name_end + extra_len;
285 let end = start as u64 + size;
286 match usize::try_from(end)
287 .ok()
288 .and_then(|end| front.get(start..end))
289 {
290 Some(head) => parse_head(head).map(Front::Head),
291 None => Ok(Front::Needs(end)),
292 }
293}
294
295pub struct Bundle<R> {
297 manifest: Manifest,
298 archive: ZipArchive<R>,
299}
300
301pub fn open_bundle<R: Read + Seek>(reader: R) -> Result<Bundle<R>, BundleError> {
304 let mut archive = ZipArchive::new(reader)?;
305 parse_head(&read_entry(&mut archive, HEAD_ENTRY)?)?;
308 let manifest = parse_manifest(&read_entry(&mut archive, MANIFEST_ENTRY)?)?;
309 for id in manifest.blob_ids() {
310 if archive.index_for_name(&blob_entry(id)).is_none() {
311 return Err(BundleError::MissingBlob(id.to_owned()));
312 }
313 }
314 Ok(Bundle { manifest, archive })
315}
316
317impl<R: Read + Seek> Bundle<R> {
318 pub fn manifest(&self) -> &Manifest {
319 &self.manifest
320 }
321
322 pub fn into_manifest(self) -> Manifest {
323 self.manifest
324 }
325
326 pub fn copy_blob(&mut self, id: &str, out: &mut impl Write) -> Result<u64, BundleError> {
328 let mut entry = match self.archive.by_name(&blob_entry(id)) {
329 Ok(entry) => entry,
330 Err(zip::result::ZipError::FileNotFound) => {
331 return Err(BundleError::MissingBlob(id.to_owned()))
332 }
333 Err(other) => return Err(other.into()),
334 };
335 Ok(io::copy(&mut entry, out)?)
336 }
337}
338
339struct Hashing<W> {
342 inner: W,
343 hasher: Sha256,
344}
345
346impl<W: Write> Write for Hashing<W> {
347 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
348 let written = self.inner.write(buf)?;
349 self.hasher.update(&buf[..written]);
350 Ok(written)
351 }
352
353 fn flush(&mut self) -> io::Result<()> {
354 self.inner.flush()
355 }
356}
357
358fn entry_options(method: CompressionMethod, size: u64) -> SimpleFileOptions {
361 SimpleFileOptions::default()
362 .compression_method(method)
363 .last_modified_time(DateTime::default())
364 .unix_permissions(0o644)
365 .large_file(size > u64::from(u32::MAX))
366}
367
368fn write_json<W: Write + Seek>(
369 zip: &mut ZipWriter<W>,
370 entry: &'static str,
371 method: CompressionMethod,
372 bytes: &[u8],
373) -> Result<(), BundleError> {
374 zip.start_file(entry, entry_options(method, bytes.len() as u64))?;
375 zip.write_all(bytes)?;
376 Ok(())
377}
378
379pub fn write_bundle<W: Write + Seek>(
388 writer: W,
389 head: &Head,
390 manifest: &Manifest,
391 blobs: &Blobs,
392) -> Result<(), BundleError> {
393 if head.format != BUNDLE_FORMAT {
394 return Err(BundleError::Format {
395 found: u64::from(head.format),
396 });
397 }
398 let json = |entry| move |source| BundleError::Json { entry, source };
399 let mut zip = ZipWriter::new(writer);
400
401 let head = serde_json::to_vec_pretty(head).map_err(json(HEAD_ENTRY))?;
405 write_json(&mut zip, HEAD_ENTRY, CompressionMethod::Stored, &head)?;
406 let body = serde_json::to_vec_pretty(manifest).map_err(json(MANIFEST_ENTRY))?;
407 write_json(&mut zip, MANIFEST_ENTRY, CompressionMethod::Deflated, &body)?;
408
409 for id in manifest.blob_ids() {
410 let source = blobs
411 .get(id)
412 .ok_or_else(|| BundleError::MissingBlob(id.to_owned()))?;
413 let size = match source {
414 BlobSource::Bytes(bytes) => bytes.len() as u64,
415 BlobSource::File(path) => std::fs::metadata(path)
416 .map_err(|source| BundleError::BlobFile {
417 id: id.to_owned(),
418 path: path.display().to_string(),
419 source,
420 })?
421 .len(),
422 };
423 zip.start_file(
424 blob_entry(id),
425 entry_options(CompressionMethod::Deflated, size),
426 )?;
427 let mut hashing = Hashing {
428 inner: &mut zip,
429 hasher: Sha256::new(),
430 };
431 match source {
432 BlobSource::Bytes(bytes) => hashing.write_all(bytes)?,
433 BlobSource::File(path) => {
434 let mut file = File::open(path).map_err(|source| BundleError::BlobFile {
435 id: id.to_owned(),
436 path: path.display().to_string(),
437 source,
438 })?;
439 io::copy(&mut file, &mut hashing)?;
440 }
441 }
442 let found = hex::encode(hashing.hasher.finalize());
443 if found != id {
444 return Err(BundleError::BlobMismatch {
445 id: id.to_owned(),
446 found,
447 });
448 }
449 }
450
451 zip.finish()?;
452 Ok(())
453}