Repository navigation
Draft on recommendations and conventions for unsafe block scope #362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
e5efcb6
ceec49e
dedb328
71db873
c78f844
8387678
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,69 @@ | ||||||||||||||
|
|
||||||||||||||
| # Unsafe coding conventions | ||||||||||||||
|
|
||||||||||||||
| Outside of the language destails, there's a question of recommendations and conventions in how to use the feature, particularly the scope of unsafe blocks. | ||||||||||||||
|
|
||||||||||||||
| We can split the annotations into two categories: | ||||||||||||||
| * Unsafe member annotations | ||||||||||||||
| * Unsafe blocks | ||||||||||||||
|
|
||||||||||||||
| Member declarations are simpler because the contract has a relatively clear dichotomy: the caller and callee. Both sides participate in a contract. The contract is structured around the aforementioned memory guarantees: an unsafe method may cause memory access violations unless all preconnditions are satisfied. The caller promises to fulfill all preconditions and the callee promises to fully enumerate all necessary preconditions. Assuming both parties discharge their obligations, the property holds and no access violations should occur. Commensurate notions of blame follow -- if a violation does occur, at least one party is at fault. If the caller did not satisfy all preconditions, they are at fault. If the callee did not fully specify all preconditions, they are at fault. In either case, the property can be repaired by identifying the cause of the failure and addressing it. | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
access violation is not the only failure mode. I would say "memory safety problem" or "memory safety error".
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "memory errors"?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, "preconnditions" -> "preconditions"
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My phrasing is not ideal here. I don't want to deliver the message that we will actually produce an error of any kind -- arbitrary behavior or corruption may occur. What I want to say is that the memory access rules that are guaranteed by the safety rules will not hold. |
||||||||||||||
|
|
||||||||||||||
| Unsafe blocks are more complicated. The are only used within functions, so when writing an unsafe block there is no natural boundary to apply. In fact, there are two contradictory sensible rules that might be used to decide the size of an unsafe block. On the one hand, there is value in making the block as small as possible -- containing precisely the call to the unsafe method and nothing else. On the other hand, the reason why that particular call might be safe may lie in preceding code that ensures required preconditions are true. You may want to enlarge the block to encompass both the unsafe call and the required preconditions. | ||||||||||||||
|
agocke marked this conversation as resolved.
Resolved
|
||||||||||||||
|
|
||||||||||||||
| Take [this example](https://github.com/dotnet/runtime/blob/b19edc085666acaf215ff60edce24cada71e2f93/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/MemoryMarshal.cs#L290C51-L290C70) from the .NET core libraries. The `Unsafe.As` method is an unsafe method that can only be used correctly if it is guaranteed that the input type has a legal conversion to the target type. In this case, we call an internal method that guarantees the input is a compatible array. Therefore, the call to `Unsafe.As` is valid, the containing method is safe, and we can enclose this call in an unsafe block. The question is, how large should the block be? We could narrowly scope the block, encompassing only the `Unsafe.As`, e.g.: | ||||||||||||||
|
|
||||||||||||||
| ```diff | ||||||||||||||
| if (RuntimeHelpers.ObjectHasComponentSize(obj)) | ||||||||||||||
| { | ||||||||||||||
| // The object has a component size, which means it's variable-length, but we already | ||||||||||||||
| // checked above that it's not a string. The only remaining option is that it's a T[] | ||||||||||||||
| // or a U[] which is blittable to a T[] (e.g., int[] and uint[]). | ||||||||||||||
|
|
||||||||||||||
| // The array may be prepinned, so remove the high bit from the start index in the line below. | ||||||||||||||
| // The ArraySegment<T> ctor will perform bounds checking on index & length. | ||||||||||||||
| + unsafe | ||||||||||||||
| + { | ||||||||||||||
| segment = new ArraySegment<T>(Unsafe.As<T[]>(obj), index & ReadOnlyMemory<T>.RemoveFlagsBitMask, length); | ||||||||||||||
| + } | ||||||||||||||
| return true; | ||||||||||||||
| } | ||||||||||||||
| ``` | ||||||||||||||
|
|
||||||||||||||
| We could also expand it to include the `ObjectHasComponentSize` call: | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Runtime implementation itself may not be the best motivating example. Can we use more user-centric examples that are not an internal runtime implementation details and that are something that can be found in 3rd party library on nuget.org?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes -- this is currently being driven by my annotation experiments in runtime, so I only have runtime-specific examples at the moment. |
||||||||||||||
|
|
||||||||||||||
| ```diff | ||||||||||||||
| +unsafe | ||||||||||||||
| +{ | ||||||||||||||
| if (RuntimeHelpers.ObjectHasComponentSize(obj)) | ||||||||||||||
| { | ||||||||||||||
| // The object has a component size, which means it's variable-length, but we already | ||||||||||||||
| // checked above that it's not a string. The only remaining option is that it's a T[] | ||||||||||||||
| // or a U[] which is blittable to a T[] (e.g., int[] and uint[]). | ||||||||||||||
|
|
||||||||||||||
| // The array may be prepinned, so remove the high bit from the start index in the line below. | ||||||||||||||
| // The ArraySegment<T> ctor will perform bounds checking on index & length. | ||||||||||||||
|
|
||||||||||||||
| segment = new ArraySegment<T>(Unsafe.As<T[]>(obj), index & ReadOnlyMemory<T>.RemoveFlagsBitMask, length); | ||||||||||||||
| return true; | ||||||||||||||
| } | ||||||||||||||
| else | ||||||||||||||
| { | ||||||||||||||
| // The object isn't null, and it's not variable-length, so the only remaining option | ||||||||||||||
| // is MemoryManager<T>. The ArraySegment<T> ctor will perform bounds checking on index & length. | ||||||||||||||
|
|
||||||||||||||
| Debug.Assert(obj is MemoryManager<T>); | ||||||||||||||
| if (Unsafe.As<MemoryManager<T>>(obj).TryGetArray(out ArraySegment<T> tempArraySegment)) | ||||||||||||||
| { | ||||||||||||||
| segment = new ArraySegment<T>(tempArraySegment.Array!, tempArraySegment.Offset + index, length); | ||||||||||||||
| return true; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| +} | ||||||||||||||
| ``` | ||||||||||||||
|
|
||||||||||||||
| If we keep the block small, we can easily see which parts are dangerous and which parts are not. As we expand the block, we capture more of the critical dependencies that flow into unsafe calls, but we also risk capturing pieces that are either not part of the safety contract, or may even be part of a different safety contract. Note that the call `Unsafe.As<MemoryManager<T>>(obj)` contains assumptions that don't even fit in the wider block. We would have to widen the scope even larger. | ||||||||||||||
|
|
||||||||||||||
| The principle of capturing all dependencies of unsafe calls is also tricky to pin down. Most safety contracts are much larger than it may seem. For instance, a lot of code requires arrays to meet their language guarantees (e.g., an array of type `T` will not contain an element of unrelated type `U`) or that GC pinning works as promised. If these guarantees were to break the safety guarantees would be lost. However, expanding unsafe blocks to the entire runtime is neither possible nor desirable. | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The low-level runtime is pretty much all unsafe code. I would be fine with having everything in the low-level runtime marked as unsafe if it comes to it.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's not what this paragraph is trying to say. It's not about implementation details of the runtime, it's about safety guarantees of the runtime itself. For example, pinning pointers "working" in the sense that an array is always pinned while one exists, is fundamental to many unsafe blocks' correctness. Marking the implementation details of the runtime unsafe doesn't change this, and isn't possible anyway because the implementation details of the runtime are not directly exposed in C#. The point is that there are foundational assumptions in all code that unsafe blocks rely on. We shouldn't have to explicitly state those assumptions in individual unsafe blocks, nor should users of unsafe have to review them all. They should be able to treat pinning pointers as working as specified, List working as specified, etc. This is a practical, engineering distinction vs. a mathematical, formal verification one. The list of actual dependencies of any given unsafe block are huge, so formal verification would require a mountain of verification to be written. But the farther you get in terms of indirect dependencies, the less useful this verification is, in practice. In practice, almost all the dependencies that matter are the immediate ones. |
||||||||||||||
|
|
||||||||||||||
| In sum, the guidance is to **keep unsafe blocks as small as reasonably possible**. In the example above, this would mean preferring the first diff to the second. In some cases the preconditions and unsafe calls are very close together and isolated. If so, it may be acceptable to widen the scope slightly. However, the starting point for unsafe blocks should be to keep them as small as reasonably possible. | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tracking issue from the Rust project is worth a read and likely inclusion, particularly the unresolved questions:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This keeps bothering me. I am worried that we are at risk of building something that is not all that useful in practice.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Well, maybe whatever we build will help to keep codebases 100% unsafe free, so no complex review will be required in the first place? E.g. "green" checkmarks on nuget packages will motivate library authors keep their code clean.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not think we can assume that we can drive unsafe code amount to nil.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I was saying that at very least we deliver a sort of a guarantee that if there is no Can we even theoretically do better than "if unsafe block exists, you have to review more than just code inside the block" ?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Dependencies and dependency graphs make reasoning complicated. Some other library with unsafe code may depend on your 100% safe library, and thus change or a bug in your 100% safe library may be indirectly causing memory safety problem in your app. How should people reason about situations like that?
We may be able to create some framework for what it is ok to take for granted when auditing unsafe code. For example, when I have unsafe code that depends on (safe) Span APIs for bounds check validation, it should be reasonable to assume that those Span APIs work correctly.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be good to see an example.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think the more accurate statement is: when auditing for memory safety issues you must also review dependencies of your unsafe code. I think that, in practice, this is easier than it sounds. In theory the set of indirect dependencies expands until it encompasses the entire program. In practice, programmers can make reasoned judgements like, "this is a fundamental invariant of the system, not specific to my use case, and is therefore assumed correct." Hence my example with arrays. Let's say you have some unsafe code that takes a pinning pointer to an array. The correctness of that code depends on pinning pointers working as designed -- if they don't, the access is unsound. If you want to specifically prove that your code is correct then a proof that pinning works properly is required. However, this is not useful or required in practice. If fundamental runtime features are broken, the whole system is broken. This particular instance of unsafe code doesn't matter. The usefulness of unsafe is predicated on assumptions like, fundamental runtime/compiler bugs are unlikely and fixed quickly. Luckily, I think that is a sound assumption in practice.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can split the relevant security sensitive code into:
We are saying that (1) is inside unsafe block, (2) and (3) are described by a comment, and correctness of (4) has to be taken for granted to make this practical. To audit unsafe code, one has to look at all (1), (2) and (3). (1) is easy to identify thanks to the unsafe block. The question is whether there should be a way to identify (2) and (3) that is better than comments.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should cross-link this with unsafe expressions. I expect that we will end up building unsafe expressions to support this guidance since they are the convenient mechanism for keeping the unsafe scopes as small as possible.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to start creating the list of proposed analyzers too - in this doc or in a separate doc? I think it would make sense to have it as part of this doc since the guidance and analyzers should be coupled together. I think we will want to have analyzer that warns on use of
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this has a lot of interaction with language design decisions, but I think it's important.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It doesn't seem useful to me to have preconditions in the unsafe blocks, ever. It is pretty standard that a piece of code has many preconditions (e.g., I would perhaps recommend people to put comments at the unsafe blocks documenting why it's safe for callers.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a method has preconditions that must be guaranteed by the caller for safe use, the method should be always marked as requiring unsafe caller. It is not ok to omit it (and e.g. replace it with asserts) in the name of minimizing unsafe contexts. It may be worth mentioning it explicitly in the guidance for clarity.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Makes sense, I was comparing this to other preconditions (where you could use asserts), not unsafe specifically.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The unsafe annotations for fields do not seem to be valuable with the minimal unsafe scope guidance. Assigning or reading fields is never unsafe on itself. It is only once you use the value and that use should get its own minimal scope.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strongly disagree. The motivation for unsafe fields is not about unsafe block scope, it's about contract boundary. Like calling methods, reading and writing fields crosses a contract boundary in the program. Effectively, it's non-local scope. When writing fields, the field may have a contract that only certain types of variables are allowed (e.g., a lot of unsafe code in the runtime does Unsafe.As on fields, assuming the actual type is only one of a fixed set of types). When reading fields, certain preconditions may need to be satisfied for the field value to be valid. In both of these cases, it's no different from calling an unsafe method. Use or assignment of the variable has specific non-compiler-verifiable requirements that must be satisfied by code inspection.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ok, it makes sense to think about fields as methods, and reading/writing fields as calling methods. Here is a different example:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assuming I understand the documentation correctly, yes this is a perfect example! Effectively, there is a precondition that the writer of the field must satisfy, otherwise memory safety violations may occur.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should start thinking about design of an auto-fixer that we will recommend people to run as the first thing after opting into unsafe v2. I think this auto-fixer should:
|
||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.