Implement CameraX GPG QR transfer on Android (#106)
This commit is contained in:
1 parent
6669b77f4e
commit
d466b170ab
33 files changed
+1616
-162
No files matched your search
@@ -1,6 +1,9 @@
|
||||
//! Storage-owned OpenPGP key transfer for native mobile QR adapters.
|
||||
|
||||
use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path};
|
||||
use std::{
|
||||
collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path, sync::Mutex,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::atomic_file::TempFile;
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
@@ -17,6 +20,9 @@ use crate::{
|
||||
const FRAME_PREFIX: &str = "ISKT1";
|
||||
const FRAME_CHUNK_BYTES: usize = 1_600;
|
||||
const MAX_TRANSFER_FRAMES: usize = 11_000;
|
||||
const MAX_TRANSFER_BYTES: usize = MAX_TRANSFER_FRAMES * FRAME_CHUNK_BYTES;
|
||||
// Bound eager QR matrices to under 8 MiB on lower-memory mobile clients.
|
||||
const MAX_EXPORT_FRAMES: usize = 256;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileKeyTransferKind {
|
||||
@@ -140,13 +146,18 @@ impl MobileKeyTransferOutcome {
|
||||
pub struct MobileKeyTransferService {
|
||||
config: Config,
|
||||
keys: KeyStore,
|
||||
export_deadline: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl MobileKeyTransferService {
|
||||
pub fn load() -> Result<Self, MobileKeyTransferError> {
|
||||
let config = Config::load(None)?;
|
||||
let keys = load_keys_or_empty(config.key_material())?;
|
||||
Ok(Self { config, keys })
|
||||
Ok(Self {
|
||||
config,
|
||||
keys,
|
||||
export_deadline: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> Vec<MobileKeyTransferKey> {
|
||||
@@ -169,6 +180,11 @@ impl MobileKeyTransferService {
|
||||
.find(|key| key.fingerprint().as_str() == fingerprint)
|
||||
.ok_or(MobileKeyTransferError::KeyUnavailable)?;
|
||||
let frames = encode_frames(armor)?;
|
||||
*self
|
||||
.export_deadline
|
||||
.lock()
|
||||
.map_err(|_| MobileKeyTransferError::KeyUnavailable)? =
|
||||
Some(Instant::now() + self.config.authentication_timeout().duration());
|
||||
Ok(MobileKeyTransferExport {
|
||||
key: transfer_key_with_kind(&info, kind),
|
||||
frames,
|
||||
@@ -178,13 +194,20 @@ impl MobileKeyTransferService {
|
||||
pub fn importer(&self) -> MobileKeyTransferImport {
|
||||
MobileKeyTransferImport::new(self.config.clone())
|
||||
}
|
||||
|
||||
pub fn export_visible(&self) -> bool {
|
||||
self.export_deadline
|
||||
.lock()
|
||||
.ok()
|
||||
.is_some_and(|deadline| deadline.is_some_and(|deadline| Instant::now() < deadline))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MobileKeyTransferImport {
|
||||
destination: MobileKeyTransferDestination,
|
||||
digest: Option<String>,
|
||||
total: Option<usize>,
|
||||
chunks: BTreeMap<usize, Vec<u8>>,
|
||||
chunks: BTreeMap<usize, SecretBytes>,
|
||||
complete: Option<SecretBytes>,
|
||||
key: Option<MobileKeyTransferKey>,
|
||||
}
|
||||
@@ -195,6 +218,31 @@ enum MobileKeyTransferDestination {
|
||||
}
|
||||
|
||||
impl MobileKeyTransferImport {
|
||||
pub fn add_camera(
|
||||
&mut self,
|
||||
frame: crate::mobile_platform::MobileCameraFrame,
|
||||
rotation_degrees: u32,
|
||||
) -> Result<Option<MobileKeyTransferProgress>, MobileKeyTransferError> {
|
||||
if frame.width > 2048
|
||||
|| frame.height > 2048
|
||||
|| frame.luminance.expose().len() > 4 * 1024 * 1024
|
||||
{
|
||||
return Err(MobileKeyTransferError::InvalidFrame);
|
||||
}
|
||||
match frame.decode_rotated(rotation_degrees) {
|
||||
Ok(payload) => self.add_frame(payload).map(Some),
|
||||
Err(QrError::NotFound) => Ok(None),
|
||||
Err(_) => Err(MobileKeyTransferError::InvalidFrame),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn completed_armor(&self) -> Result<SecretBytes, MobileKeyTransferError> {
|
||||
self.complete
|
||||
.as_ref()
|
||||
.map(|armor| SecretBytes::new(armor.expose().to_vec()))
|
||||
.ok_or(MobileKeyTransferError::IncompleteTransfer)
|
||||
}
|
||||
|
||||
fn new(config: Config) -> Self {
|
||||
Self {
|
||||
destination: MobileKeyTransferDestination::Configured(Box::new(config)),
|
||||
@@ -230,6 +278,9 @@ impl MobileKeyTransferImport {
|
||||
&mut self,
|
||||
payload: SecretBytes,
|
||||
) -> Result<MobileKeyTransferProgress, MobileKeyTransferError> {
|
||||
if payload.expose().len() > MAX_TRANSFER_BYTES {
|
||||
return Err(MobileKeyTransferError::InvalidFrame);
|
||||
}
|
||||
if self.complete.is_some() {
|
||||
return Err(MobileKeyTransferError::AlreadyComplete);
|
||||
}
|
||||
@@ -251,52 +302,58 @@ impl MobileKeyTransferImport {
|
||||
if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(MobileKeyTransferError::InvalidFrame);
|
||||
}
|
||||
let digest = digest.to_ascii_lowercase();
|
||||
let sequence = parse_positive(fields.next())?;
|
||||
let total = parse_positive(fields.next())?;
|
||||
if sequence > total || total > MAX_TRANSFER_FRAMES {
|
||||
return Err(MobileKeyTransferError::InvalidFrame);
|
||||
}
|
||||
let chunk = BASE64URL_NOPAD
|
||||
.decode(
|
||||
fields
|
||||
.next()
|
||||
.ok_or(MobileKeyTransferError::InvalidFrame)?
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|_| MobileKeyTransferError::InvalidFrame)?;
|
||||
if chunk.is_empty() || chunk.len() > FRAME_CHUNK_BYTES {
|
||||
let encoded = fields.next().ok_or(MobileKeyTransferError::InvalidFrame)?;
|
||||
if encoded.len() > FRAME_CHUNK_BYTES.div_ceil(3) * 4 {
|
||||
return Err(MobileKeyTransferError::InvalidFrame);
|
||||
}
|
||||
let chunk = SecretBytes::new(
|
||||
BASE64URL_NOPAD
|
||||
.decode(encoded.as_bytes())
|
||||
.map_err(|_| MobileKeyTransferError::InvalidFrame)?,
|
||||
);
|
||||
if chunk.expose().is_empty() || chunk.expose().len() > FRAME_CHUNK_BYTES {
|
||||
return Err(MobileKeyTransferError::InvalidFrame);
|
||||
}
|
||||
|
||||
if self.digest.as_deref().is_some_and(|value| value != digest)
|
||||
if self
|
||||
.digest
|
||||
.as_deref()
|
||||
.is_some_and(|value| value != digest.as_str())
|
||||
|| self.total.is_some_and(|value| value != total)
|
||||
{
|
||||
return Err(MobileKeyTransferError::MixedTransfers);
|
||||
}
|
||||
self.digest
|
||||
.get_or_insert_with(|| digest.to_ascii_lowercase());
|
||||
self.digest.get_or_insert(digest);
|
||||
self.total.get_or_insert(total);
|
||||
if let Some(existing) = self.chunks.get(&sequence) {
|
||||
if existing == &chunk {
|
||||
if existing.expose() == chunk.expose() {
|
||||
return Ok(self.progress(true));
|
||||
}
|
||||
return Err(MobileKeyTransferError::ConflictingFrame);
|
||||
}
|
||||
self.chunks.insert(sequence, chunk);
|
||||
if self.chunks.len() == total {
|
||||
let mut armor = Vec::new();
|
||||
let mut armor = zeroize::Zeroizing::new(Vec::new());
|
||||
for sequence in 1..=total {
|
||||
armor.extend(
|
||||
armor.extend_from_slice(
|
||||
self.chunks
|
||||
.get(&sequence)
|
||||
.ok_or(MobileKeyTransferError::IncompleteTransfer)?,
|
||||
.ok_or(MobileKeyTransferError::IncompleteTransfer)?
|
||||
.expose(),
|
||||
);
|
||||
}
|
||||
let actual = HEXLOWER.encode(&Sha256::digest(&armor));
|
||||
let actual = HEXLOWER.encode(&Sha256::digest(armor.as_slice()));
|
||||
if self.digest.as_deref() != Some(actual.as_str()) {
|
||||
return Err(MobileKeyTransferError::ChecksumMismatch);
|
||||
}
|
||||
self.finish(SecretBytes::new(armor))?;
|
||||
self.finish(SecretBytes::new(std::mem::take(&mut *armor)))?;
|
||||
self.chunks.clear();
|
||||
}
|
||||
Ok(self.progress(false))
|
||||
}
|
||||
@@ -435,7 +492,11 @@ impl MobileKeyTransferImport {
|
||||
|
||||
fn progress(&self, duplicate: bool) -> MobileKeyTransferProgress {
|
||||
MobileKeyTransferProgress {
|
||||
received: self.chunks.len().max(usize::from(self.complete.is_some())),
|
||||
received: if self.complete.is_some() {
|
||||
self.total.unwrap_or(1)
|
||||
} else {
|
||||
self.chunks.len()
|
||||
},
|
||||
total: self.total.unwrap_or(1),
|
||||
duplicate,
|
||||
key: self.key.clone(),
|
||||
@@ -576,7 +637,7 @@ fn encode_multipart(
|
||||
}
|
||||
let digest = HEXLOWER.encode(&Sha256::digest(armor.expose()));
|
||||
let total = armor.expose().len().div_ceil(chunk_bytes);
|
||||
if total == 0 || total > MAX_TRANSFER_FRAMES {
|
||||
if total == 0 || total > MAX_EXPORT_FRAMES {
|
||||
return Err(MobileKeyTransferError::QrPayload);
|
||||
}
|
||||
let mut frames = Vec::with_capacity(total);
|
||||
@@ -706,6 +767,7 @@ mod tests {
|
||||
MobileKeyTransferService, encode_multipart, load_keys_or_empty,
|
||||
};
|
||||
use crate::{config::Config, crypto::KeyStore, repository::SecretBytes};
|
||||
use std::{sync::Mutex, time::Instant};
|
||||
use tempfile::tempdir;
|
||||
|
||||
const ALICE_PUBLIC: &[u8] =
|
||||
@@ -714,6 +776,89 @@ mod tests {
|
||||
include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc");
|
||||
const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||
|
||||
#[test]
|
||||
fn export_rejects_oversized_matrix_sets_before_rendering() {
|
||||
assert!(matches!(
|
||||
encode_multipart(SecretBytes::new(vec![0; 257]), 1),
|
||||
Err(MobileKeyTransferError::QrPayload)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_visibility_is_bounded_and_does_not_renew_while_reading() {
|
||||
let fixture = configured_secret_key().unwrap();
|
||||
let service = MobileKeyTransferService {
|
||||
keys: load_keys_or_empty(fixture.config.key_material()).unwrap(),
|
||||
config: fixture.config,
|
||||
export_deadline: Mutex::new(None),
|
||||
};
|
||||
assert!(!service.export_visible());
|
||||
service
|
||||
.export(ALICE_FINGERPRINT, MobileKeyTransferKind::Public, None)
|
||||
.unwrap();
|
||||
assert!(service.export_visible());
|
||||
*service.export_deadline.lock().unwrap() = Some(Instant::now());
|
||||
assert!(!service.export_visible());
|
||||
assert!(!service.export_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_multipart_is_exact_duplicate_safe_and_transactional()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let fixture = configured_public_key()?;
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||
let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?;
|
||||
frames.reverse();
|
||||
for (index, frame) in frames.iter().enumerate() {
|
||||
let matrix = frame.matrix();
|
||||
let width = (matrix.width() + 8) * 4;
|
||||
let height = width + 8;
|
||||
let row = width * 2 + 13;
|
||||
let mut pixels = vec![255; height * row];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let dark = x / 4 >= 4
|
||||
&& y / 4 >= 4
|
||||
&& matrix.is_dark(x / 4 - 4, y / 4 - 4).unwrap_or(false);
|
||||
pixels[y * row + x * 2] = if dark { 0 } else { 255 };
|
||||
}
|
||||
}
|
||||
let camera = || crate::mobile_platform::MobileCameraFrame {
|
||||
width: width as u32,
|
||||
height: height as u32,
|
||||
row_stride: row as u32,
|
||||
pixel_stride: 2,
|
||||
luminance: SecretBytes::new(pixels.clone()),
|
||||
};
|
||||
let progress = importer
|
||||
.add_camera(camera(), [0, 90, 180, 270][index % 4])?
|
||||
.unwrap();
|
||||
assert_eq!(progress.received(), index + 1);
|
||||
if index + 1 < frames.len() {
|
||||
assert!(importer.add_camera(camera(), 0)?.unwrap().duplicate());
|
||||
assert_eq!(
|
||||
importer.import(None, true, false),
|
||||
Err(MobileKeyTransferError::IncompleteTransfer)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(importer.completed_armor()?.expose(), ALICE_SECRET);
|
||||
assert_eq!(
|
||||
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false),
|
||||
Err(MobileKeyTransferError::IncorrectPassphrase)
|
||||
);
|
||||
let before = std::fs::read(fixture.key_path.join("alice-public.asc"))?;
|
||||
importer.import(
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
assert_eq!(before, ALICE_PUBLIC);
|
||||
let keys = KeyStore::load(fixture.config.key_material())?;
|
||||
assert!(keys.infos().any(|key| key.has_secret()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qr_images_reconstruct_the_same_key_and_reject_invalid_images()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -750,8 +895,14 @@ mod tests {
|
||||
let mut importer = MobileKeyTransferImport::new(fixture.config);
|
||||
let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?;
|
||||
frames.reverse();
|
||||
for frame in frames {
|
||||
importer.add_frame(SecretBytes::new(frame.payload().to_vec()))?;
|
||||
for (index, frame) in frames.into_iter().enumerate() {
|
||||
let mut payload = frame.payload().to_vec();
|
||||
if index % 2 == 0 {
|
||||
for byte in &mut payload[6..70] {
|
||||
byte.make_ascii_uppercase();
|
||||
}
|
||||
}
|
||||
importer.add_frame(SecretBytes::new(payload))?;
|
||||
}
|
||||
assert_eq!(
|
||||
importer.complete.as_ref().map(SecretBytes::expose),
|
||||
@@ -769,6 +920,7 @@ mod tests {
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let fixture = configured_secret_key()?;
|
||||
let service = MobileKeyTransferService {
|
||||
export_deadline: Mutex::new(None),
|
||||
config: fixture.config.clone(),
|
||||
keys: KeyStore::load(&fixture.key_path)?,
|
||||
};
|
||||
@@ -953,6 +1105,7 @@ mod tests {
|
||||
let fixture = configured_key("placeholder", b"")?;
|
||||
std::fs::remove_file(fixture.key_path.join("placeholder"))?;
|
||||
let service = MobileKeyTransferService {
|
||||
export_deadline: Mutex::new(None),
|
||||
keys: load_keys_or_empty(&fixture.key_path)?,
|
||||
config: fixture.config.clone(),
|
||||
};
|
||||
|
||||
@@ -163,6 +163,15 @@ impl AndroidOnboarding {
|
||||
self.status_unlocked()
|
||||
}
|
||||
|
||||
pub fn import_scanned(
|
||||
&self,
|
||||
transfer: &crate::mobile_key_transfer::MobileKeyTransferImport,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<AndroidOnboardingStatus, crate::mobile_key_transfer::MobileKeyTransferError> {
|
||||
self.import(transfer.completed_armor()?, passphrase)
|
||||
.map_err(crate::mobile_key_transfer::MobileKeyTransferError::LocalSetup)
|
||||
}
|
||||
|
||||
fn stage_key(
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
@@ -1052,6 +1061,40 @@ pub(crate) mod tests {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn scanned_import_stages_exact_key_without_creating_a_store() {
|
||||
use crate::mobile_key_transfer::{MobileKeyTransferError, MobileKeyTransferImport};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let private = storage(temp.path());
|
||||
let backend = Arc::new(Backend::default());
|
||||
let setup = AndroidOnboarding::new(&private, backend).unwrap();
|
||||
let mut transfer = MobileKeyTransferImport::local_setup();
|
||||
assert!(matches!(
|
||||
setup.import_scanned(&transfer, password()),
|
||||
Err(MobileKeyTransferError::IncompleteTransfer)
|
||||
));
|
||||
transfer
|
||||
.add_frame(SecretBytes::new(
|
||||
include_bytes!("../../tests/fixtures/compatibility/keys/alice-secret.asc").to_vec(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
setup
|
||||
.import_scanned(&transfer, Some(SecretBytes::new(b"wrong".to_vec())))
|
||||
.is_err()
|
||||
);
|
||||
assert!(setup.status().unwrap().keys.is_empty());
|
||||
let status = setup.import_scanned(&transfer, password()).unwrap();
|
||||
assert_eq!(status.keys[0].fingerprint().as_str(), ALICE);
|
||||
assert!(!status.configured);
|
||||
assert!(setup.configuration().unwrap().is_none());
|
||||
assert!(matches!(
|
||||
setup.import_scanned(&transfer, password()),
|
||||
Err(MobileKeyTransferError::LocalSetup(_))
|
||||
));
|
||||
assert_eq!(setup.status().unwrap().keys.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_key_restarts_then_creates_a_pass_compatible_local_store() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -90,6 +90,10 @@ pub struct MobileCameraFrame {
|
||||
|
||||
impl MobileCameraFrame {
|
||||
pub fn decode(&self) -> Result<SecretBytes, QrError> {
|
||||
self.decode_rotated(0)
|
||||
}
|
||||
|
||||
pub fn decode_rotated(&self, rotation_degrees: u32) -> Result<SecretBytes, QrError> {
|
||||
let width = self.width as usize;
|
||||
let height = self.height as usize;
|
||||
let row = self.row_stride as usize;
|
||||
@@ -99,6 +103,7 @@ impl MobileCameraFrame {
|
||||
|| !(1..=4096).contains(&height)
|
||||
|| !(1..=4).contains(&pixel)
|
||||
|| bytes.len() > 32 * 1024 * 1024
|
||||
|| !matches!(rotation_degrees, 0 | 90 | 180 | 270)
|
||||
{
|
||||
return Err(QrError::InvalidImage);
|
||||
}
|
||||
@@ -112,7 +117,20 @@ impl MobileCameraFrame {
|
||||
if row <= last_column || last >= bytes.len() {
|
||||
return Err(QrError::InvalidImage);
|
||||
}
|
||||
QrMatrix::decode_luminance(width, height, |x, y| bytes[y * row + x * pixel])
|
||||
let (output_width, output_height) = if matches!(rotation_degrees, 90 | 270) {
|
||||
(height, width)
|
||||
} else {
|
||||
(width, height)
|
||||
};
|
||||
QrMatrix::decode_luminance(output_width, output_height, |x, y| {
|
||||
let (x, y) = match rotation_degrees {
|
||||
90 => (y, height - 1 - x),
|
||||
180 => (width - 1 - x, height - 1 - y),
|
||||
270 => (width - 1 - y, x),
|
||||
_ => (x, y),
|
||||
};
|
||||
bytes[y * row + x * pixel]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +241,61 @@ impl fmt::Debug for MobileCredentialRequest {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn camera_planes_decode_padded_pixels_at_every_orientation() {
|
||||
let payload = SecretBytes::new(b"camera-qr-byte-exact".to_vec());
|
||||
let matrix = QrMatrix::encode(&payload).unwrap();
|
||||
let size = (matrix.width() + 8) * 5;
|
||||
let row = size * 2 + 7;
|
||||
let mut pixels = vec![255; size * row];
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dark = x / 5 >= 4
|
||||
&& y / 5 >= 4
|
||||
&& matrix.is_dark(x / 5 - 4, y / 5 - 4).unwrap_or(false);
|
||||
pixels[y * row + x * 2] = if dark { 0 } else { 255 };
|
||||
}
|
||||
}
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let decoded = MobileCameraFrame {
|
||||
luminance: SecretBytes::new(pixels.clone()),
|
||||
width: size as u32,
|
||||
height: size as u32,
|
||||
row_stride: row as u32,
|
||||
pixel_stride: 2,
|
||||
}
|
||||
.decode_rotated(rotation)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.expose(), payload.expose());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_rejects_truncated_oversized_and_invalid_planes() {
|
||||
for (width, height, row, step, rotation, length) in [
|
||||
(0, 1, 1, 1, 0, 1),
|
||||
(4097, 1, 4097, 1, 0, 4097),
|
||||
(2, 2, 2, 1, 0, 3),
|
||||
(2, 2, 1, 1, 0, 4),
|
||||
(2, 2, 2, 0, 0, 4),
|
||||
(2, 2, 2, 1, 45, 4),
|
||||
(2, 2, u32::MAX, 1, 0, 4),
|
||||
(1, 1, 1, 1, 0, 32 * 1024 * 1024 + 1),
|
||||
] {
|
||||
assert!(matches!(
|
||||
MobileCameraFrame {
|
||||
luminance: SecretBytes::new(vec![255; length]),
|
||||
width,
|
||||
height,
|
||||
row_stride: row,
|
||||
pixel_stride: step,
|
||||
}
|
||||
.decode_rotated(rotation),
|
||||
Err(QrError::InvalidImage)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots_and_caller_facts_are_validated_without_creating_data_or_grants() {
|
||||
let paths =
|
||||
|
||||
Reference in new issue
Block a user