Merge branch 'master' into nfc-example-app
This commit is contained in:
2
.github/workflows/cargo_fuzz.yml
vendored
2
.github/workflows/cargo_fuzz.yml
vendored
@@ -29,3 +29,5 @@ jobs:
|
||||
run: cargo fuzz build
|
||||
- name: Cargo fuzz build (libraries/cbor)
|
||||
run: cd libraries/cbor && cargo fuzz build && cd ../..
|
||||
- name: Cargo fuzz build (libraries/persistent_store)
|
||||
run: cd libraries/persistent_store && cargo fuzz build && cd ../..
|
||||
|
||||
4
libraries/persistent_store/fuzz/.gitignore
vendored
Normal file
4
libraries/persistent_store/fuzz/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/Cargo.lock
|
||||
/artifacts/
|
||||
/corpus/
|
||||
/target/
|
||||
21
libraries/persistent_store/fuzz/Cargo.toml
Normal file
21
libraries/persistent_store/fuzz/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "fuzz-store"
|
||||
version = "0.0.0"
|
||||
authors = ["Julien Cretin <cretin@google.com>"]
|
||||
publish = false
|
||||
edition = "2018"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.3"
|
||||
persistent_store = { path = "..", features = ["std"] }
|
||||
|
||||
# Prevent this from interfering with workspaces
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[[bin]]
|
||||
name = "store"
|
||||
path = "fuzz_targets/store.rs"
|
||||
21
libraries/persistent_store/fuzz/fuzz_targets/store.rs
Normal file
21
libraries/persistent_store/fuzz/fuzz_targets/store.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright 2019-2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// TODO(ia0): Call fuzzing when implemented.
|
||||
});
|
||||
191
libraries/persistent_store/fuzz/src/lib.rs
Normal file
191
libraries/persistent_store/fuzz/src/lib.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright 2019-2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Fuzzing library for the persistent store.
|
||||
//!
|
||||
//! The overall design principles are (in order of precedence):
|
||||
//! - Determinism: fuzzing is a function from seeds (byte slices) to sequences of store
|
||||
//! manipulations (things like creating a store, applying operations, interrupting operations,
|
||||
//! interrupting reboots, checking invariant, etc). We can replay this function on the same input
|
||||
//! to get the same sequence of manipulations (for the same fuzzing and store code).
|
||||
//! - Coverage: fuzzing tries to coverage as much different behaviors as possible for small seeds.
|
||||
//! Ideally, each seed bit would control a branch decision in the tree of execution paths.
|
||||
//! - Surjectivity: all sequences of manipulations are reachable by fuzzing for some seed. The only
|
||||
//! situation where coverage takes precedence over surjectivity is for the value of insert updates
|
||||
//! where a pseudo-random generator is used to avoid wasting entropy.
|
||||
|
||||
// TODO(ia0): Remove when used.
|
||||
#![allow(dead_code)]
|
||||
|
||||
/// Bit-level entropy source based on a byte slice shared reference.
|
||||
///
|
||||
/// This is used to convert the byte slice provided by the fuzzer into the entropy used by the
|
||||
/// fuzzing code to generate a sequence of store manipulations, among other things. Entropy
|
||||
/// operations use the shortest necessary sequence of bits from the byte slice, such that fuzzer
|
||||
/// mutations of the byte slice have local impact or cascading effects towards future operations
|
||||
/// only.
|
||||
///
|
||||
/// The entropy has the following properties (in order of precedence):
|
||||
/// - It always returns a result.
|
||||
/// - It is deterministic: for a given slice and a given sequence of operations, the same results
|
||||
/// are returned. This permits to replay and debug fuzzing artifacts.
|
||||
/// - It uses the slice as a bit stream. In particular, it doesn't do big number arithmetic. This
|
||||
/// permits to have a simple implementation.
|
||||
/// - It doesn't waste information: for a given operation, the minimum integer number of bits is
|
||||
/// used to produce the result. As a consequence fractional bits can be wasted at each operation.
|
||||
/// - It uses the information uniformly: each bit is used exactly once, except when only a fraction
|
||||
/// of it is used. In particular, a bit is not used more than once. A consequence of each bit
|
||||
/// being used essentially once, is that the results are mostly uniformly distributed.
|
||||
///
|
||||
/// # Invariant
|
||||
///
|
||||
/// - The bit is a valid position in the slice, or one past: `bit <= 8 * data.len()`.
|
||||
struct Entropy<'a> {
|
||||
/// The byte slice shared reference providing the entropy.
|
||||
data: &'a [u8],
|
||||
|
||||
/// The bit position in the byte slice of the next entropy bit.
|
||||
bit: usize,
|
||||
}
|
||||
|
||||
impl Entropy<'_> {
|
||||
/// Creates a bit-level entropy given a byte slice.
|
||||
fn new(data: &[u8]) -> Entropy {
|
||||
let bit = 0;
|
||||
Entropy { data, bit }
|
||||
}
|
||||
|
||||
/// Consumes the remaining entropy.
|
||||
fn consume_all(&mut self) {
|
||||
self.bit = 8 * self.data.len();
|
||||
}
|
||||
|
||||
/// Returns whether there is entropy remaining.
|
||||
fn is_empty(&self) -> bool {
|
||||
assert!(self.bit <= 8 * self.data.len());
|
||||
self.bit == 8 * self.data.len()
|
||||
}
|
||||
|
||||
/// Reads a bit.
|
||||
fn read_bit(&mut self) -> bool {
|
||||
if self.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let b = self.bit;
|
||||
self.bit += 1;
|
||||
self.data[b / 8] & 1 << (b % 8) != 0
|
||||
}
|
||||
|
||||
/// Reads a number with a given bit-width.
|
||||
///
|
||||
/// # Preconditions
|
||||
///
|
||||
/// - The number should fit in the return type: `n <= 8 * size_of::<usize>()`.
|
||||
fn read_bits(&mut self, n: usize) -> usize {
|
||||
assert!(n <= 8 * std::mem::size_of::<usize>());
|
||||
let mut r = 0;
|
||||
for i in 0..n {
|
||||
r |= (self.read_bit() as usize) << i;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Reads a byte.
|
||||
fn read_byte(&mut self) -> u8 {
|
||||
self.read_bits(8) as u8
|
||||
}
|
||||
|
||||
/// Reads a slice.
|
||||
fn read_slice(&mut self, length: usize) -> Vec<u8> {
|
||||
let mut result = Vec::with_capacity(length);
|
||||
for _ in 0..length {
|
||||
result.push(self.read_byte());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Reads a number between `min` and `max` (inclusive bounds).
|
||||
///
|
||||
/// The distribution is uniform if the range width is a power of two. Otherwise, the minimum
|
||||
/// amount of entropy is used (the next power of two) and the distribution is the closest to
|
||||
/// uniform for that entropy.
|
||||
///
|
||||
/// # Preconditions
|
||||
///
|
||||
/// - The bounds should be correctly ordered: `min <= max`.
|
||||
/// - The upper-bound should not be too large: `max < usize::max_value()`.
|
||||
fn read_range(&mut self, min: usize, max: usize) -> usize {
|
||||
assert!(min <= max && max < usize::max_value());
|
||||
let count = max - min + 1;
|
||||
let delta = self.read_bits(num_bits(count - 1)) % count;
|
||||
min + delta
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of bits necessary to represent a number.
|
||||
fn num_bits(x: usize) -> usize {
|
||||
8 * std::mem::size_of::<usize>() - x.leading_zeros() as usize
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn num_bits_ok() {
|
||||
assert_eq!(num_bits(0), 0);
|
||||
assert_eq!(num_bits(1), 1);
|
||||
assert_eq!(num_bits(2), 2);
|
||||
assert_eq!(num_bits(3), 2);
|
||||
assert_eq!(num_bits(4), 3);
|
||||
assert_eq!(num_bits(7), 3);
|
||||
assert_eq!(num_bits(8), 4);
|
||||
assert_eq!(num_bits(15), 4);
|
||||
assert_eq!(num_bits(16), 5);
|
||||
assert_eq!(
|
||||
num_bits(usize::max_value()),
|
||||
8 * std::mem::size_of::<usize>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bit_ok() {
|
||||
let mut entropy = Entropy::new(&[0b10110010]);
|
||||
assert!(!entropy.read_bit());
|
||||
assert!(entropy.read_bit());
|
||||
assert!(!entropy.read_bit());
|
||||
assert!(!entropy.read_bit());
|
||||
assert!(entropy.read_bit());
|
||||
assert!(entropy.read_bit());
|
||||
assert!(!entropy.read_bit());
|
||||
assert!(entropy.read_bit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bits_ok() {
|
||||
let mut entropy = Entropy::new(&[0x83, 0x92]);
|
||||
assert_eq!(entropy.read_bits(4), 0x3);
|
||||
assert_eq!(entropy.read_bits(8), 0x28);
|
||||
assert_eq!(entropy.read_bits(2), 0b01);
|
||||
assert_eq!(entropy.read_bits(2), 0b10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_range_ok() {
|
||||
let mut entropy = Entropy::new(&[0b00101011]);
|
||||
assert_eq!(entropy.read_range(0, 7), 0b011);
|
||||
assert_eq!(entropy.read_range(1, 8), 1 + 0b101);
|
||||
assert_eq!(entropy.read_range(4, 6), 4 + 0b00);
|
||||
let mut entropy = Entropy::new(&[0b00101011]);
|
||||
assert_eq!(entropy.read_range(0, 8), 0b1011 % 9);
|
||||
assert_eq!(entropy.read_range(3, 15), 3 + 0b0010);
|
||||
let mut entropy = Entropy::new(&[0x12, 0x34, 0x56, 0x78]);
|
||||
assert_eq!(entropy.read_range(0, usize::max_value() - 1), 0x78563412);
|
||||
}
|
||||
640
libraries/persistent_store/src/driver.rs
Normal file
640
libraries/persistent_store/src/driver.rs
Normal file
@@ -0,0 +1,640 @@
|
||||
// Copyright 2019-2020 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::format::{Format, Position};
|
||||
#[cfg(test)]
|
||||
use crate::StoreUpdate;
|
||||
use crate::{
|
||||
BufferCorruptFunction, BufferOptions, BufferStorage, Nat, Store, StoreError, StoreHandle,
|
||||
StoreModel, StoreOperation, StoreResult,
|
||||
};
|
||||
|
||||
/// Tracks the store behavior against its model and its storage.
|
||||
#[derive(Clone)]
|
||||
pub enum StoreDriver {
|
||||
/// When the store is running.
|
||||
On(StoreDriverOn),
|
||||
|
||||
/// When the store is off.
|
||||
Off(StoreDriverOff),
|
||||
}
|
||||
|
||||
/// Keeps a power-on store and its model in sync.
|
||||
#[derive(Clone)]
|
||||
pub struct StoreDriverOn {
|
||||
/// The store being tracked.
|
||||
store: Store<BufferStorage>,
|
||||
|
||||
/// The model associated to the store.
|
||||
model: StoreModel,
|
||||
}
|
||||
|
||||
/// Keeps a power-off store and its potential models in sync.
|
||||
#[derive(Clone)]
|
||||
pub struct StoreDriverOff {
|
||||
/// The storage of the store being tracked.
|
||||
storage: BufferStorage,
|
||||
|
||||
/// The last valid model before power off.
|
||||
model: StoreModel,
|
||||
|
||||
/// In case of interrupted operation, the invariant after completion.
|
||||
complete: Option<Complete>,
|
||||
}
|
||||
|
||||
/// The invariant a store must satisfy if an interrupted operation completes.
|
||||
#[derive(Clone)]
|
||||
struct Complete {
|
||||
/// The model after the operation completes.
|
||||
model: StoreModel,
|
||||
|
||||
/// The entries that should be deleted after the operation completes.
|
||||
deleted: Vec<StoreHandle>,
|
||||
}
|
||||
|
||||
/// Specifies an interruption.
|
||||
pub struct StoreInterruption<'a> {
|
||||
/// After how many storage operations the interruption should happen.
|
||||
pub delay: usize,
|
||||
|
||||
/// How the interrupted operation should be corrupted.
|
||||
pub corrupt: BufferCorruptFunction<'a>,
|
||||
}
|
||||
|
||||
/// Possible ways a driver operation may fail.
|
||||
#[derive(Debug)]
|
||||
pub enum StoreInvariant {
|
||||
/// The store reached its lifetime.
|
||||
///
|
||||
/// This is not simulated by the model. So the operation should be ignored.
|
||||
NoLifetime,
|
||||
|
||||
/// The store returned an unexpected error.
|
||||
StoreError(StoreError),
|
||||
|
||||
/// The store did not recover an interrupted operation.
|
||||
Interrupted {
|
||||
/// The reason why the store didn't rollback the operation.
|
||||
rollback: Box<StoreInvariant>,
|
||||
|
||||
/// The reason why the store didn't complete the operation.
|
||||
complete: Box<StoreInvariant>,
|
||||
},
|
||||
|
||||
/// The store returned a different result than the model.
|
||||
DifferentResult {
|
||||
/// The result of the store.
|
||||
store: StoreResult<()>,
|
||||
|
||||
/// The result of the model.
|
||||
model: StoreResult<()>,
|
||||
},
|
||||
|
||||
/// The store did not wipe an entry.
|
||||
NotWiped {
|
||||
/// The key of the entry that has not been wiped.
|
||||
key: usize,
|
||||
|
||||
/// The value of the entry in the storage.
|
||||
value: Vec<u8>,
|
||||
},
|
||||
|
||||
/// The store has an entry not present in the model.
|
||||
OnlyInStore {
|
||||
/// The key of the additional entry.
|
||||
key: usize,
|
||||
},
|
||||
|
||||
/// The store has a different value than the model for an entry.
|
||||
DifferentValue {
|
||||
/// The key of the entry with a different value.
|
||||
key: usize,
|
||||
|
||||
/// The value of the entry in the store.
|
||||
store: Box<[u8]>,
|
||||
|
||||
/// The value of the entry in the model.
|
||||
model: Box<[u8]>,
|
||||
},
|
||||
|
||||
/// The store is missing an entry from the model.
|
||||
OnlyInModel {
|
||||
/// The key of the missing entry.
|
||||
key: usize,
|
||||
},
|
||||
|
||||
/// The store reports a different capacity than the model.
|
||||
DifferentCapacity {
|
||||
/// The capacity according to the store.
|
||||
store: usize,
|
||||
|
||||
/// The capacity according to the model.
|
||||
model: usize,
|
||||
},
|
||||
|
||||
/// The store failed to track the number of erase cycles correctly.
|
||||
DifferentErase {
|
||||
/// The first page in physical storage order with a wrong value.
|
||||
page: usize,
|
||||
|
||||
/// How many times the page has been erased according to the store.
|
||||
store: usize,
|
||||
|
||||
/// How many times the page has been erased according to the model.
|
||||
model: usize,
|
||||
},
|
||||
|
||||
/// The store failed to track the number of word writes correctly.
|
||||
DifferentWrite {
|
||||
/// The first page in physical storage order with a wrong value.
|
||||
page: usize,
|
||||
|
||||
/// The first word in the page with a wrong value.
|
||||
word: usize,
|
||||
|
||||
/// How many times the word has been written according to the store.
|
||||
///
|
||||
/// This value is exact only for the metadata of the page. For the content of the page, it
|
||||
/// is set to:
|
||||
/// - 0 if the word is after the tail. Such word should not have been written.
|
||||
/// - 1 if the word is before the tail. Such word may or may not have been written.
|
||||
store: usize,
|
||||
|
||||
/// How many times the word has been written according to the model.
|
||||
///
|
||||
/// This value is exact only for the metadata of the page. For the content of the page, it
|
||||
/// is set to:
|
||||
/// - 0 if the word was not written.
|
||||
/// - 1 if the word was written.
|
||||
model: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl StoreDriver {
|
||||
/// Provides read-only access to the storage.
|
||||
pub fn storage(&self) -> &BufferStorage {
|
||||
match self {
|
||||
StoreDriver::On(x) => x.store().storage(),
|
||||
StoreDriver::Off(x) => x.storage(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the power-on version of the driver.
|
||||
pub fn on(self) -> Option<StoreDriverOn> {
|
||||
match self {
|
||||
StoreDriver::On(x) => Some(x),
|
||||
StoreDriver::Off(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Powers on the store if not already on.
|
||||
pub fn power_on(self) -> Result<StoreDriverOn, StoreInvariant> {
|
||||
match self {
|
||||
StoreDriver::On(x) => Ok(x),
|
||||
StoreDriver::Off(x) => x.power_on(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the power-off version of the driver.
|
||||
pub fn off(self) -> Option<StoreDriverOff> {
|
||||
match self {
|
||||
StoreDriver::On(_) => None,
|
||||
StoreDriver::Off(x) => Some(x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreDriverOff {
|
||||
/// Starts a simulation with a clean storage given its configuration.
|
||||
pub fn new(options: BufferOptions, num_pages: usize) -> StoreDriverOff {
|
||||
let storage = vec![0xff; num_pages * options.page_size].into_boxed_slice();
|
||||
let storage = BufferStorage::new(storage, options);
|
||||
StoreDriverOff::new_dirty(storage)
|
||||
}
|
||||
|
||||
/// Starts a simulation from an existing storage.
|
||||
pub fn new_dirty(storage: BufferStorage) -> StoreDriverOff {
|
||||
let format = Format::new(&storage).unwrap();
|
||||
StoreDriverOff {
|
||||
storage,
|
||||
model: StoreModel::new(format),
|
||||
complete: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides read-only access to the storage.
|
||||
pub fn storage(&self) -> &BufferStorage {
|
||||
&self.storage
|
||||
}
|
||||
|
||||
/// Provides mutable access to the storage.
|
||||
pub fn storage_mut(&mut self) -> &mut BufferStorage {
|
||||
&mut self.storage
|
||||
}
|
||||
|
||||
/// Provides read-only access to the model.
|
||||
pub fn model(&self) -> &StoreModel {
|
||||
&self.model
|
||||
}
|
||||
|
||||
/// Powers on the store without interruption.
|
||||
pub fn power_on(self) -> Result<StoreDriverOn, StoreInvariant> {
|
||||
Ok(self
|
||||
.partial_power_on(StoreInterruption::none())
|
||||
.map_err(|x| x.1)?
|
||||
.on()
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// Powers on the store with a possible interruption.
|
||||
pub fn partial_power_on(
|
||||
mut self,
|
||||
interruption: StoreInterruption,
|
||||
) -> Result<StoreDriver, (BufferStorage, StoreInvariant)> {
|
||||
self.storage.arm_interruption(interruption.delay);
|
||||
Ok(match Store::new(self.storage) {
|
||||
Ok(mut store) => {
|
||||
store.storage_mut().disarm_interruption();
|
||||
let mut error = None;
|
||||
if let Some(complete) = self.complete {
|
||||
match StoreDriverOn::new(store, complete.model, &complete.deleted) {
|
||||
Ok(driver) => return Ok(StoreDriver::On(driver)),
|
||||
Err((e, x)) => {
|
||||
error = Some(e);
|
||||
store = x;
|
||||
}
|
||||
}
|
||||
};
|
||||
StoreDriver::On(StoreDriverOn::new(store, self.model, &[]).map_err(
|
||||
|(rollback, store)| {
|
||||
let storage = store.extract_storage();
|
||||
match error {
|
||||
None => (storage, rollback),
|
||||
Some(complete) => {
|
||||
let rollback = Box::new(rollback);
|
||||
let complete = Box::new(complete);
|
||||
(storage, StoreInvariant::Interrupted { rollback, complete })
|
||||
}
|
||||
}
|
||||
},
|
||||
)?)
|
||||
}
|
||||
Err((StoreError::StorageError, mut storage)) => {
|
||||
storage.corrupt_operation(interruption.corrupt);
|
||||
StoreDriver::Off(StoreDriverOff { storage, ..self })
|
||||
}
|
||||
Err((error, mut storage)) => {
|
||||
storage.reset_interruption();
|
||||
return Err((storage, StoreInvariant::StoreError(error)));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a mapping from delay time to number of modified bits.
|
||||
///
|
||||
/// For example if the `i`-th value is `n`, it means that the `i`-th operation modifies `n` bits
|
||||
/// in the storage. For convenience, the vector always ends with `0` for one past the last
|
||||
/// operation. This permits to choose a random index in the vector and then a random set of bit
|
||||
/// positions among the number of modified bits to simulate any possible corruption (including
|
||||
/// no corruption with the last index).
|
||||
pub fn delay_map(&self) -> Result<Vec<usize>, (usize, BufferStorage)> {
|
||||
let mut result = Vec::new();
|
||||
loop {
|
||||
let delay = result.len();
|
||||
let mut storage = self.storage.clone();
|
||||
storage.arm_interruption(delay);
|
||||
match Store::new(storage) {
|
||||
Err((StoreError::StorageError, x)) => storage = x,
|
||||
Err((StoreError::InvalidStorage, mut storage)) => {
|
||||
storage.reset_interruption();
|
||||
return Err((delay, storage));
|
||||
}
|
||||
Ok(_) | Err(_) => break,
|
||||
}
|
||||
result.push(count_modified_bits(&mut storage));
|
||||
}
|
||||
result.push(0);
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreDriverOn {
|
||||
/// Provides read-only access to the store.
|
||||
pub fn store(&self) -> &Store<BufferStorage> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// Extracts the store.
|
||||
pub fn extract_store(self) -> Store<BufferStorage> {
|
||||
self.store
|
||||
}
|
||||
|
||||
/// Provides mutable access to the store.
|
||||
pub fn store_mut(&mut self) -> &mut Store<BufferStorage> {
|
||||
&mut self.store
|
||||
}
|
||||
|
||||
/// Provides read-only access to the model.
|
||||
pub fn model(&self) -> &StoreModel {
|
||||
&self.model
|
||||
}
|
||||
|
||||
/// Applies a store operation to the store and model without interruption.
|
||||
pub fn apply(&mut self, operation: StoreOperation) -> Result<(), StoreInvariant> {
|
||||
let (deleted, store_result) = self.store.apply(&operation);
|
||||
let model_result = self.model.apply(operation);
|
||||
if store_result != model_result {
|
||||
return Err(StoreInvariant::DifferentResult {
|
||||
store: store_result,
|
||||
model: model_result,
|
||||
});
|
||||
}
|
||||
self.check_deleted(&deleted)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a store operation to the store and model with a possible interruption.
|
||||
pub fn partial_apply(
|
||||
mut self,
|
||||
operation: StoreOperation,
|
||||
interruption: StoreInterruption,
|
||||
) -> Result<(Option<StoreError>, StoreDriver), (Store<BufferStorage>, StoreInvariant)> {
|
||||
self.store
|
||||
.storage_mut()
|
||||
.arm_interruption(interruption.delay);
|
||||
let (deleted, store_result) = self.store.apply(&operation);
|
||||
Ok(match store_result {
|
||||
Err(StoreError::NoLifetime) => return Err((self.store, StoreInvariant::NoLifetime)),
|
||||
Ok(()) | Err(StoreError::NoCapacity) | Err(StoreError::InvalidArgument) => {
|
||||
self.store.storage_mut().disarm_interruption();
|
||||
let model_result = self.model.apply(operation);
|
||||
if store_result != model_result {
|
||||
return Err((
|
||||
self.store,
|
||||
StoreInvariant::DifferentResult {
|
||||
store: store_result,
|
||||
model: model_result,
|
||||
},
|
||||
));
|
||||
}
|
||||
if store_result.is_ok() {
|
||||
if let Err(invariant) = self.check_deleted(&deleted) {
|
||||
return Err((self.store, invariant));
|
||||
}
|
||||
}
|
||||
(store_result.err(), StoreDriver::On(self))
|
||||
}
|
||||
Err(StoreError::StorageError) => {
|
||||
let mut driver = StoreDriverOff {
|
||||
storage: self.store.extract_storage(),
|
||||
model: self.model,
|
||||
complete: None,
|
||||
};
|
||||
driver.storage.corrupt_operation(interruption.corrupt);
|
||||
let mut model = driver.model.clone();
|
||||
if model.apply(operation).is_ok() {
|
||||
driver.complete = Some(Complete { model, deleted });
|
||||
}
|
||||
(None, StoreDriver::Off(driver))
|
||||
}
|
||||
Err(error) => return Err((self.store, StoreInvariant::StoreError(error))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a mapping from delay time to number of modified bits.
|
||||
///
|
||||
/// See the documentation of [`StoreDriverOff::delay_map`] for details.
|
||||
///
|
||||
/// [`StoreDriverOff::delay_map`]: struct.StoreDriverOff.html#method.delay_map
|
||||
pub fn delay_map(
|
||||
&self,
|
||||
operation: &StoreOperation,
|
||||
) -> Result<Vec<usize>, (usize, BufferStorage)> {
|
||||
let mut result = Vec::new();
|
||||
loop {
|
||||
let delay = result.len();
|
||||
let mut store = self.store.clone();
|
||||
store.storage_mut().arm_interruption(delay);
|
||||
match store.apply(operation).1 {
|
||||
Err(StoreError::StorageError) => (),
|
||||
Err(StoreError::InvalidStorage) => return Err((delay, store.extract_storage())),
|
||||
Ok(()) | Err(_) => break,
|
||||
}
|
||||
result.push(count_modified_bits(store.storage_mut()));
|
||||
}
|
||||
result.push(0);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Powers off the store.
|
||||
pub fn power_off(self) -> StoreDriverOff {
|
||||
StoreDriverOff {
|
||||
storage: self.store.extract_storage(),
|
||||
model: self.model,
|
||||
complete: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an insertion to the store and model without interruption.
|
||||
#[cfg(test)]
|
||||
pub fn insert(&mut self, key: usize, value: &[u8]) -> Result<(), StoreInvariant> {
|
||||
let value = value.to_vec();
|
||||
let updates = vec![StoreUpdate::Insert { key, value }];
|
||||
self.apply(StoreOperation::Transaction { updates })
|
||||
}
|
||||
|
||||
/// Applies a deletion to the store and model without interruption.
|
||||
#[cfg(test)]
|
||||
pub fn remove(&mut self, key: usize) -> Result<(), StoreInvariant> {
|
||||
let updates = vec![StoreUpdate::Remove { key }];
|
||||
self.apply(StoreOperation::Transaction { updates })
|
||||
}
|
||||
|
||||
/// Checks that the store and model are in sync.
|
||||
pub fn check(&self) -> Result<(), StoreInvariant> {
|
||||
self.recover_check(&[])
|
||||
}
|
||||
|
||||
/// Starts a simulation from a power-off store.
|
||||
///
|
||||
/// Checks that the store and model are in sync and that the given deleted entries are wiped.
|
||||
fn new(
|
||||
store: Store<BufferStorage>,
|
||||
model: StoreModel,
|
||||
deleted: &[StoreHandle],
|
||||
) -> Result<StoreDriverOn, (StoreInvariant, Store<BufferStorage>)> {
|
||||
let driver = StoreDriverOn { store, model };
|
||||
match driver.recover_check(deleted) {
|
||||
Ok(()) => Ok(driver),
|
||||
Err(error) => Err((error, driver.store)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that the store and model are in sync and that the given entries are wiped.
|
||||
fn recover_check(&self, deleted: &[StoreHandle]) -> Result<(), StoreInvariant> {
|
||||
self.check_deleted(deleted)?;
|
||||
self.check_model()?;
|
||||
self.check_storage()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks that the given entries are wiped from the storage.
|
||||
fn check_deleted(&self, deleted: &[StoreHandle]) -> Result<(), StoreInvariant> {
|
||||
for handle in deleted {
|
||||
let value = self.store.inspect_value(&handle);
|
||||
if !value.iter().all(|&x| x == 0x00) {
|
||||
return Err(StoreInvariant::NotWiped {
|
||||
key: handle.get_key(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks that the store and model are in sync.
|
||||
fn check_model(&self) -> Result<(), StoreInvariant> {
|
||||
let mut model_content = self.model.content().clone();
|
||||
for handle in self.store.iter().unwrap() {
|
||||
let handle = handle.unwrap();
|
||||
let model_value = match model_content.remove(&handle.get_key()) {
|
||||
None => {
|
||||
return Err(StoreInvariant::OnlyInStore {
|
||||
key: handle.get_key(),
|
||||
})
|
||||
}
|
||||
Some(x) => x,
|
||||
};
|
||||
let store_value = handle.get_value(&self.store).unwrap().into_boxed_slice();
|
||||
if store_value != model_value {
|
||||
return Err(StoreInvariant::DifferentValue {
|
||||
key: handle.get_key(),
|
||||
store: store_value,
|
||||
model: model_value,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(&key) = model_content.keys().next() {
|
||||
return Err(StoreInvariant::OnlyInModel { key });
|
||||
}
|
||||
let store_capacity = self.store.capacity().unwrap().remaining();
|
||||
let model_capacity = self.model.capacity().remaining();
|
||||
if store_capacity != model_capacity {
|
||||
return Err(StoreInvariant::DifferentCapacity {
|
||||
store: store_capacity,
|
||||
model: model_capacity,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks that the store is tracking lifetime correctly.
|
||||
fn check_storage(&self) -> Result<(), StoreInvariant> {
|
||||
let format = self.model.format();
|
||||
let storage = self.store.storage();
|
||||
let num_words = format.page_size() / format.word_size();
|
||||
let head = self.store.head().unwrap();
|
||||
let tail = self.store.tail().unwrap();
|
||||
for page in 0..format.num_pages() {
|
||||
// Check the erase cycle of the page.
|
||||
let store_erase = head.cycle(format) + (page < head.page(format)) as Nat;
|
||||
let model_erase = storage.get_page_erases(page as usize);
|
||||
if store_erase as usize != model_erase {
|
||||
return Err(StoreInvariant::DifferentErase {
|
||||
page: page as usize,
|
||||
store: store_erase as usize,
|
||||
model: model_erase,
|
||||
});
|
||||
}
|
||||
let page_pos = Position::new(format, store_erase, page, 0);
|
||||
|
||||
// Check the init word of the page.
|
||||
let mut store_write = (page_pos < tail) as usize;
|
||||
if page == 0 && tail == Position::new(format, 0, 0, 0) {
|
||||
// When the store is initialized and nothing written yet, the first page is still
|
||||
// initialized.
|
||||
store_write = 1;
|
||||
}
|
||||
let model_write = storage.get_word_writes((page * num_words) as usize);
|
||||
if store_write != model_write {
|
||||
return Err(StoreInvariant::DifferentWrite {
|
||||
page: page as usize,
|
||||
word: 0,
|
||||
store: store_write,
|
||||
model: model_write,
|
||||
});
|
||||
}
|
||||
|
||||
// Check the compact info of the page.
|
||||
let model_write = storage.get_word_writes((page * num_words + 1) as usize);
|
||||
let store_write = 0;
|
||||
if store_write != model_write {
|
||||
return Err(StoreInvariant::DifferentWrite {
|
||||
page: page as usize,
|
||||
word: 1,
|
||||
store: store_write,
|
||||
model: model_write,
|
||||
});
|
||||
}
|
||||
|
||||
// Check the content of the page. We only check cases where the model says a word was
|
||||
// written while the store doesn't think it should be the case. This is because the
|
||||
// model doesn't count writes to the same value. Also we only check whether a word is
|
||||
// written and not how many times. This is because this is hard to rebuild in the store.
|
||||
for word in 2..num_words {
|
||||
let store_write = (page_pos + (word - 2) < tail) as usize;
|
||||
let model_write =
|
||||
(storage.get_word_writes((page * num_words + word) as usize) > 0) as usize;
|
||||
if store_write < model_write {
|
||||
return Err(StoreInvariant::DifferentWrite {
|
||||
page: page as usize,
|
||||
word: word as usize,
|
||||
store: store_write,
|
||||
model: model_write,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> StoreInterruption<'a> {
|
||||
/// Builds an interruption that never triggers.
|
||||
pub fn none() -> StoreInterruption<'a> {
|
||||
StoreInterruption {
|
||||
delay: usize::max_value(),
|
||||
corrupt: Box::new(|_, _| {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the number of bits modified by an interrupted operation.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an interruption did not trigger.
|
||||
fn count_modified_bits(storage: &mut BufferStorage) -> usize {
|
||||
let mut modified_bits = 0;
|
||||
storage.corrupt_operation(Box::new(|before, after| {
|
||||
modified_bits = before
|
||||
.iter()
|
||||
.zip(after.iter())
|
||||
.map(|(x, y)| (x ^ y).count_ones() as usize)
|
||||
.sum();
|
||||
}));
|
||||
// We should never write the same slice or erase an erased page.
|
||||
assert!(modified_bits > 0);
|
||||
modified_bits
|
||||
}
|
||||
@@ -349,6 +349,8 @@
|
||||
extern crate alloc;
|
||||
|
||||
mod buffer;
|
||||
#[cfg(feature = "std")]
|
||||
mod driver;
|
||||
mod format;
|
||||
#[cfg(feature = "std")]
|
||||
mod model;
|
||||
@@ -357,6 +359,10 @@ mod store;
|
||||
|
||||
pub use self::buffer::{BufferCorruptFunction, BufferOptions, BufferStorage};
|
||||
#[cfg(feature = "std")]
|
||||
pub use self::driver::{
|
||||
StoreDriver, StoreDriverOff, StoreDriverOn, StoreInterruption, StoreInvariant,
|
||||
};
|
||||
#[cfg(feature = "std")]
|
||||
pub use self::model::{StoreModel, StoreOperation};
|
||||
pub use self::storage::{Storage, StorageError, StorageIndex, StorageResult};
|
||||
pub use self::store::{
|
||||
|
||||
@@ -18,9 +18,11 @@ use crate::format::{
|
||||
};
|
||||
#[cfg(feature = "std")]
|
||||
pub use crate::model::{StoreModel, StoreOperation};
|
||||
#[cfg(feature = "std")]
|
||||
pub use crate::BufferStorage;
|
||||
use crate::{usize_to_nat, Nat, Storage, StorageError, StorageIndex};
|
||||
#[cfg(feature = "std")]
|
||||
pub use crate::{
|
||||
BufferStorage, StoreDriver, StoreDriverOff, StoreDriverOn, StoreInterruption, StoreInvariant,
|
||||
};
|
||||
use alloc::vec::Vec;
|
||||
use core::cmp::{max, min, Ordering};
|
||||
#[cfg(feature = "std")]
|
||||
@@ -1050,7 +1052,7 @@ impl Store<BufferStorage> {
|
||||
}
|
||||
|
||||
/// Extracts the storage.
|
||||
pub fn into_storage(self) -> BufferStorage {
|
||||
pub fn extract_storage(self) -> BufferStorage {
|
||||
self.storage
|
||||
}
|
||||
|
||||
@@ -1233,3 +1235,207 @@ fn is_write_needed(source: &[u8], target: &[u8]) -> StoreResult<bool> {
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BufferOptions;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Config {
|
||||
word_size: usize,
|
||||
page_size: usize,
|
||||
num_pages: usize,
|
||||
max_word_writes: usize,
|
||||
max_page_erases: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn new_driver(&self) -> StoreDriverOff {
|
||||
let options = BufferOptions {
|
||||
word_size: self.word_size,
|
||||
page_size: self.page_size,
|
||||
max_word_writes: self.max_word_writes,
|
||||
max_page_erases: self.max_page_erases,
|
||||
strict_write: true,
|
||||
};
|
||||
StoreDriverOff::new(options, self.num_pages)
|
||||
}
|
||||
}
|
||||
|
||||
const MINIMAL: Config = Config {
|
||||
word_size: 4,
|
||||
page_size: 64,
|
||||
num_pages: 5,
|
||||
max_word_writes: 2,
|
||||
max_page_erases: 9,
|
||||
};
|
||||
|
||||
const NORDIC: Config = Config {
|
||||
word_size: 4,
|
||||
page_size: 0x1000,
|
||||
num_pages: 20,
|
||||
max_word_writes: 2,
|
||||
max_page_erases: 10000,
|
||||
};
|
||||
|
||||
const TITAN: Config = Config {
|
||||
word_size: 4,
|
||||
page_size: 0x800,
|
||||
num_pages: 10,
|
||||
max_word_writes: 2,
|
||||
max_page_erases: 10000,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn nordic_capacity() {
|
||||
let driver = NORDIC.new_driver().power_on().unwrap();
|
||||
assert_eq!(driver.model().capacity().total, 19123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titan_capacity() {
|
||||
let driver = TITAN.new_driver().power_on().unwrap();
|
||||
assert_eq!(driver.model().capacity().total, 4315);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_virt_page_size() {
|
||||
// Make sure a virtual page has 14 words. We use this property in the other tests below to
|
||||
// know whether entries are spanning, starting, and ending pages.
|
||||
assert_eq!(MINIMAL.new_driver().model().format().virt_page_size(), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_ok() {
|
||||
assert!(MINIMAL.new_driver().power_on().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_ok() {
|
||||
let mut driver = MINIMAL.new_driver().power_on().unwrap();
|
||||
// Empty entry.
|
||||
driver.insert(0, &[]).unwrap();
|
||||
driver.insert(1, &[]).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Last word is erased but last bit is not user data.
|
||||
driver.insert(0, &[0xff]).unwrap();
|
||||
driver.insert(1, &[0xff]).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Last word is erased and last bit is user data.
|
||||
driver.insert(0, &[0xff, 0xff, 0xff, 0xff]).unwrap();
|
||||
driver.insert(1, &[0xff, 0xff, 0xff, 0xff]).unwrap();
|
||||
driver.insert(2, &[0x5c; 6]).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Entry spans 2 pages.
|
||||
assert_eq!(driver.store().tail().unwrap().get(), 13);
|
||||
driver.insert(3, &[0x5c; 8]).unwrap();
|
||||
driver.check().unwrap();
|
||||
assert_eq!(driver.store().tail().unwrap().get(), 16);
|
||||
// Entry ends a page.
|
||||
driver.insert(2, &[0x93; (28 - 16 - 1) * 4]).unwrap();
|
||||
driver.check().unwrap();
|
||||
assert_eq!(driver.store().tail().unwrap().get(), 28);
|
||||
// Entry starts a page.
|
||||
driver.insert(3, &[0x81; 10]).unwrap();
|
||||
driver.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_ok() {
|
||||
let mut driver = MINIMAL.new_driver().power_on().unwrap();
|
||||
// Remove absent entry.
|
||||
driver.remove(0).unwrap();
|
||||
driver.remove(1).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Remove last inserted entry.
|
||||
driver.insert(0, &[0x5c; 6]).unwrap();
|
||||
driver.remove(0).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Remove empty entries.
|
||||
driver.insert(0, &[]).unwrap();
|
||||
driver.insert(1, &[]).unwrap();
|
||||
driver.remove(0).unwrap();
|
||||
driver.remove(1).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Remove entry with flipped bit.
|
||||
driver.insert(0, &[0xff]).unwrap();
|
||||
driver.insert(1, &[0xff; 4]).unwrap();
|
||||
driver.remove(0).unwrap();
|
||||
driver.remove(1).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Write some entries with one spanning 2 pages.
|
||||
driver.insert(2, &[0x93; 9]).unwrap();
|
||||
assert_eq!(driver.store().tail().unwrap().get(), 13);
|
||||
driver.insert(3, &[0x81; 10]).unwrap();
|
||||
assert_eq!(driver.store().tail().unwrap().get(), 17);
|
||||
driver.insert(4, &[0x76; 11]).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Remove the entry spanning 2 pages.
|
||||
driver.remove(3).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Write some entries with one ending a page and one starting the next.
|
||||
assert_eq!(driver.store().tail().unwrap().get(), 21);
|
||||
driver.insert(2, &[0xd7; (28 - 21 - 1) * 4]).unwrap();
|
||||
assert_eq!(driver.store().tail().unwrap().get(), 28);
|
||||
driver.insert(4, &[0xe2; 21]).unwrap();
|
||||
driver.check().unwrap();
|
||||
// Remove them.
|
||||
driver.remove(2).unwrap();
|
||||
driver.remove(4).unwrap();
|
||||
driver.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_ok() {
|
||||
let mut driver = MINIMAL.new_driver().power_on().unwrap();
|
||||
|
||||
// Don't compact if enough immediate capacity.
|
||||
assert_eq!(driver.store().immediate_capacity().unwrap(), 39);
|
||||
assert_eq!(driver.store().capacity().unwrap().remaining(), 34);
|
||||
assert_eq!(driver.store().head().unwrap().get(), 0);
|
||||
driver.store_mut().prepare(34).unwrap();
|
||||
assert_eq!(driver.store().head().unwrap().get(), 0);
|
||||
|
||||
// Fill the store.
|
||||
for key in 0..4 {
|
||||
driver.insert(key, &[0x38; 28]).unwrap();
|
||||
}
|
||||
driver.check().unwrap();
|
||||
assert_eq!(driver.store().immediate_capacity().unwrap(), 7);
|
||||
assert_eq!(driver.store().capacity().unwrap().remaining(), 2);
|
||||
// Removing entries increases available capacity but not immediate capacity.
|
||||
driver.remove(0).unwrap();
|
||||
driver.remove(2).unwrap();
|
||||
driver.check().unwrap();
|
||||
assert_eq!(driver.store().immediate_capacity().unwrap(), 7);
|
||||
assert_eq!(driver.store().capacity().unwrap().remaining(), 18);
|
||||
|
||||
// Prepare for next write (7 words data + 1 word overhead).
|
||||
assert_eq!(driver.store().head().unwrap().get(), 0);
|
||||
driver.store_mut().prepare(8).unwrap();
|
||||
driver.check().unwrap();
|
||||
assert_eq!(driver.store().head().unwrap().get(), 16);
|
||||
// The available capacity did not change, but the immediate capacity is above 8.
|
||||
assert_eq!(driver.store().immediate_capacity().unwrap(), 14);
|
||||
assert_eq!(driver.store().capacity().unwrap().remaining(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reboot_ok() {
|
||||
let mut driver = MINIMAL.new_driver().power_on().unwrap();
|
||||
|
||||
// Do some operations and reboot.
|
||||
driver.insert(0, &[0x38; 24]).unwrap();
|
||||
driver.insert(1, &[0x5c; 13]).unwrap();
|
||||
driver = driver.power_off().power_on().unwrap();
|
||||
driver.check().unwrap();
|
||||
|
||||
// Do more operations and reboot.
|
||||
driver.insert(2, &[0x93; 1]).unwrap();
|
||||
driver.remove(0).unwrap();
|
||||
driver.insert(3, &[0xde; 9]).unwrap();
|
||||
driver = driver.power_off().power_on().unwrap();
|
||||
driver.check().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ cargo fuzz build
|
||||
cd libraries/cbor
|
||||
cargo fuzz build
|
||||
cd ../..
|
||||
cd libraries/persistent_store
|
||||
cargo fuzz build
|
||||
cd ../..
|
||||
|
||||
echo "Checking that CTAP2 builds and links properly (1 set of features)..."
|
||||
cargo build --release --target=thumbv7em-none-eabi --features with_ctap1
|
||||
|
||||
Reference in New Issue
Block a user