¿Std :: ptr :: write transfiere la "falta de inicialización" de los bytes que escribe?

8

Estoy trabajando en una biblioteca que ayuda a realizar transacciones de tipos que se ajustan a un tamaño de puntero int sobre los límites de FFI. Supongamos que tengo una estructura como esta:

use std::mem::{size_of, align_of};

struct PaddingDemo {
    data: u8,
    force_pad: [usize; 0]
}

assert_eq!(size_of::<PaddingDemo>(), size_of::<usize>());
assert_eq!(align_of::<PaddingDemo>(), align_of::<usize>());

Esta estructura tiene 1 byte de datos y 7 bytes de relleno. Quiero empaquetar una instancia de esta estructura en ay usizeluego descomprimirla en el otro lado de un límite de FFI. Debido a que esta biblioteca es genérica, estoy usando MaybeUninity ptr::write:

use std::ptr;
use std::mem::MaybeUninit;

let data = PaddingDemo { data: 12, force_pad: [] };

// In order to ensure all the bytes are initialized,
// zero-initialize the buffer
let mut packed: MaybeUninit<usize> = MaybeUninit::zeroed();
let ptr = packed.as_mut_ptr() as *mut PaddingDemo;

let packed_int = unsafe {
    std::ptr::write(ptr, data);
    packed.assume_init()
};

// Attempt to trigger UB in Miri by reading the
// possibly uninitialized bytes
let copied = unsafe { ptr::read(&packed_int) };

¿ assume_initEsa llamada desencadenó un comportamiento indefinido? En otras palabras, cuando ptr::writecopia la estructura en el búfer, ¿copia el no inicializado de los bytes de relleno, sobrescribiendo el estado inicializado como cero bytes?

Actualmente, cuando este o un código similar se ejecuta en Miri, no detecta ningún comportamiento indefinido. Sin embargo, según la discusión sobre este tema en github , ptr::writesupuestamente se permite copiar esos bytes de relleno y, además, copiar su no inicializado. ¿Es eso cierto? Los documentos para ptr::writeno hablan sobre esto en absoluto, ni la sección de nomicon sobre memoria no inicializada .

Lucretiel
fuente
Algunas optimizaciones útiles pueden facilitarse haciendo que una copia de un valor indeterminado deje el destino en un estado indeterminado, pero hay otros momentos en los que es necesario poder copiar un objeto con la semántica en que cualquier parte del original que se haya indeterminado se convierta sin especificar en la copia (por lo que se garantizará que cualquier copia futura coincida entre sí). Desafortunadamente, los diseñadores de lenguaje no parecen considerar mucho la importancia de poder lograr la última semántica en el código sensible a la seguridad.
supercat

Respuestas:

3

¿Esa llamada asumir_inicio desencadenó un comportamiento indefinido?

Si. "Sin inicializar" es solo otro valor que puede tener un byte en Rust Abstract Machine, junto al habitual 0x00 - 0xFF. Escribamos este byte especial como 0xUU. (Consulte esta publicación de blog para obtener un poco más de información sobre este tema ). 0xUU se conserva mediante copias al igual que cualquier otro valor posible que un byte pueda tener se conserva mediante copias.

Pero los detalles son un poco más complicados. Hay dos formas de copiar datos en la memoria en Rust. Desafortunadamente, los detalles para esto tampoco están especificados explícitamente por el equipo de lenguaje Rust, por lo que lo que sigue es mi interpretación personal. Creo que lo que digo no es controvertido a menos que se indique lo contrario, pero, por supuesto, podría ser una impresión equivocada.

Copia sin tipo / byte-wise

En general, cuando se copia un rango de bytes, el rango de origen simplemente sobrescribe el rango de destino, por lo que si el rango de origen era "0x00 0xUU 0xUU 0xUU", entonces, después de la copia, el rango de destino tendrá esa lista exacta de bytes.

Así es como se comporta memcpy/ memmoveen C (en mi interpretación del estándar, que desafortunadamente no está muy claro aquí). En Rust, ptr::copy{,_nonoverlapping} probablemente realiza una copia en bytes, pero en realidad no se especifica con precisión en este momento y algunas personas pueden querer decir que también está escrita. Esto se discutió un poco en este tema .

Copia mecanografiada

La alternativa es una "copia escrita", que es lo que sucede en cada asignación normal ( =) y al pasar valores a / desde una función. Una copia mecanografiada interpreta la memoria de origen en algún tipo Ty luego "vuelve a serializar" ese valor de tipo Ten la memoria de destino.

La diferencia clave para una copia en bytes es que Tse pierde información que no es relevante en el tipo . Básicamente, esta es una forma complicada de decir que una copia escrita "olvida" el relleno y, efectivamente, la restablece como no inicializada. En comparación con una copia sin tipo, una copia con tipo pierde más información. Las copias sin tipo conservan la representación subyacente, las copias con tipo solo conservan el valor representado.

Entonces, incluso cuando transmuta 0usizea PaddingDemo, una copia escrita de ese valor puede restablecer esto a "0x00 0xUU 0xUU 0xUU" (o cualquier otro byte posible para el relleno) - suponiendo que se dataencuentre en el desplazamiento 0, lo que no está garantizado (agregue #[repr(C)]si lo desea esa garantía)

En su caso, ptr::writetoma un argumento de tipo PaddingDemo, y el argumento se pasa a través de una copia escrita. Entonces, en ese punto, los bytes de relleno pueden cambiar arbitrariamente, en particular pueden convertirse en 0xUU.

Sin inicializar usize

Si su código tiene UB, entonces depende de otro factor, es decir, si tener un byte no inicializado en a usizees UB. La pregunta es, ¿un rango de memoria (parcialmente) no inicializado representa algún número entero? Actualmente, no lo hace y, por lo tanto, hay UB . Sin embargo, si ese debería ser el caso, se debate mucho y parece probable que eventualmente lo permitamos.

Muchos otros detalles aún no están claros, sin embargo - por ejemplo, la transmutación de "0x00 0xUU 0xUU 0xUU" a un entero bien puede resultar en una completamente número entero no inicializado, es decir, números enteros pueden no ser capaces de preservar "inicialización parcial". Para preservar bytes parcialmente inicializados en números enteros, tendríamos que decir básicamente que un número entero no tiene un "valor" abstracto, es solo una secuencia de bytes (posiblemente no inicializados). Esto no refleja cómo se usan los enteros en operaciones como /. (Algo de esto también depende de las decisiones de LLVM poisonyfreeze ; LLVM podría decidir que al realizar una carga en el tipo entero, el resultado es completo poisonsi algún byte de entrada espoison.) Entonces, incluso si el código no es UB porque permitimos enteros no inicializados, puede que no se comporte como se esperaba porque se están perdiendo los datos que desea transferir.

Si desea transferir bytes sin procesar, sugiero usar un tipo adecuado para eso, como MaybeUninit. Si usa un tipo entero, el objetivo debe ser transferir valores enteros, es decir, números.

Ralf Jung
fuente
Todo esto es muy útil, ¡gracias!
Lucretiel
Entonces, hipotéticamente, si el comportamiento descrito en su último párrafo se formaliza (no es el caso en este momento), se podría permitir que un usize tenga bytes UU siempre que no se realicen operaciones en él, y luego se transmute de nuevo a mi tipo original, lo que funcionaría porque no importa si los bytes de relleno son UU.
Lucretiel
¡Gracias por la respuesta detallada! ¿Sería posible para Miri detectar este tipo de comportamiento indefinido?
Sven Marnach
1
@Lucretiel si decidimos que usizerepresenta bolsas de bytes (y no enteros), entonces sí, usizey MaybeUninit<usize>sería equivalente y ambos preservarían perfectamente la representación de nivel de bytes subyacente (y esto incluye "bytes indefinidos").
Ralf Jung
1
@SvenMarnach Debido a que la implementación actual de ptr::writees lo suficientemente inteligente como para no copiar los bytes de cola no inicializados.
Lucretiel