tifiles/bundle.rs
1//! B83 and B84 bundles
2//!
3//! Bundle files are supported by TI-Connect CE for sending multiple variables to a calculator
4//! in a single operation. Some other linking software also supports them, but not universally
5//! (older versions in particular don't, since bundle files are relatively new).
6//!
7//! ```
8//! use std::io::Write;
9//! use tifiles::{VariableType, bundle::{Writer, Kind}};
10//!
11//! # fn doit() -> Result<(), Box<dyn std::error::Error>> {
12//! let outf = std::io::Cursor::new(Vec::new());
13//! let mut bundle = Writer::new(Kind::B84, outf);
14//!
15//! // Writes to the bundle append to the most recently started var
16//! bundle.start_var(VariableType::ProtectedProgram, "NOP", false)?;
17//! bundle.write_all(&[0xbb, 0x6d, 0xc9])?;
18//!
19//! bundle.start_var(VariableType::AppVar, "GREETZ", true)?;
20//! bundle.write_all(b"Hello, world!")?;
21//!
22//! // The bundle must be closed to be valid
23//! bundle.close()?;
24//! # Ok(())
25//! # }
26//! # doit().unwrap();
27//! ```
28//!
29//! ## Internals
30//!
31//! Internally, a bundle is a zip archive containing regular variable files and two special
32//! files:
33//!
34//! * METADATA: a plain-text file containing several fields in the format `<name>:<value>\n`:
35//! * bundle_identifier: `TI Bundle`
36//! * bundle_format_version: 1
37//! * bundle_target_device: `83CE` or `84CE`
38//! * bundle_target_type: `CUSTOM` (presumably other values are also understood)
39//! * bundle_comments: anything you like, apparently
40//! * _CHECKSUM: the arithmetic sum of the CRC32 of each individual variable file's uncompressed
41//! data (as fed into the zip writer). This file is a single line of that CRC formatted as a hex
42//! number followed by `\r\n`.
43//!
44//! The order of zip entries appears to matter: variable files must come first, followed by METADATA
45//! and _CHECKSUM in that order.
46
47use std::io::{Cursor, Read, Result as IoResult, Seek, Write};
48use zip::result::ZipError;
49use zip::write::FileOptions;
50
51use zip::ZipWriter;
52
53use crate::{VariableType, Writer as VarWriter};
54
55/// Supported bundle kinds.
56///
57/// A bundle of a given kind has no particular affinity with a given calculator,
58/// but TI-Connect may refuse to transfer a bundle to a calculator if the bundle
59/// kind does not match the calculator.
60pub enum Kind {
61 /// .b83, for TI-83 Premium CE
62 B83,
63 /// .b84, for TI-84+ CE
64 B84,
65}
66
67impl Kind {
68 /// Return the file extension customarily associated with a given bundle kind.
69 pub fn file_extension(&self) -> &'static str {
70 match self {
71 Kind::B83 => "b83",
72 Kind::B84 => "b84",
73 }
74 }
75
76 fn metadata_device_name(&self) -> &'static str {
77 match self {
78 Kind::B83 => "83CE",
79 Kind::B84 => "84CE",
80 }
81 }
82}
83
84/// Writes bundle files.
85///
86/// A bundle contains zero or more variables, which are written using the
87/// [`Write` impl](impl std::io::Write). For each call to [`start_var`](Writer::start_var),
88/// subsequent writes will append to that variable's data.
89///
90/// Users must call [`close`](Writer::close) when done writing all variables
91/// in order to create the required metadata entries and close the archive.
92pub struct Writer<W>
93where
94 W: Write + Seek,
95{
96 kind: Kind,
97 zip: ZipWriter<W>,
98 crc_sum: u32,
99 active_var: Option<(VarWriter<Cursor<Vec<u8>>>, String)>,
100}
101
102impl<W> Writer<W>
103where
104 W: Write + Seek,
105{
106 pub fn new(kind: Kind, writer: W) -> Self {
107 Writer {
108 kind,
109 zip: ZipWriter::new(writer),
110 crc_sum: 0,
111 active_var: None,
112 }
113 }
114
115 /// Begin writing a variable.
116 ///
117 /// Subsequent writes will append to the most recently-started variable.
118 /// Parameters are the same as [`write::Writer::new`](crate::write::Writer::new).
119 pub fn start_var(&mut self, ty: VariableType, name: &str, archived: bool) -> IoResult<()> {
120 // Finish off the previous var, if any
121 self.close_var()?;
122 // Make the new one active
123 self.active_var = Some((
124 VarWriter::new(Cursor::new(Vec::new()), ty, name, archived)?,
125 format!("{}.{}", name, ty.file_extension()),
126 ));
127 Ok(())
128 }
129
130 fn update_crc(&mut self, data: &[u8]) {
131 self.crc_sum = self.crc_sum.wrapping_add(crc32fast::hash(data));
132 }
133
134 fn close_var(&mut self) -> IoResult<()> {
135 // Clear the active var and do nothing if there isn't one
136 let (w, name) = match self.active_var.take() {
137 Some(x) => x,
138 None => return Ok(()),
139 };
140 // Finalize the var file; we needed to buffer it since we can't seek in the zip
141 let buf = w.close()?.into_inner();
142 // and we need the data to get its CRC (even though the zip writer also computes this; it's
143 // hard to get back out of the zip writer)
144 self.update_crc(&buf);
145
146 // Flush buffered data out to a new file within the zip
147 self.zip.start_file(name, FileOptions::default())?;
148 self.zip.write_all(&buf)
149 }
150
151 /// Close the archive, returning the underlying writer.
152 ///
153 /// This must be called in order to make the bundle valid.
154 pub fn close(mut self) -> IoResult<W> {
155 self.close_var()?;
156
157 self.zip.start_file("METADATA", FileOptions::default())?;
158 let metadata_contents =
159 format!(
160 "bundle_identifier:TI Bundle\n\
161 bundle_format_version:1\n\
162 bundle_target_device:{}\n\
163 bundle_target_type:CUSTOM\n\
164 bundle_comments:Generated by tifiles-rs::bundle::Writer\n",
165 self.kind.metadata_device_name()
166 );
167 self.update_crc(metadata_contents.as_bytes());
168 self.zip.write_all(metadata_contents.as_bytes())?;
169
170 self.zip.start_file("_CHECKSUM", FileOptions::default())?;
171 write!(self.zip, "{:x}", self.crc_sum)?;
172
173 match self.zip.finish() {
174 Err(ZipError::Io(e)) => Err(e),
175 Err(o) => unreachable!("zip.finish() can only return IO errors, but got {:?}", o),
176 Ok(w) => Ok(w),
177 }
178 }
179}
180
181impl<W> Write for Writer<W>
182where
183 W: Write + Seek,
184{
185 fn write(&mut self, buf: &[u8]) -> IoResult<usize> {
186 match self.active_var {
187 None => {
188 panic!("start_var must be called on a bundle writer before data can be written")
189 }
190 Some((ref mut v, _)) => v.write(buf),
191 }
192 }
193
194 fn flush(&mut self) -> IoResult<()> {
195 match self.active_var {
196 None => {
197 panic!("start_var must be called on a bundle writer before data can be flushed")
198 }
199 Some((ref mut v, _)) => v.flush(),
200 }
201 }
202}
203
204#[test]
205fn crc_matches_metafile() {
206 let mut w = Writer::new(Kind::B83, Cursor::new(Vec::new()));
207
208 w.start_var(VariableType::AppVar, "A", false).unwrap();
209 write!(w, "var one data").unwrap();
210 w.start_var(VariableType::AppVar, "B", false).unwrap();
211 write!(w, "var two data").unwrap();
212 let data = w.close().unwrap().into_inner();
213
214 let mut zip = zip::ZipArchive::new(Cursor::new(data)).unwrap();
215 let mut actual_crc = 0u32;
216 for i in 0..zip.len() - 1 {
217 let file = zip.by_index(i).unwrap();
218 assert_ne!(file.name(), "_CHECKSUM", "checksum file should be last");
219 actual_crc = actual_crc.wrapping_add(file.crc32());
220 }
221
222 let mut checksum_file = zip.by_name("_CHECKSUM").unwrap();
223 let mut checksum_string = String::new();
224 checksum_file.read_to_string(&mut checksum_string).unwrap();
225
226 assert_eq!(
227 u32::from_str_radix(&checksum_string, 16).unwrap(),
228 actual_crc,
229 "Actual zip CRCs did not match CHECKSUM file"
230 );
231}