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}