From 73b0ae791c31cdee17a3ec1e7a73e2d5eb17e816 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Wed, 9 Jul 2025 17:48:13 -0700 Subject: [PATCH] rust: Add support for decrypting encrypted SPSS files. Also, fix a bug in the C implementation of password decoding. --- rust/Cargo.lock | 110 ++- rust/pspp/Cargo.toml | 4 + rust/pspp/src/crypto/mod.rs | 666 ++++++++++++++++++ .../src/crypto/testdata/test-encrypted.sav | Bin 0 -> 1748 bytes .../src/crypto/testdata/test-encrypted.sps | Bin 0 -> 244 bytes .../src/crypto/testdata/test-encrypted.spv | Bin 0 -> 7764 bytes rust/pspp/src/crypto/testdata/test.sav | Bin 0 -> 1705 bytes rust/pspp/src/crypto/testdata/test.sps | 13 + rust/pspp/src/crypto/testdata/test.spv | Bin 0 -> 7713 bytes rust/pspp/src/lib.rs | 1 + rust/pspp/src/main.rs | 42 +- rust/pspp/src/sys/test.rs | 39 +- .../src/sys/testdata/test-encrypted.expected | 93 +++ rust/pspp/src/sys/testdata/test-encrypted.sav | Bin 0 -> 1748 bytes src/data/encrypted-file.c | 2 +- 15 files changed, 961 insertions(+), 9 deletions(-) create mode 100644 rust/pspp/src/crypto/mod.rs create mode 100644 rust/pspp/src/crypto/testdata/test-encrypted.sav create mode 100644 rust/pspp/src/crypto/testdata/test-encrypted.sps create mode 100644 rust/pspp/src/crypto/testdata/test-encrypted.spv create mode 100644 rust/pspp/src/crypto/testdata/test.sav create mode 100644 rust/pspp/src/crypto/testdata/test.sps create mode 100644 rust/pspp/src/crypto/testdata/test.spv create mode 100644 rust/pspp/src/sys/testdata/test-encrypted.expected create mode 100644 rust/pspp/src/sys/testdata/test-encrypted.sav diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 318d4e09c4..1e7795ae4c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -385,6 +385,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + [[package]] name = "color" version = "0.2.4" @@ -474,6 +485,15 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -1581,6 +1601,7 @@ dependencies = [ name = "pspp" version = "0.1.0" dependencies = [ + "aes", "anyhow", "binrw", "bitflags 2.9.1", @@ -1588,6 +1609,7 @@ dependencies = [ "chardetng", "chrono", "clap", + "cmac", "color", "csv", "derive_more", @@ -1612,6 +1634,7 @@ dependencies = [ "pspp-derive", "quick-xml", "rand", + "readpass", "serde", "smallstr", "smallvec", @@ -1621,6 +1644,7 @@ dependencies = [ "unicode-width", "windows-sys 0.48.0", "xmlwriter", + "zeroize", "zip", ] @@ -1704,6 +1728,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" +[[package]] +name = "readpass" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85614414429758be439b3cfc7ec2d883df5f3fa7027cc38f3b967ce72bfee60e" +dependencies = [ + "libc", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "redox_syscall" version = "0.5.12" @@ -2464,6 +2499,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -2488,13 +2532,29 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2507,6 +2567,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2519,6 +2585,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2531,12 +2603,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2549,6 +2633,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2561,6 +2651,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2573,6 +2669,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2585,6 +2687,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" version = "0.7.10" diff --git a/rust/pspp/Cargo.toml b/rust/pspp/Cargo.toml index b13efd3b55..043fbae2a4 100644 --- a/rust/pspp/Cargo.toml +++ b/rust/pspp/Cargo.toml @@ -45,6 +45,10 @@ pangocairo = "0.20.7" zip = "4.0.0" xmlwriter = "0.1.0" csv = "1.3.1" +cmac = "0.7.2" +aes = "0.8.4" +readpass = "1.0.3" +zeroize = "1.8.1" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.48.0", features = ["Win32_Globalization"] } diff --git a/rust/pspp/src/crypto/mod.rs b/rust/pspp/src/crypto/mod.rs new file mode 100644 index 0000000000..40ccfe0ca2 --- /dev/null +++ b/rust/pspp/src/crypto/mod.rs @@ -0,0 +1,666 @@ +//! # Decryption for SPSS encrypted files +//! +//! SPSS supports encryption using a password for data, viewer, and syntax +//! files. The encryption mechanism is poorly designed, so this module provides +//! support for decrypting, but not encrypting, the SPSS format. +//! Use [EncryptedFile] as the starting point for reading an encrypted file. +//! +//! SPSS also supports what calls "encrypted passwords". Use [EncodedPassword] +//! to encode and decode these passwords. + +// Warn about missing docs, but not for items declared with `#[cfg(test)]`. +#![cfg_attr(not(test), warn(missing_docs))] + +use aes::{ + cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit}, + Aes256, Aes256Dec, +}; +use cmac::{Cmac, Mac}; +use smallvec::SmallVec; +use std::{ + fmt::Debug, + io::{BufRead, Error as IoError, ErrorKind, Read, Seek, SeekFrom}, +}; +use thiserror::Error as ThisError; + +use binrw::{io::NoSeek, BinRead}; + +/// Error reading an encrypted file. +#[derive(Clone, Debug, ThisError)] +pub enum Error { + /// I/O error. + #[error("I/O error reading encrypted file wrapper ({0})")] + IoError(ErrorKind), + + /// Invalid padding in final encrypted data block. + #[error("Invalid padding in final encrypted data block")] + InvalidPadding, + + /// Not an encrypted file. + #[error("Not an encrypted file")] + NotEncrypted, + + /// Encrypted file has invalid length. + #[error("Encrypted file has invalid length {0} (expected 4 more than a multiple of 16).")] + InvalidLength(u64), + + /// Unknown file type. + #[error("Unknown file type {0:?}.")] + UnknownFileType(String), +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::IoError(value.kind()) + } +} + +#[derive(BinRead)] +struct EncryptedHeader { + /// Fixed as `1c 00 00 00 00 00 00 00` in practice. + _ignore: [u8; 8], + + /// File type. + #[br(magic = b"ENCRYPTED")] + file_type: [u8; 3], + + /// Fixed as `15 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00` in practice. + _ignore2: [u8; 16], +} + +/// An encrypted file. +pub struct EncryptedFile { + reader: R, + file_type: FileType, + + /// Length of the ciphertext (excluding the 36-byte header). + length: u64, + + /// First block of ciphertext, for verifying that any password the user + /// tries is correct. + first_block: [u8; 16], + + /// Last block of ciphertext, for checking padding and determining the + /// plaintext length. + last_block: [u8; 16], +} + +/// Type of encrypted file. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FileType { + /// A `.sps` syntax file. + Syntax, + + /// A `.spv` viewer file. + Viewer, + + /// A `.sav` data file. + Data, +} + +impl EncryptedFile +where + R: Read + Seek, +{ + /// Opens `reader` as an encrypted file. + /// + /// This reads enough of the file to verify that it is in the expected + /// format and returns an error if it cannot be read or is not the expected + /// format. + pub fn new(mut reader: R) -> Result { + let header = + EncryptedHeader::read_le(&mut NoSeek::new(&mut reader)).map_err( + |error| match error { + binrw::Error::BadMagic { .. } => Error::NotEncrypted, + binrw::Error::Io(error) => Error::IoError(error.kind()), + _ => unreachable!(), + }, + )?; + let file_type = match &header.file_type { + b"SAV" => FileType::Data, + b"SPV" => FileType::Viewer, + b"SPS" => FileType::Syntax, + _ => { + return Err(Error::UnknownFileType( + header.file_type.iter().map(|b| *b as char).collect(), + )) + } + }; + let mut first_block = [0; 16]; + reader.read_exact(&mut first_block)?; + let length = reader.seek(SeekFrom::End(-16))? + 16; + if length < 36 + 16 || (length - 36) % 16 != 0 { + return Err(Error::InvalidLength(length + 36)); + } + let mut last_block = [0; 16]; + reader.read_exact(&mut last_block)?; + reader.seek(SeekFrom::Start(36))?; + Ok(Self { + reader, + file_type, + length, + first_block, + last_block, + }) + } + + /// Tries to unlock the encrypted file using both `password` and with + /// `password` decoded with [EncodedPassword::decode]. If successful, + /// returns an [EncryptedReader] for the file; on failure, returns the + /// [EncryptedFile] again for another try. + pub fn unlock(self, password: &[u8]) -> Result, Self> { + self.unlock_literal(password).or_else(|this| { + match EncodedPassword::from_encoded(password) { + Some(encoded) => this.unlock_literal(&encoded.decode()), + None => Err(this), + } + }) + } + + /// Tries to unlock the encrypted file using just `password`. If + /// successful, returns an [EncryptedReader] for the file; on failure, + /// returns the [EncryptedFile] again for another try. + /// + /// If the password itself might be encoded ("encrypted"), instead use + /// [Self::unlock] to try it both ways. + pub fn unlock_literal(self, password: &[u8]) -> Result, Self> { + // NIST SP 800-108 fixed data. + #[rustfmt::skip] + static FIXED: &[u8] = &[ + // i + 0x00, 0x00, 0x00, 0x01, + + // label + 0x35, 0x27, 0x13, 0xcc, 0x53, 0xa7, 0x78, 0x89, + 0x87, 0x53, 0x22, 0x11, 0xd6, 0x5b, 0x31, 0x58, + 0xdc, 0xfe, 0x2e, 0x7e, 0x94, 0xda, 0x2f, 0x00, + 0xcc, 0x15, 0x71, 0x80, 0x0a, 0x6c, 0x63, 0x53, + + // delimiter + 0x00, + + // context + 0x38, 0xc3, 0x38, 0xac, 0x22, 0xf3, 0x63, 0x62, + 0x0e, 0xce, 0x85, 0x3f, 0xb8, 0x07, 0x4c, 0x4e, + 0x2b, 0x77, 0xc7, 0x21, 0xf5, 0x1a, 0x80, 0x1d, + 0x67, 0xfb, 0xe1, 0xe1, 0x83, 0x07, 0xd8, 0x0d, + + // L + 0x00, 0x00, 0x01, 0x00, + ]; + + // Truncate password to at most 10 bytes. + let password = password.get(..10).unwrap_or(password); + let n = password.len(); + + // padded_password = password padded with zeros to 32 bytes. + let mut padded_password = [0; 32]; + padded_password[..n].copy_from_slice(password); + + // cmac = CMAC(padded_password, fixed). + let mut cmac = as Mac>::new_from_slice(&padded_password).unwrap(); + cmac.update(FIXED); + let cmac = cmac.finalize().into_bytes(); + + // The key is the cmac repeated twice. + let mut key = [0; 32]; + key[..16].copy_from_slice(cmac.as_slice()); + key[16..].copy_from_slice(cmac.as_slice()); + + // Use key to initialize AES. + let aes = ::new_from_slice(&key).unwrap(); + + // Decrypt first block to verify password. + let mut out = [0; 16]; + aes.decrypt_block_b2b( + &GenericArray::from_slice(&self.first_block), + GenericArray::from_mut_slice(&mut out), + ); + static MAGIC: &[&[u8]] = &[ + b"$FL2@(#)", + b"$FL3@(#)", + b"* Encoding", + b"PK\x03\x04\x14\0\x08", + ]; + if !MAGIC.iter().any(|magic| out.starts_with(*magic)) { + return Err(self); + } + + // Decrypt last block to check padding and get final length. + aes.decrypt_block_b2b( + &GenericArray::from_slice(&self.last_block), + GenericArray::from_mut_slice(&mut out), + ); + let Some(padding_length) = parse_padding(&out) else { + return Err(self); + }; + + Ok(EncryptedReader::new( + self.reader, + aes, + self.file_type, + self.length - 36 - padding_length as u64, + )) + } + + /// Returns the type of encrypted file. + pub fn file_type(&self) -> FileType { + self.file_type + } +} + +fn parse_padding(block: &[u8; 16]) -> Option { + let pad = block[15] as usize; + if (1..=16).contains(&pad) && block[16 - pad..].iter().all(|b| *b == pad as u8) { + Some(pad) + } else { + None + } +} + +impl Debug for EncryptedFile +where + R: Read, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "EncryptedFile({:?})", &self.file_type) + } +} + +/// Encrypted file reader. +/// +/// This implements [Read] and [Seek] for SPSS encrypted files. To construct an +/// [EncryptedReader], call [EncryptedFile::new], then [EncryptedFile::unlock]. +pub struct EncryptedReader { + /// Underlying reader. + reader: R, + + /// AES-256 decryption key. + aes: Aes256Dec, + + /// Type of file. + file_type: FileType, + + /// Plaintext file length (not including the file header or padding). + length: u64, + + /// Plaintext data buffer. + buffer: Box<[u8; 4096]>, + + /// Plaintext offset of the byte in `buffer[0]`. A multiple of 16 less than + /// or equal to `length`. + start: u64, + + /// Number of bytes in buffer (`0 <= head <= 4096`). + head: usize, + + /// Offset in buffer of the next byte to read (`head <= tail`). + tail: usize, +} + +impl EncryptedReader { + fn new(reader: R, aes: Aes256Dec, file_type: FileType, length: u64) -> Self { + Self { + reader, + aes, + file_type, + length, + buffer: Box::new([0; 4096]), + start: 0, + head: 0, + tail: 0, + } + } + + fn read_buffer(&mut self, buf: &mut [u8]) -> Result { + let n = buf.len().min(self.head - self.tail); + buf[..n].copy_from_slice(&self.buffer[self.tail..n + self.tail]); + self.tail += n; + Ok(n) + } + + /// Returns the type of encrypted file. + pub fn file_type(&self) -> FileType { + self.file_type + } +} + +impl EncryptedReader +where + R: Read, +{ + fn fill_buffer(&mut self, offset: u64) -> Result<(), IoError> { + self.start = offset / 16 * 16; + self.head = 0; + self.tail = (offset % 16) as usize; + let n = self.buffer.len().min((self.length - self.start) as usize); + self.reader + .read_exact(&mut self.buffer[..n.next_multiple_of(16)])?; + for offset in (0..n).step_by(16) { + self.aes.decrypt_block(GenericArray::from_mut_slice( + &mut self.buffer[offset..offset + 16], + )); + } + self.head = n; + Ok(()) + } +} + +impl Read for EncryptedReader +where + R: Read, +{ + fn read(&mut self, buf: &mut [u8]) -> Result { + if self.tail < self.head { + self.read_buffer(buf) + } else { + let offset = self.start + self.head as u64; + if offset < self.length { + self.fill_buffer(offset)?; + self.read_buffer(buf) + } else { + Ok(0) + } + } + } +} + +impl Seek for EncryptedReader +where + R: Read + Seek, +{ + fn seek(&mut self, pos: SeekFrom) -> Result { + let offset = match pos { + SeekFrom::Start(offset) => Some(offset), + SeekFrom::End(relative) => self.length.checked_add_signed(relative), + SeekFrom::Current(relative) => { + (self.start + self.tail as u64).checked_add_signed(relative) + } + } + .filter(|offset| *offset < u64::MAX - 36) + .ok_or(IoError::from(ErrorKind::InvalidInput))?; + if offset != self.start + self.tail as u64 { + self.reader.seek(SeekFrom::Start(offset / 16 * 16 + 36))?; + self.fill_buffer(offset)?; + } + Ok(offset) + } +} + +impl BufRead for EncryptedReader +where + R: Read + Seek, +{ + fn fill_buf(&mut self) -> std::io::Result<&[u8]> { + if self.tail >= self.head { + let offset = self.start + self.head as u64; + if offset < self.length { + self.fill_buffer(offset)?; + } + } + Ok(&self.buffer[self.tail..self.head]) + } + + fn consume(&mut self, amount: usize) { + self.tail += amount; + debug_assert!(self.tail <= self.head); + } +} + +const fn b(x: i32) -> u16 { + 1 << x +} + +static AH: [[u16; 2]; 4] = [ + [b(2), b(2) | b(3) | b(6) | b(7)], + [b(3), b(0) | b(1) | b(4) | b(5)], + [b(4) | b(7), b(8) | b(9) | b(12) | b(13)], + [b(5) | b(6), b(10) | b(11) | b(14) | b(15)], +]; + +static AL: [[u16; 2]; 4] = [ + [b(0) | b(3) | b(12) | b(15), b(0) | b(1) | b(4) | b(5)], + [b(1) | b(2) | b(13) | b(14), b(2) | b(3) | b(6) | b(7)], + [b(4) | b(7) | b(8) | b(11), b(8) | b(9) | b(12) | b(13)], + [b(5) | b(6) | b(9) | b(10), b(10) | b(11) | b(14) | b(15)], +]; + +static BH: [[u16; 2]; 4] = [ + [b(2), b(1) | b(3) | b(9) | b(11)], + [b(3), b(0) | b(2) | b(8) | b(10)], + [b(4) | b(7), b(4) | b(6) | b(12) | b(14)], + [b(5) | b(6), b(5) | b(7) | b(13) | b(15)], +]; + +static BL: [[u16; 2]; 4] = [ + [b(0) | b(3) | b(12) | b(15), b(0) | b(2) | b(8) | b(10)], + [b(1) | b(2) | b(13) | b(14), b(1) | b(3) | b(9) | b(11)], + [b(4) | b(7) | b(8) | b(11), b(4) | b(6) | b(12) | b(14)], + [b(5) | b(6) | b(9) | b(10), b(5) | b(7) | b(13) | b(15)], +]; + +fn decode_nibble(table: &[[u16; 2]; 4], nibble: u8) -> u16 { + for section in table.iter() { + if section[0] & (1 << nibble) != 0 { + return section[1]; + } + } + 0 +} + +fn find_1bit(x: u16) -> Option { + x.is_power_of_two().then(|| x.trailing_zeros() as u8) +} + +fn decode_pair(a: u8, b: u8) -> Option { + let x = find_1bit(decode_nibble(&AH, a >> 4) & decode_nibble(&BH, b >> 4))?; + let y = find_1bit(decode_nibble(&AL, a & 15) & decode_nibble(&BL, b & 15))?; + Some((x << 4) | y) +} + +fn encode_nibble(table: &[[u16; 2]; 4], nibble: u8) -> Vec { + for section in table.iter() { + if section[1] & (1 << nibble) != 0 { + let mut outputs = Vec::with_capacity(4); + let mut bits = section[0]; + while bits != 0 { + outputs.push(bits.trailing_zeros() as u8); + bits &= bits - 1; + } + return outputs; + } + } + unreachable!() +} + +fn encode_byte(hi_table: &[[u16; 2]; 4], lo_table: &[[u16; 2]; 4], byte: u8) -> Vec { + let hi_variants = encode_nibble(hi_table, byte >> 4); + let lo_variants = encode_nibble(lo_table, byte & 15); + let mut variants = Vec::with_capacity(hi_variants.len() * lo_variants.len()); + for hi in hi_variants.iter().copied() { + for lo in lo_variants.iter().copied() { + let byte = (hi << 4) | lo; + if byte != 127 { + variants.push(byte as char); + } + } + } + variants +} + +/// An encoded password. +/// +/// SPSS calls these "encrypted passwords", but they are not encrypted. They +/// are encoded with a simple scheme, analogous to base64 encoding but +/// one-to-many: any plaintext password maps to many possible encoded passwords. +/// +/// The encoding scheme maps each plaintext password byte to 2 ASCII characters, +/// using only at most the first 10 bytes of the plaintext password. Thus, an +/// encoded password is always a multiple of 2 characters long, and never longer +/// than 20 characters. The characters in an encoded password are always in the +/// graphic ASCII range 33 through 126. Each successive pair of characters in +/// the password encodes a single byte in the plaintext password. +/// +/// This struct supports both encoding and decoding passwords. +#[derive(Clone, Debug)] +pub struct EncodedPassword(Vec>); + +impl EncodedPassword { + /// Creates an [EncodedPassword] from an already-encoded password `encoded`. + /// Returns `None` if `encoded` is not a valid encoded password. + pub fn from_encoded(encoded: &[u8]) -> Option { + if encoded.len() > 20 + || encoded.len() % 2 != 0 + || !encoded.iter().all(|byte| (32..=127).contains(byte)) + { + return None; + } + + Some(EncodedPassword( + encoded.iter().map(|byte| vec![*byte as char]).collect(), + )) + } + + /// Returns an [EncodedPassword] as an encoded version of the given + /// `plaintext` password. Only the first 10 bytes, at most, of the + /// plaintext password is used. + pub fn from_plaintext(plaintext: &[u8]) -> EncodedPassword { + let input = plaintext.get(..10).unwrap_or(plaintext); + EncodedPassword( + input + .iter() + .copied() + .map(|byte| [encode_byte(&AH, &AL, byte), encode_byte(&BH, &BL, byte)]) + .flatten() + .collect(), + ) + } + + /// Returns the number of variations of this encoded password. + /// + /// An [EncodedPassword] created by [EncodedPassword::from_plaintext] has + /// many variations: between `16**n` and `32**n` for an `n`-byte plaintext + /// password, so up to `32**10` (about 1e15) for the 10-byte longest + /// plaintext passwords. + /// + /// An [EncodedPassword] created by [EncodedPassword::from_encoded] has only + /// a single variation, the one passed in by that function. + pub fn n_variants(&self) -> u64 { + self.0 + .iter() + .map(|variants| variants.len() as u64) + .product() + } + + /// Returns one variation of this encoded password, numbered `index`. All + /// variations decode the same way. + pub fn variant(&self, mut index: u64) -> String { + let mut output = String::with_capacity(20); + for variants in &self.0 { + let n = variants.len() as u64; + output.push(variants[(index % n) as usize]); + index /= n; + } + output + } + + /// Returns the decoded version of this encoded password. + pub fn decode(&self) -> SmallVec<[u8; 10]> { + let mut output = SmallVec::new(); + for [a, b] in self.0.as_chunks::<2>().0 { + output.push(decode_pair(a[0] as u8, b[0] as u8).unwrap()); + } + output + } +} + +#[cfg(test)] +mod test { + use std::{io::Cursor, path::Path}; + + use crate::crypto::{EncodedPassword, EncryptedFile, FileType}; + + fn test_decrypt(input_name: &Path, expected_name: &Path, password: &str, file_type: FileType) { + let input_filename = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/crypto/testdata") + .join(input_name); + let input = std::fs::read(&input_filename).unwrap(); + let mut cursor = Cursor::new(&input); + let file = EncryptedFile::new(&mut cursor).unwrap(); + assert_eq!(file.file_type(), file_type); + let mut reader = file.unlock_literal(password.as_bytes()).unwrap(); + assert_eq!(reader.file_type(), file_type); + let mut actual = Vec::new(); + std::io::copy(&mut reader, &mut actual).unwrap(); + + let expected_filename = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/crypto/testdata") + .join(expected_name); + let expected = std::fs::read(&expected_filename).unwrap(); + if actual != expected { + panic!(); + } + } + + #[test] + fn sys_file() { + test_decrypt( + Path::new("test-encrypted.sav"), + Path::new("test.sav"), + "pspp", + FileType::Data, + ); + } + + #[test] + fn syntax_file() { + test_decrypt( + Path::new("test-encrypted.sps"), + Path::new("test.sps"), + "password", + FileType::Syntax, + ); + } + + #[test] + fn spv_file() { + test_decrypt( + Path::new("test-encrypted.spv"), + Path::new("test.spv"), + "Password1", + FileType::Viewer, + ); + } + + #[test] + fn password_encoding() { + // Decode a few specific passwords. + assert_eq!( + EncodedPassword::from_encoded(b"-|") + .unwrap() + .decode() + .as_slice(), + b"b" + ); + assert_eq!( + EncodedPassword::from_encoded(b" A") + .unwrap() + .decode() + .as_slice(), + b"a" + ); + + // Check that the encoding and decoding algorithms are inverses + // for individual characters at least. + for plaintext in 0..=255 { + let encoded = EncodedPassword::from_plaintext(&[plaintext]); + for variant in 0..encoded.n_variants() { + let encoded_variant = encoded.variant(variant); + let decoded = EncodedPassword::from_encoded(encoded_variant.as_bytes()) + .unwrap() + .decode(); + assert_eq!(&[plaintext], decoded.as_slice()); + } + } + } +} diff --git a/rust/pspp/src/crypto/testdata/test-encrypted.sav b/rust/pspp/src/crypto/testdata/test-encrypted.sav new file mode 100644 index 0000000000000000000000000000000000000000..2d9f531102adda401f1782c4ce362e80f0d22628 GIT binary patch literal 1748 zcmc&yZ8#GM06sL66U7K(6*`N-GJXtNpev1#teF`_Lr zEKGB*TebN}J5r1rlW9ImBsJ{rdG78{f9~hKf8OVPpXYs^_j%se^s~R=?gjG=^zm~) zfp!Zp`p5g17EcHqm@^au|3>??6%TL;dFcIN!cz>bJ|-4yT2E+qZk1|r3gP>BCf@;2 zjP5>r){$gNuea76o~M8ja(_Orm71k32Oh>_%<+aW(0L3jp$#Px_}U`7ZX|IQXw5)KS&3u~wFC7)!k>s_uc$54^zRrS9$&7voHt2TTju|~!=g|~G-MbZ= zriz#c!W!s?lA4@+QRn0t7PlbuRm43@z2&5HHTDQ+2%WS_&=l5DqY`-^QTcikvc~zcPF?PDXOB7KcvR;eQ7+Wlz`dAXVFQA+Lp>sVNvd=H&yjM z`SzZj)H##{YbXIl?c$rh)uAS=&BN%B8!BCflNM2JeSzJ*j8srd9GnkVA-Dgc>;<_=I< zk4yZO*Kp>FtX7}QbM0+;+%fX)h(+T-XY8R=9AzLw*QQ}W#C-wK-X>*M7>TkD*+Z+? zLWTJz4$PSMd+EvbX&ntG9;q!)iLMggFPq$IS-GE##4Xnji++{I)c&>P$Lh%0d1}$w z?d7Ann(>S)Q!*vgZS)2%YV=2i?Vx9)QHf1B6*W{u&~f{tml?C!Ph;$(tx<+s<<@Rf zNn$7RT4w$8>0pg1mx*Td?SfJ7)jaS*p)yhYI#||l@xqnxMAbksLF;T6(U82F2BO6# z_$9u|Qx<%QP8!yDWa!4XCM}Ig1c22y2)xsfLAy51?yS@<-D;1IGUjDX4ovGQ4{bP2 zmx%i&uw`FAr&sA4ODk+Kbv;jt>%i7CU4~XU-pjv8pLCM8<(dL5JgniOLG<_4T?HpK zIz0{P%6Av}2XT|d8}i}DY3kC{K7;uAo15qq+mqd)kZMqvdPo2FJ0twE8yw1@KMCzr z_q`jK*FG1O?K=#8F(i3_{oE94tAugZUhfWZfQ%X zeq2HL$m!%Jzvf#tyIQz}u~|QZ9bh(rH-KoyFcg?*6^iv$PtZcBe)+wSFg#&tEb}$o zwBGvJjTh(RaAqrgkY+abp#Lu+L%R;Rb92>BAmcaQ_G;r=gE?AQ{;6Bv)aP@%k5uAK z2L02@|L?!x#BeWjP08Z+qbW_SS7yz^-8peV<+qJXEM0|xPO7y1PGXn`gtBE}m`RI{ zTq^ZBXfGy4UU+BU^igv;i^#h7a}bfLxb`6ANxLI_+p$Nhh2wrSQB}g_*A5;*#v9gK zCxC?&=hA<_J{g^bByu9oRmVE-7%*Z;*VAEE8QOavTt>oVCROce<$J;lMH6zwfi_0Q z)N$^9E0AwloAEK+7uv<<#S~wcT)EW;Z!F|)CUca2YW(?A1-o=klmjjjyfv5r;v7kX zr`|6o6L$#`AeMAeL`<9$g&y~+<9!)N7P>QV_q>il5X@=20uZ&%yIiVWwC<-mzveIK zmpghpd>C)+nE&w1LaKu>=2s^3Ya-YfOW2b&wlCiQW{7Uh;Ev(5jT2Mbc zU@e`h-Y3EGSsUYkY}iWO0sgcG`gGN4Rdqi>{M+3mN&jcK$fSGoGk;Aol$|0d{V+3_o$HkK zjz3?UFZ2n&F#Bh>J9&M0x>tbi9j25y2I>WKCb2w{YS(#?wJm6Eh{*d7`&rkCTxcCwX)DmvZmB{rp~I#^R`b3JV+AL{)iwr6d+SxbZYWfZZX(_HniOjCScy zD_@HIu$nU`ekO~YFv}h5*)|Uf?s{=0Zdi1N5r{=Q^$Qe64+>x!!m{2s3#xd8y8`DSqd literal 0 HcmV?d00001 diff --git a/rust/pspp/src/crypto/testdata/test-encrypted.spv b/rust/pspp/src/crypto/testdata/test-encrypted.spv new file mode 100644 index 0000000000000000000000000000000000000000..da8be2c80fdc62c959c32eaec4fec32a76eac432 GIT binary patch literal 7764 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 Gz5NfRbPzEB literal 0 HcmV?d00001 diff --git a/rust/pspp/src/crypto/testdata/test.sav b/rust/pspp/src/crypto/testdata/test.sav new file mode 100644 index 0000000000000000000000000000000000000000..a84e8f15a85d87cc385a927eccffd73efa2f246d GIT binary patch literal 1705 zcmd6nUuzRV5WwS~Vp37tQhZVvFwpct&HV}3K=7(k2pA(?Jn`|k%-$_rcFXPcLZ1AJ zehcxbegUmB*^AIXguZ2x$==M){APCNNc-%)8+4u>!|=@r!jEAH;pB8O3@5|4A&f%! zBGpvS4Y=-$E`MN6ZZ{f@9nxE*Ec*T}hjCJZ<2fgN$Ln`b)^cxb5|!(*tm-wI zRGl<(EUiO(Lx^W!;!+wRaSC%;W>97bIaZj17GSWbWP%%6u4O$T)`aq^IKMy;x`3Hh zxPWR{Z4pKpZ=3J|(R4IjSXTl z?$QR&oON4z;kj)~FWkqr^un{+mR|Usy;t|ceKkoRldj}D4RQ5e@jQS2w(+Z2KF8Hk zF70x4`Rz|_Gxt6z$37zEeC9Lnrq9fJQ0G3SHF5@((~8_d<+dVkPJ6$M z2k((`F)qXfSyq3(ynjH-{xNCg^xMHFonz;yd$fD}vU1mKZ_W1C?A|q-@rN~@@AvcM z?D?x7d{ZQuNHauC5v8EVl%9l4k-kFGo*)lqQeY~`!!FDyjvSmNBqycm(qIIKkc+r~ E0dd(yrT_o{ literal 0 HcmV?d00001 diff --git a/rust/pspp/src/crypto/testdata/test.sps b/rust/pspp/src/crypto/testdata/test.sps new file mode 100644 index 0000000000..0f48aa196d --- /dev/null +++ b/rust/pspp/src/crypto/testdata/test.sps @@ -0,0 +1,13 @@ +* Encoding: windows-1252. +DATA LIST LIST /name (a25) quantity (f8). +BEGIN DATA. +widgets 10345 +oojars 2345 +dubreys 98 +thingumies 518 +END DATA. + +LIST. + +DESCRIPTIVES /quantity + /statistics ALL. \ No newline at end of file diff --git a/rust/pspp/src/crypto/testdata/test.spv b/rust/pspp/src/crypto/testdata/test.spv new file mode 100644 index 0000000000000000000000000000000000000000..891263dccd0cdc2f5a4250a36228e7f9bec8c852 GIT binary patch literal 7713 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 bjpWbStgeiJ_;VG?<5T=7Z;;1z0Kk6%?iym{ literal 0 HcmV?d00001 diff --git a/rust/pspp/src/lib.rs b/rust/pspp/src/lib.rs index 7fde26fc5e..8b5d81f400 100644 --- a/rust/pspp/src/lib.rs +++ b/rust/pspp/src/lib.rs @@ -16,6 +16,7 @@ pub mod calendar; pub mod command; +pub mod crypto; pub mod dictionary; pub mod endian; pub mod engine; diff --git a/rust/pspp/src/main.rs b/rust/pspp/src/main.rs index c874f5dcd7..8432f4267d 100644 --- a/rust/pspp/src/main.rs +++ b/rust/pspp/src/main.rs @@ -14,9 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Args, Parser, Subcommand, ValueEnum}; use encoding_rs::Encoding; +use pspp::crypto::EncryptedFile; use pspp::sys::cooked::{Error, Headers}; use pspp::sys::raw::{encoding_from_headers, Decoder, Magic, Reader, Record, Warning}; use std::fs::File; @@ -24,6 +25,7 @@ use std::io::{stdout, BufReader, Write}; use std::path::{Path, PathBuf}; use std::str; use thiserror::Error as ThisError; +use zeroize::Zeroizing; /// PSPP, a program for statistical analysis of sampled data. #[derive(Parser, Debug)] @@ -117,6 +119,42 @@ impl Convert { } } +/// Decrypts an encrypted SPSS data, output, or syntax file. +#[derive(Args, Clone, Debug)] +struct Decrypt { + /// Input file name. + input: PathBuf, + + /// Output file name. + output: PathBuf, + + /// Password for decryption, with or without what SPSS calls "password encryption". + /// + /// If omitted, PSPP will prompt interactively for the password. + #[clap(short, long)] + password: Option, +} + +impl Decrypt { + fn run(self) -> Result<()> { + let input = EncryptedFile::new(File::open(&self.input)?)?; + let password = match self.password { + Some(password) => Zeroizing::new(password), + None => { + eprintln!("Please enter the password for {}:", self.input.display()); + readpass::from_tty().unwrap() + } + }; + let mut reader = match input.unlock(password.as_bytes()) { + Ok(reader) => reader, + Err(_) => return Err(anyhow!("Incorrect password.")), + }; + let mut writer = File::create(self.output)?; + std::io::copy(&mut reader, &mut writer)?; + Ok(()) + } +} + /// Dissects SPSS system files. #[derive(Args, Clone, Debug)] struct Dissect { @@ -149,6 +187,7 @@ impl Dissect { #[derive(Subcommand, Clone, Debug)] enum Command { Convert(Convert), + Decrypt(Decrypt), Dissect(Dissect), } @@ -156,6 +195,7 @@ impl Command { fn run(self) -> Result<()> { match self { Command::Convert(convert) => convert.run(), + Command::Decrypt(decrypt) => decrypt.run(), Command::Dissect(dissect) => dissect.run(), } } diff --git a/rust/pspp/src/sys/test.rs b/rust/pspp/src/sys/test.rs index fc39ec2730..16ec17551e 100644 --- a/rust/pspp/src/sys/test.rs +++ b/rust/pspp/src/sys/test.rs @@ -14,9 +14,15 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . -use std::{io::Cursor, path::Path, sync::Arc}; +use std::{ + fs::File, + io::{Cursor, Read, Seek}, + path::Path, + sync::Arc, +}; use crate::{ + crypto::EncryptedFile, endian::Endian, output::{ pivot::{test::assert_lines_eq, Axis3, Dimension, Group, PivotTable, Value}, @@ -542,12 +548,31 @@ fn duplicate_variable_name() { test_sack_sysfile("duplicate_variable_name"); } +#[test] +fn encrypted_file() { + test_encrypted_sysfile("test-encrypted.sav", "pspp"); +} + fn test_raw_sysfile(name: &str) { let input_filename = Path::new(env!("CARGO_MANIFEST_DIR")) .join("src/sys/testdata") .join(name) .with_extension("sav"); - let sysfile = std::fs::read(&input_filename).unwrap(); + let sysfile = File::open(&input_filename).unwrap(); + let expected_filename = input_filename.with_extension("expected"); + let expected = String::from_utf8(std::fs::read(&expected_filename).unwrap()).unwrap(); + test_sysfile(sysfile, &expected, &expected_filename); +} + +fn test_encrypted_sysfile(name: &str, password: &str) { + let input_filename = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/sys/testdata") + .join(name) + .with_extension("sav"); + let sysfile = EncryptedFile::new(File::open(&input_filename).unwrap()) + .unwrap() + .unlock(password.as_bytes()) + .unwrap(); let expected_filename = input_filename.with_extension("expected"); let expected = String::from_utf8(std::fs::read(&expected_filename).unwrap()).unwrap(); test_sysfile(sysfile, &expected, &expected_filename); @@ -570,14 +595,16 @@ fn test_sack_sysfile(name: &str) { }, ); let sysfile = sack(&input, Some(&input_filename), endian).unwrap(); - test_sysfile(sysfile, &expected, &expected_filename); + test_sysfile(Cursor::new(sysfile), &expected, &expected_filename); } } -fn test_sysfile(sysfile: Vec, expected: &str, expected_filename: &Path) { - let cursor = Cursor::new(sysfile); +fn test_sysfile(sysfile: R, expected: &str, expected_filename: &Path) +where + R: Read + Seek + 'static, +{ let mut warnings = Vec::new(); - let mut reader = Reader::new(cursor, |warning| warnings.push(warning)).unwrap(); + let mut reader = Reader::new(sysfile, |warning| warnings.push(warning)).unwrap(); let output = match reader.headers().collect() { Ok(headers) => { let cases = reader.cases(); diff --git a/rust/pspp/src/sys/testdata/test-encrypted.expected b/rust/pspp/src/sys/testdata/test-encrypted.expected new file mode 100644 index 0000000000..e98ba62f71 --- /dev/null +++ b/rust/pspp/src/sys/testdata/test-encrypted.expected @@ -0,0 +1,93 @@ +╭──────────────────────┬────────────────────────────────────────────╮ +│ Created │ 04-OCT-2013 19:13:09│ +├──────────────────────┼────────────────────────────────────────────┤ +│Writer Product │@(#) IBM SPSS STATISTICS MS Windows 22.0.0.0│ +│ Version │22.0.0 │ +├──────────────────────┼────────────────────────────────────────────┤ +│ Compression │SAV │ +│ Number of Cases│ 17│ +╰──────────────────────┴────────────────────────────────────────────╯ + +╭─────────┬─╮ +│Variables│5│ +╰─────────┴─╯ + +╭────────────────────────────────────────────────────┬────────┬────────────────────────────────────────────────────┬─────────────────┬─────┬─────┬─────────┬────────────┬────────────┬──────────────╮ +│ │Position│ Label │Measurement Level│ Role│Width│Alignment│Print Format│Write Format│Missing Values│ +├────────────────────────────────────────────────────┼────────┼────────────────────────────────────────────────────┼─────────────────┼─────┼─────┼─────────┼────────────┼────────────┼──────────────┤ +│I am satisfied with the level of service │ 1│I am satisfied with the level of service │Ordinal │Input│ 8│Right │F8.0 │F8.0 │ │ +│The value for money was good │ 2│The value for money was good │Ordinal │Input│ 8│Right │F8.0 │F8.0 │ │ +│The staff were slow in responding │ 3│The staff were slow in responding │Ordinal │Input│ 8│Right │F8.0 │F8.0 │ │ +│My concerns were dealt with in an efficient manner │ 4│My concerns were dealt with in an efficient manner │Ordinal │Input│ 8│Right │F8.0 │F8.0 │ │ +│There was too much noise in the rooms │ 5│There was too much noise in the rooms │Ordinal │Input│ 8│Right │F8.0 │F8.0 │ │ +╰────────────────────────────────────────────────────┴────────┴────────────────────────────────────────────────────┴─────────────────┴─────┴─────┴─────────┴────────────┴────────────┴──────────────╯ + +╭──────────────────────────────────────────────────────┬─────────────────╮ +│Variable Value │ │ +├──────────────────────────────────────────────────────┼─────────────────┤ +│I am satisfied with the level of service 1│Strongly Disagree│ +│ 2│Disagree │ +│ 3│No Opinion │ +│ 4│Agree │ +│ 5│Strongly Agree │ +├──────────────────────────────────────────────────────┼─────────────────┤ +│The value for money was good 1│Strongly Disagree│ +│ 2│Disagree │ +│ 3│No Opinion │ +│ 4│Agree │ +│ 5│Strongly Agree │ +├──────────────────────────────────────────────────────┼─────────────────┤ +│The staff were slow in responding 1│Strongly Disagree│ +│ 2│Disagree │ +│ 3│No Opinion │ +│ 4│Agree │ +│ 5│Strongly Agree │ +├──────────────────────────────────────────────────────┼─────────────────┤ +│My concerns were dealt with in an efficient manner 1│Strongly Disagree│ +│ 2│Disagree │ +│ 3│No Opinion │ +│ 4│Agree │ +│ 5│Strongly Agree │ +├──────────────────────────────────────────────────────┼─────────────────┤ +│There was too much noise in the rooms 1│Strongly Disagree│ +│ 2│Disagree │ +│ 3│No Opinion │ +│ 4│Agree │ +│ 5│Strongly Agree │ +╰──────────────────────────────────────────────────────┴─────────────────╯ + +╭───────────────────────────────────────────────────────────┬─────╮ +│Variable and Name │Value│ +├───────────────────────────────────────────────────────────┼─────┤ +│I am satisfied with the level of service $@Role│0 │ +├───────────────────────────────────────────────────────────┼─────┤ +│The value for money was good $@Role│0 │ +├───────────────────────────────────────────────────────────┼─────┤ +│The staff were slow in responding $@Role│0 │ +├───────────────────────────────────────────────────────────┼─────┤ +│My concerns were dealt with in an efficient manner $@Role│0 │ +├───────────────────────────────────────────────────────────┼─────┤ +│There was too much noise in the rooms $@Role│0 │ +╰───────────────────────────────────────────────────────────┴─────╯ + +╭────┬────────────────────────────────────────┬────────────────────────────┬────────────────────────────────────┬────────────────────────────────────────────────────┬────────────────────────────────────────╮ +│Case│I am satisfied with the level of service│The value for money was good│The staff were slow in responding │My concerns were dealt with in an efficient manner │There was too much noise in the rooms │ +├────┼────────────────────────────────────────┼────────────────────────────┼────────────────────────────────────┼────────────────────────────────────────────────────┼────────────────────────────────────────┤ +│1 │ 4.00│ 2.00│ 3.00│ 4.00│ 1.00│ +│2 │ 1.00│ 1.00│ 3.00│ 1.00│ 1.00│ +│3 │ 5.00│ 2.00│ 2.00│ 3.00│ 4.00│ +│4 │ 3.00│ 1.00│ 3.00│ 1.00│ 2.00│ +│5 │ 5.00│ 3.00│ 1.00│ 5.00│ 3.00│ +│6 │ 1.00│ 2.00│ 5.00│ 4.00│ 2.00│ +│7 │ 3.00│ 2.00│ 4.00│ 3.00│ 1.00│ +│8 │ 1.00│ 4.00│ 5.00│ 2.00│ 1.00│ +│9 │ 3.00│ 2.00│ 3.00│ 1.00│ 2.00│ +│10 │ 2.00│ 5.00│ 4.00│ 2.00│ 1.00│ +│11 │ 4.00│ 2.00│ 2.00│ 3.00│ 5.00│ +│12 │ 2.00│ 1.00│ 4.00│ 1.00│ 1.00│ +│13 │ 1.00│ 2.00│ 5.00│ 5.00│ 2.00│ +│14 │ 2.00│ 3.00│ 3.00│ 3.00│ 1.00│ +│15 │ 4.00│ 1.00│ 1.00│ 1.00│ 3.00│ +│16 │ 1.00│ 1.00│ 5.00│ 1.00│ 2.00│ +│17 │ 2.00│ 5.00│ 5.00│ 2.00│ 2.00│ +╰────┴────────────────────────────────────────┴────────────────────────────┴────────────────────────────────────┴────────────────────────────────────────────────────┴────────────────────────────────────────╯ diff --git a/rust/pspp/src/sys/testdata/test-encrypted.sav b/rust/pspp/src/sys/testdata/test-encrypted.sav new file mode 100644 index 0000000000000000000000000000000000000000..2d9f531102adda401f1782c4ce362e80f0d22628 GIT binary patch literal 1748 zcmc&yZ8#GM06sL66U7K(6*`N-GJXtNpev1#teF`_Lr zEKGB*TebN}J5r1rlW9ImBsJ{rdG78{f9~hKf8OVPpXYs^_j%se^s~R=?gjG=^zm~) zfp!Zp`p5g17EcHqm@^au|3>??6%TL;dFcIN!cz>bJ|-4yT2E+qZk1|r3gP>BCf@;2 zjP5>r){$gNuea76o~M8ja(_Orm71k32Oh>_%<+aW(0L3jp$#Px_}U`7ZX|IQXw5)KS&3u~wFC7)!k>s_uc$54^zRrS9$&7voHt2TTju|~!=g|~G-MbZ= zriz#c!W!s?lA4@+QRn0t7PlbuRm43@z2&5HHTDQ+2%WS_&=l5DqY`-^QTcikvc~zcPF?PDXOB7KcvR;eQ7+Wlz`dAXVFQA+Lp>sVNvd=H&yjM z`SzZj)H##{YbXIl?c$rh)uAS=&BN%B8!BCflNM2JeSzJ*j8srd9GnkVA-Dgc>;<_=I< zk4yZO*Kp>FtX7}QbM0+;+%fX)h(+T-XY8R=9AzLw*QQ}W#C-wK-X>*M7>TkD*+Z+? zLWTJz4$PSMd+EvbX&ntG9;q!)iLMggFPq$IS-GE##4Xnji++{I)c&>P$Lh%0d1}$w z?d7Ann(>S)Q!*vgZS)2%YV=2i?Vx9)QHf1B6*W{u&~f{tml?C!Ph;$(tx<+s<<@Rf zNn$7RT4w$8>0pg1mx*Td?SfJ7)jaS*p)yhYI#||l@xqnxMAbksLF;T6(U82F2BO6# z_$9u|Qx<%QP8!yDWa!4XCM}Ig1c22y2)xsfLAy51?yS@<-D;1IGUjDX4ovGQ4{bP2 zmx%i&uw`FAr&sA4ODk+Kbv;jt>%i7CU4~XU-pjv8pLCM8<(dL5JgniOLG<_4T?HpK zIz0{P%6Av}2XT|d8}i}DY3kC{K7;uAo15qq+mqd)kZMqvdPo2FJ0twE8yw1@KMCzr z_q`jK*FG1O?K=#8F(i3_{oE94tAugZUhfWZfQ%X zeq2HL$m!%Jzvf#tyIQz}u~|QZ9bh(rH-KoyFcg?*6^iv$PtZcBe)+wSFg#&tEb}$o zwBGvJjTh(RaAqrgkY+abp#Lu+L%R;Rb92>BAmcaQ_G;r=gE?AQ{;6Bv)aP@%k5uAK z2L02@|L?!x#BeWjP08Z+qbW_SS7yz^-8peV<+qJXEM0|xPO7y1PGXn`gtBE}m`RI{ zTq^ZBXfGy4UU+BU^igv;i^#h7a}bfLxb`6ANxLI_+p$Nhh2wrSQB}g_*A5;*#v9gK zCxC?&=hA<_J{g^bByu9oRmVE-7%*Z;*VAEE8QOavTt>oVCROce<$J;lMH6zwfi_0Q z)N$^9E0AwloAEK+7uv<<#S~wcT)EW;Z!F|)CUca2YW(?A1-o=klmjjjyfv5r;v7kX zr`|6o6L$#`AeMAeL`<9$g&y~+<9!)N7P>QV_q>il5X@=20uZ&%yIiVWwC<-mzveIK zmpghpd>C)+nE&w1LaKu>=2s^3Ya-YfOW2b&wlCiQW{7Uh;Ev(5jT2Mbc zU@e`h-Y3EGSsUYkY}iWO0sgcG`gGN4Rd