3
\$\begingroup\$

First of all, I am here to explore unsafe features of Rust, if you are the kind of guy who likes "safe" Rust and will start nagging about using safe features of Rust I will still happily read your comment and learn about the safe alternatives.

That said, I am looking to enhance my knowledge about unsafe Rust. The documentation is very scarce and not very well documented. Little by little, thanks to help from Stack Overflow users (especially @kmdreko) and my perseverance I was able to make it work. My program is able to send large files (4Gb) over UDP.

As I always look to improve the quality of my code I wonder if someone could take a look at my source code and let me know how I could improve it.


server.rs

const UDP_HEADER: usize = 8;
const IP_HEADER: usize = 20;
const AG_HEADER: usize = 4;
const MAX_CHUNK_SIZE: usize = (64 * 1024 - 1) - UDP_HEADER - IP_HEADER - AG_HEADER;

use std::net::UdpSocket;
use std::io;
use std::fs::File;
use std::io::prelude::*;
use std::alloc::{alloc, dealloc, Layout};
use std::mem;
use std::{mem::MaybeUninit};

// cmp -l 1.jpg 2.jpg

#[inline(always)]
fn memcpy(dst_ptr:*mut u8, src_ptr:*const u8, len:usize) {
    unsafe {
        std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
    }
}

#[inline(always)]
fn next_power_of_two(n:u32) -> u32 {
    return 32 - (n - 1).leading_zeros();
}

#[inline(always)]
fn write_chunks_to_file(filename: &str, bytes:&[u8]) -> Result<bool, io::Error> {
    let mut file = File::create(filename)?;
    file.write_all(bytes)?;
    Ok(true)
}

use std::thread;
fn main() {
    let socket = UdpSocket::bind("0.0.0.0:8888").expect("Could not bind socket");
    let filename = "2.jpg";
    let mut count = 0;
    let mut chunks_cnt:u32 = 0xffff;
    let mut total_size:usize = 0;
    let mut layout;
    unsafe { layout = MaybeUninit::<Layout>::uninit().assume_init(); };

    let mut bytes_buf;
    unsafe { bytes_buf = MaybeUninit::<*mut u8>::uninit().assume_init(); };

    loop {
        let mut buf = [0u8; MAX_CHUNK_SIZE + AG_HEADER];
        let sock = socket.try_clone().expect("Failed to clone socket");
        match socket.recv_from(&mut buf) {
            Ok((size, src)) => { // thanks https://doc.rust-lang.org/beta/std/net/struct.UdpSocket.html#method.recv_from
                total_size += size;
                let packet_index:usize = (buf[0] as usize) << 8 | buf[1] as usize;
                if count == 0 {
                    chunks_cnt = (buf[2] as u32) << 8 | buf[3] as u32;
                    let n:usize = 0x10000 << next_power_of_two(chunks_cnt);
                   // assert_eq!(n.count_ones(), 1); // can check with this function that n is aligned on power of 2
                    unsafe {
                         layout = Layout::from_size_align_unchecked(n, mem::align_of::<u8>());
                         bytes_buf = alloc(layout);
                    }
                }
                unsafe {
                    let dst_ptr = bytes_buf.offset((packet_index*MAX_CHUNK_SIZE) as isize);
                    memcpy(dst_ptr, &buf[AG_HEADER], size-AG_HEADER);
                };
                thread::spawn(move || {
                    //let s = String::from_utf8_lossy(&buf);
                    println!("receiving packet {} from: {}", packet_index, src);
                    sock.send_to(&buf, &src).expect("Failed to send a response");
                });
                println!("count: {}", count);
                count+=1;
            }
            Err(e) => {
                eprintln!("couldn't recieve a datagram: {}", e);
            }
        }
         if count == chunks_cnt { // all chunks have been collected, write bytes to file
            let bytes = unsafe { std::slice::from_raw_parts(bytes_buf, total_size) };
            let result = write_chunks_to_file(filename, &bytes);
            match result {
                Ok(true) => println!("Succesfully created file: {}", true),
                Ok(false) => println!("Could not create file: {}", false),
                Err(e) => println!("Error: {}", e),
            }
            count = 0;
            total_size = 0;
            unsafe { dealloc(bytes_buf, layout); }
        }
    }
}

client.rs

const UDP_HEADER: usize = 8;
const IP_HEADER: usize = 20;
const AG_HEADER: usize = 4;
const MAX_DATA_LENGTH: usize = (64 * 1024 - 1) - UDP_HEADER - IP_HEADER;

use std::io::Read;
use std::net::UdpSocket;
use std::io;

pub fn get_chunks_from_file(mut filename: String,total_size: &mut usize) -> Result<Vec<Vec<u8>>, io::Error> {
    filename.pop(); // get read of the trailing '\n' in user input.
    let mut file = std::fs::File::open(filename)?;
    let mut list_of_chunks = Vec::new();
    let chunk_size = MAX_DATA_LENGTH - AG_HEADER;

    loop {
        let mut chunk = Vec::with_capacity(chunk_size);
        let n = file
            .by_ref()
            .take(chunk_size as u64)
            .read_to_end(&mut chunk)?;
        *total_size += n;
        if n == 0 {
            break;
        }
        list_of_chunks.push(chunk);
        if n < chunk_size {
            break;
        }
    }
    Ok(list_of_chunks)
}

fn main() {
    let socket = UdpSocket::bind("127.0.0.1:8000").expect("Could not bind client socket");
    let mut buffer = [0u8; MAX_DATA_LENGTH];

    socket.connect("127.0.0.1:8888").expect("Could not connect to server");
    loop {
        let mut input = String::new();

        io::stdin()
            .read_line(&mut input)
            .expect("Failed to read from stdin");
        println!("{}", input);
        let mut total_size: usize = 0;
        let result: Result<Vec<Vec<u8>>, io::Error> = get_chunks_from_file(input, &mut total_size); // : Result<u8:u8>
        match result {
            Ok(chunks) => {
                let nb: u16 = chunks.len() as u16;
                let mut index: u16 = 0;
                let header: &mut[u8;4] = &mut[
                    (index >> 8) as u8,
                    (index & 0xff) as u8,
                    (nb >> 8) as u8,
                    (nb & 0xff) as u8,

                ]; //input.as_bytes();

                for chunk in chunks.iter() {
                    header[1] = (index & 0xff) as u8;
                    header[0] = (index >> 8) as u8;
                    let data:Vec<u8> = [header.as_ref(), chunk].concat();
                    //println!("FILE {} BYTES\n {:?}", index, chunk);
                    println!(
                        "size: {} FILE {:?} of {} BYTES\n {:?}",
                        total_size,
                        (header[0] as u16) << 8 | header[1] as u16,
                        nb - 1,
                        [0]
                    );
                    println!("{}", index);
                    socket.send(&data).expect("Failed to write to server");
                    socket.recv_from(&mut buffer).expect("Could not read into buffer");
                    index += 1;
                }
            }
            Err(e) => println!("Error: {}", e),
        }
        //print!( "{}",str::from_utf8(&buffer).expect("Could not write buffer as string"));
      //  println!( "Chunk received by server {:?}", &buffer);
    }
}

The ones I am aware of

There are a few things I am especially concerned and already know that could be improved like this function:

fn write_chunks_to_file(filename: &str, bytes:&[u8]) -> Result<bool, io::Error> {
    let mut file = File::create(filename)?;
    file.write_all(bytes)?;
    Ok(true)
}

I wrote it this way in order to make the compilation works but even I, the creator, can acknowledge that it does not make any sense! What would be a good Result to return?

And also the following warning when I compile the server file:

warning: the type `std::alloc::Layout` does not permit being left uninitialized
  --> udp-server.rs:43:23
   |
43 |     unsafe { layout = MaybeUninit::<Layout>::uninit().assume_init(); };
   |                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |                       |
   |                       this code causes undefined behavior when executed
   |                       help: use `MaybeUninit<T>` instead, and only call `assume_init` after initialization is done
   |
   = note: `#[warn(invalid_value)]` on by default
note: `std::num::NonZeroUsize` must be non-null (in this struct field)

Kindly appreciating any peer review.

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Welcome to Code Review! Could you please update the title to accurately reflect what the code does, since lots of people are presumably playing with unsafe code it currently doesn't help distinguishing it from other posts. \$\endgroup\$
    – ferada
    Commented Mar 17, 2021 at 14:51

1 Answer 1

3
\$\begingroup\$

Ok, let's talk about unsafe

Your use of unsafe is very unidiomatic, not because you're using it at all, but because you're not using it correctly; that is, to create a boundary between safe and unsafe code.

First, some definitions

Soundness is the property code has when its behavior is defined according to the abstract machine. Basically, sound code is code that means something, whereas unsound code doesn't mean anything, and may therefore do anything.

Safety is the property code has when its soundness can be guaranteed according to the rules of the language.

Safety is subordinate to soundness. All code you write ought to be sound, whether it is unsafe or not. In a language like C, all code is unsafe, and it falls to the programmer to guarantee its soundness. In most languages considered safe, unsoundness is prevented by various runtime mechanisms (such as garbage collection to prevent dangling pointers). The tricky thing for a language like Rust is to allow unsafe code without letting the unsafety "leak out" into safe code. This is where unsafe comes in. In Rust, unsafe as a keyword can be used in a couple of different ways, but it is all about creating a boundary between safe and unsafe code.

A pointless (and dangerous) use of unsafe

With the preliminaries out of the way, we can discuss what's wrong with a function definition like this one:

fn memcpy(dst_ptr:*mut u8, src_ptr:*const u8, len:usize) {
    unsafe {
        std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
    }
}

This function does not maintain a boundary between unsafe and safe code. In fact, it pokes a hole right through that boundary because memcpy can be called in safe code, but will still create unsoundness if called with the wrong arguments. copy_nonoverlapping is marked unsafe because it imposes certain requirements on its caller that cannot be checked by the compiler. You can read these requirements in its documentation:

Behavior is undefined if any of the following conditions are violated:

  • src must be valid for reads of count * size_of::<T>() bytes.

  • dst must be valid for writes of count * size_of::<T>() bytes.

  • Both src and dst must be properly aligned.

  • The region of memory beginning at src with a size of count * size_of::<T>() bytes must not overlap with the region of memory beginning at dst with the same size.

Like read, copy_nonoverlapping creates a bitwise copy of T, regardless of whether T is Copy. If T is not Copy, using both the values in the region beginning at *src and the region beginning at *dst can violate memory safety.

Because memcpy doesn't guarantee any of these requirements, but passes the responsibility for doing so on to its caller, memcpy is unsafe, despite not being an unsafe fn. Using an unsafe block inside the function is not merely pointless (because memcpy does not guarantee any of the soundness requirements of copy_nonoverlapping) but actually dangerous (because memcpy itself is not marked unsafe, so it can be called from non-unsafe contexts without the compiler's gentle reminder to check the list of requirements above).

We can fix this by (1) removing the unsafe block inside the function, (2) marking memcpy appropriately, and (3) adding a comment to the function to indicate what soundness requirements it has.

/// A wrapper for ptr::copy_nonoverlapping with different argument order.
/// Safety: see `std::ptr::copy_nonoverlapping`.
unsafe fn memcpy(dst_ptr: *mut u8, src_ptr: *const u8, len: usize) {
    std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, len);
}

(Aside: This function doesn't really carry its weight. Just call ptr::copy_nonoverlapping and get used to the unusual argument order. But I'm focusing on unsafe for the moment.)

An unsound use of unsafe

Your code has one clear-cut example of unsoundness. Recall that unsoundness is worse than mere unsafety. Driving on a bridge with no guardrails is unsafe; unsound is when your car ends up in the river.

    unsafe { layout = MaybeUninit::<Layout>::uninit().assume_init(); };

This car is in a river. There are certain bit patterns that are not permitted for Layout, and constructing a Layout that contains them leads to instant UB (undefined behavior). In C we might call these "trap representations". Calling assume_init on a value that is not initialized permits the compiler to assume one of these invalid values, and therefore "optimize" this code to do anything, or merely delete it. The warning message (which should really be a hard error, but I think it is a warning for backward compatibility reasons) hints at how to fix it:

   |                       help: use `MaybeUninit<T>` instead, and only call `assume_init` after initialization is done
                                                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

To fix this, we change the type of layout to MaybeUninit<Layout> (note that uninit does not require unsafe):

    let mut layout = MaybeUninit::<Layout>::uninit();

And only call assume_init() later, when you know you have initialized it and want to use the value:

                    unsafe {
                        layout.as_mut_ptr().write(Layout::from_size_align_unchecked(n, mem::align_of::<u8>()));
                        bytes_buf = alloc(layout.assume_init());
                    }

This is a bit clunky today, unfortunately. With the unstable feature maybe_uninit_extra, you could use write to make it more concise and less unsafe. (The other place where you use layout, later, would still need to use assume_init().)

A sketchy use of unsafe

The compiler warns about layout, but it doesn't warn about the same thing with bytes_buf:

    unsafe { bytes_buf = MaybeUninit::<*mut u8>::uninit().assume_init(); };

Unfortunately, just because the compiler doesn't warn about it doesn't mean it's sound. I'm not sure what the exact semantics are here; in fact, Rust's exact semantics haven't been completely nailed down, so it might be that nobody knows whether this is sound or not. The deprecation notice for mem::uninitialized (which does exactly the same thing as the line above) seems to hint that putting an uninitialized value in a typed variable may be insta-UB even for integers. So it's probably best to avoid that here as well.

You could do the same thing as with layout, and call assume_init() only after actually initializing the pointer, but don't do that. Just initialize bytes_buf to std::ptr::null_mut() and call it a day.

Other unsafe issues

Let's go back to this unsafe block:

                    unsafe {
                        layout.as_mut_ptr().write(Layout::from_size_align_unchecked(n, mem::align_of::<u8>()));
                        bytes_buf = alloc(layout.assume_init());
                    }

First things first: let's document all the safety requirements and how we're upholding them. There are four unsafe fns in this block (write, from_size_align_unchecked, alloc, and assume_init).

  • <*mut T>::write refers to ptr::write, which just has two preconditions:

    Behavior is undefined if any of the following conditions are violated:

    • dst must be valid for writes.

    • dst must be properly aligned. Use write_unaligned if this is not the case.

    MaybeUninit::as_mut_ptr does guarantee these, since that's more or less the whole point of it (MaybeUninit::write, on nightly, is safe.) The documentation doesn't explicitly talk about validity and alignment of the returned pointer; however, that's clearly the intent and the example correct usage also shows basically the same thing. So let's add a comment to that effect:

    // SAFETY: layout.as_mut_ptr() is valid for writing and properly aligned
    

    (Note that assigning directly to *layout.as_mut_ptr() is potentially risky because it causes the previous value to be dropped. Although Layout doesn't have drop glue, using write instead relieves us from having to worry about that additional precondition.)

  • Layout::from_size_align_unchecked requires that the caller manually uphold the following conditions checked by from_size_align:

    • align must not be zero,

    • align must be a power of two,

    • size, when rounded up to the nearest multiple of align, must not overflow (i.e., the rounded value must be less than or equal to usize::MAX).

    The first two are trivial because mem::align_of::<u8>() is an alignment by definition. The last one is also true because n is created by shifting 0x10000 left, which can't round up to usize::MAX (let's agree to ignore the possibility of u8 having a massive alignment requirement on some obtuse, exotic target.)

    // SAFETY: align_of<u8>() is nonzero and a power of two because it is an alignment
    // SAFETY: no shift amount will make 0x10000 << x round up to usize::MAX
    
  • alloc(layout.assume_init()) has undefined behavior when n is zero, which can happen any time 0x10000 << next_power_of_two(chunks_cnt) overflows. This can't happen on platforms where usize is 64 bits, but it can happen on 32-bit platforms, easily if cnt_chunks is zero (which will happen any time you try to handle an IPv6 jumbogram).

    It's not obvious how you should deal with this, since it relates to how you handle incorrect or unexpected data. However, I suggest trying a little harder to make n guaranteed to be greater than 0. Using checked_ methods on integers instead of plain operators like << might help you deal with overflowing arithmetic.

    // SAFETY: layout's size is n, which is guaranteed >0 because...?
    
  • Finally, layout.assume_init() is sound, as we just discussed, because layout was just initialized in the previous line. This is another unsafe fn that MaybeUninit::write will remove the need for.

    // SAFETY: layout is initialized right before calling assume_init()
    

Phew. It's a lot of work to check all this stuff, right? And I didn't even get to the other two unsafe blocks. At least this one doesn't appear to have any data races in it.

It might seem like a lot of these conditions are trivially true, and ideally that will usually be the case, but as we saw in this example, there can be a lot of nuance to manually checking that you are using an unsafe fn correctly. The value of unsafe is that if you can check the rules locally (in or in the immediate vicinity of the unsafe block), then you can wrap it in a regular, safe function and not have to go on checking those rules globally (every time you call it).

But why are you actually using unsafe?

Checking and manually commenting all the unsafe in a project is a chore, which is one good reason to minimize your use of unsafe to only the cases where it's really needed. For instance: do you really need to avoid the checks done by Layout::from_size_align? Is calling Layout::array::<u8>(n).unwrap() really going to hurt your performance so much? (Note that in this case, if the unsafe code was sound, the unwrap() will never get called – so don't be concerned about making your server unstable; if adding the unwrap() makes it crash, it was already unstable.)

None of this review is meant to convince you not to use unsafe. If you really do need that maybe-two-cycles of performance that you might save from skipping the checks, you should use unsafe pretty much like this. But don't waste your time eliminating a couple of checks that might not even be in the compiled code. Even and especially when your goal is performance, you should start with reliable, predictable, safe code, profile it, and then start attacking your bottlenecks.

This is a really long review already, so I'm not going to try to rewrite your server in only safe code. I've already seen (and made) comments on some of your SO questions about how to do that, and like I said, my object is not to convince you not to use unsafe at all. Just use it correctly: maintain a boundary between safe and unsafe code, document the soundness requirements of unsafe code and show how you satisfy them.

That said, here's some other non-safety-related thoughts (I might come back and add to this):

Miscellaneous

  • Regarding write_chunks_to_file, you can simply return a Result<(), io::Error> (or equivalently io::Result<()>), since the Ok variant conveys no additional information (() is a unit type, which conveys no information and takes up no space). This is also what write_all returns.

  • next_power_of_two confused me for a while because it's named the same as a standard library function, but does something different. Consider renaming it, or just rewriting your logic to use the standard function.

  • This might not do what you think:

                thread::spawn(move || {
                    println!("receiving packet {} from: {}", packet_index, src);
                    sock.send_to(&buf, &src).expect("Failed to send a response");
                });
    

    move || causes buf and src to be copied into the closure, even though they're only used by reference. This does make the whole thing work, so maybe that's what you wanted, but it does make a big copy when it seems you're trying to avoid that.

  • The 0x10000 magic number should probably be a constant, maybe MAX_DATAGRAM_SIZE?

  • It's a good idea to use rustfmt. Yeah, you might not like all the reformattings it does. But it's better than uneven indentation and less annoying to people reading your code (including you in 6 months).

  • It's a better idea to use cargo. I made a Cargo project by running cargo init projectname and moving both files into projectname/src/bin (I had to create the bin directory). Putting things in bin makes them standalone binaries which Cargo will build along with everything else when you run cargo build.

\$\endgroup\$
1
  • \$\begingroup\$ What a quality (and sound!) answer!! I have never seen such an excellent answer before. Clear, well explained, and show the code. Really thankful!! PS: For memcpy I had already changed it before but appreciate the idea of the wrapper. ;) \$\endgroup\$ Commented Mar 17, 2021 at 1:00

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.