Std :: ptr :: write transfere o “não inicializado-ness” dos bytes que escreve?

8

Estou trabalhando em uma biblioteca que ajuda a transacionar tipos que se encaixam em um tamanho de ponteiro int sobre os limites da FFI. Suponha que eu tenha uma estrutura 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>());

Essa estrutura possui 1 byte de dados e 7 bytes de preenchimento. Quero compactar uma instância dessa estrutura em um usizee descompactá-lo do outro lado de um limite de FFI. Como esta biblioteca é genérica, estou usando MaybeUninite 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) };

Essa assume_initchamada desencadeou um comportamento indefinido? Em outras palavras, quando ptr::writecopia a estrutura no buffer, ele copia a não inicialização dos bytes de preenchimento, substituindo o estado inicializado como zero bytes?

Atualmente, quando esse código ou semelhante é executado no Miri, ele não detecta nenhum comportamento indefinido. No entanto, de acordo com a discussão sobre esse problema no github , ptr::writesupostamente é permitido copiar esses bytes de preenchimento e, além disso, copiar seu estado não inicializado. Isso é verdade? Os documentos para ptr::writenão falam sobre isso, nem a seção nomicon sobre memória não inicializada .

Lucretiel
fonte
Algumas otimizações úteis podem ser facilitadas fazendo com que uma cópia de um valor indeterminado deixe o destino em um estado indeterminado, mas há outros momentos em que é necessário poder copiar um objeto com a semântica em que qualquer parte do original que seja indeterminada se torna não especificado na cópia (portanto, qualquer cópia futura teria a garantia de corresponder uma à outra). Infelizmente, os designers de linguagem não parecem dar muita atenção à importância de conseguir atingir a última semântica em códigos sensíveis à segurança.
supercat 9/04

Respostas:

3

Essa chamada assume_init acionou um comportamento indefinido?

Sim. "Não inicializado" é apenas outro valor que um byte na Rust Abstract Machine pode ter, próximo ao usual 0x00 - 0xFF. Vamos escrever este byte especial como 0xUU. (Consulte esta postagem do blog para obter mais informações sobre esse assunto .) 0xUU é preservado por cópias, assim como qualquer outro valor possível que um byte possa ter é preservado por cópias.

Mas os detalhes são um pouco mais complicados. Existem duas maneiras de copiar dados na memória no Rust. Infelizmente, os detalhes para isso também não são explicitamente especificados pela equipe de idiomas da Rust; portanto, a seguir, é minha interpretação pessoal. Eu acho que o que estou dizendo é incontroverso, a menos que seja indicado de outra forma, mas é claro que isso poderia ser uma impressão errada.

Cópia sem tipo / byte

Em geral, quando um intervalo de bytes está sendo copiado, o intervalo de origem apenas substitui o intervalo de destino - portanto, se o intervalo de origem era "0x00 0xUU 0xUU 0xUU", depois da cópia, o intervalo de destino terá a lista exata de bytes.

É assim que se comporta memcpy/ memmoveem C (na minha interpretação do padrão, que não está muito claro aqui, infelizmente). No Rust, ptr::copy{,_nonoverlapping} provavelmente executa uma cópia em bytes, mas na verdade não está especificada com precisão no momento e algumas pessoas podem querer dizer que foi digitada também. Isso foi discutido um pouco nesta edição .

Cópia digitada

A alternativa é uma "cópia digitada", que é o que acontece em todas as atribuições normais ( =) e ao passar valores para / de uma função. Uma cópia digitada interpreta a memória de origem em algum tipo Te, em seguida, "re-serializa" esse valor do tipo Tna memória de destino.

A principal diferença para uma cópia em bytes é que as informações que não são relevantes para o tipo Tsão perdidas. Essa é basicamente uma maneira complicada de dizer que uma cópia digitada "esquece" o preenchimento e a redefine efetivamente para não inicializada. Comparado a uma cópia não digitada, uma cópia digitada perde mais informações. Cópias sem tipo preservam a representação subjacente, cópias digitadas apenas preservam o valor representado.

Portanto, mesmo quando você transmuta 0usizepara PaddingDemo, uma cópia digitada desse valor pode redefinir para "0x00 0xUU 0xUU 0xUU" (ou quaisquer outros bytes possíveis para o preenchimento) - supondo- datase que esteja no deslocamento 0, que não é garantido (adicione #[repr(C)]se desejar essa garantia).

No seu caso, ptr::writeaceita um argumento do tipo PaddingDemoe o argumento é passado por uma cópia digitada. Portanto, já nesse ponto, os bytes de preenchimento podem mudar arbitrariamente, em particular eles podem se tornar 0xUU.

Não inicializado usize

Se o seu código possui UB depende de outro fator, a saber, se um byte não inicializado em a usizeé UB. A questão é: um intervalo de memória (parcialmente) não inicializado representa algum número inteiro? Atualmente, não existe e, portanto, existe UB . No entanto, se esse deve ser o caso é muito debatido e parece provável que eventualmente o permitiremos.

Muitos outros detalhes ainda não são claras, embora - por exemplo, transmutando "0x00 0xUU 0xUU 0xUU" para um inteiro pode resultar em um totalmente inteiro não inicializado, ou seja, inteiros pode não ser capaz de preservar a "inicialização parcial". Para preservar bytes parcialmente inicializados em números inteiros, teríamos que dizer basicamente que um número inteiro não tem um "valor" abstrato, é apenas uma sequência de bytes (possivelmente não inicializados). Isso não reflete como os inteiros são usados ​​em operações como /. (Parte disso também depende das decisões do LLVM poisonefreeze ; o LLVM pode decidir que, ao fazer uma carga no tipo inteiro, o resultado será totalmente poisonse houver algum byte de entrada.poison.) Portanto, mesmo que o código não seja UB porque permitimos números inteiros não inicializados, ele pode não se comportar conforme o esperado, porque os dados que você deseja transferir estão sendo perdidos.

Se você deseja transferir bytes não processados, sugiro usar um tipo adequado para isso, como MaybeUninit. Se você usar um tipo inteiro, o objetivo deve ser transferir valores inteiros - ou seja, números.

Ralf Jung
fonte
Tudo isso é muito útil, obrigado!
Lucretiel 12/04
Portanto, hipoteticamente, se o comportamento descrito em seu último parágrafo for formalizado (não é o caso no momento), um usize poderá ter bytes UU desde que nenhuma operação seja executada nele e depois transmutado de volta ao meu tipo original, o que funcionaria porque não importa se os bytes de preenchimento são UU.
Lucretiel
Obrigado pela resposta detalhada! Seria possível para Miri detectar esse tipo de comportamento indefinido?
Sven Marnach 12/04
1
@ Lucretiel se decidirmos que usizerepresenta pacotes de bytes (e não números inteiros), então sim, usizee MaybeUninit<usize>seria equivalente e ambos preservariam perfeitamente a representação no nível de bytes subjacente (e isso inclui "bytes indefinidos").
Ralf Jung
1
@SvenMarnach Porque a implementação atual de ptr::writeé inteligente o suficiente para não copiar os bytes não inicializados restantes.
Lucretiel 15/04