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