پارادایم جدید امنیت حافظه در C# 16: بازطراحی مکانیسم Unsafe با الهام از Rust
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۱۵ ۰۹:۰۰
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
unsafe) در نظر گرفته شدهاست. در مدل سنتی سیشارپ (از نسخه ۱.۰ تا کنون)، کلیدواژه unsafe صرفاً به عنوان یک مجوز سینتکسی برای استفاده از اشارهگرها (Pointers) عمل میکرد؛ رویکردی که در آن مرز میان کد امن و ناامن برای کامپایلر و ابزارهای تحلیل کدهای هوش مصنوعی شفاف نبود.unsafe را به طور کامل بازطراحی کردهاست. هدف این تغییر بزرگ، تبدیل unsafe از یک «مجوز سینتکسی ساده» به یک «قرارداد صریح میان فراخواننده و فراخوانده» (Caller-Facing Contract) است؛ رویکردی که شباهت بسیار زیادی به مدل پیشرفته و اثباتشدهی زبان راست (Rust) دارد.unsafe تعریف میشد، این کلیدواژه بیشتر شبیه به یک جعبه سیاه رفتار میکرد. کامپایلر متوجه تعهدات و الزامات خاصی که برنامهنویس باید در زمان فراخوانی آن متد رعایت کند (مانند معتبر بودن آدرس حافظه یا خارج نشدن از محدوده آرایه) نمیشد.byte*) در امضای متد به طور خودکار ناامنی را به کدهای بالاتر سرایت میداد، بدون اینکه مرز دقیق جایی که عملیات خطرناکِ آزادسازی یا دیرفرنس (Dereferencing) اتفاق میافتد، مشخص باشد. با تکامل اکوسیستم داتنت، نیاز بود که این تعهدات به صورت کاملاً مستند، ساختاریافته و قابلارزیابی توسط کامپایلر درآیند.unsafeبه یک قرارداد صریحunsafe بر روی یک متد، به این معناست که فراخواننده (Caller) باید وظایف خاصی را برای حفظ پایداری برنامه انجام دهد. کامپایلر از این پس به شما اجازه نخواهد داد که یک متد ناامن را بدون قرار دادن صریح آن در یک بلوک unsafe {} در سمت فراخواننده، صدا بزنید.// مدل قدیمی (C# 1.0 - 15)
public unsafe void ProcessData(byte* buffer, int length)
{
// کار با اشارهگر
}
// فراخوانی در متد دیگر بدون نیاز به بلاک صریح در صورت unsafe بودن متد بیرونی// مدل جدید C# 16
/// <summary>
/// پردازش بافر خام حافظه.
/// </summary>
/// <safety>
/// فراخواننده باید تضمین کند که اشارهگر buffer به یک حافظه معتبر اشاره میکند
/// و طول آرایه حداقل به اندازه پارامتر length است.
/// </safety>
public unsafe void ProcessData(byte* buffer, int length)
{
// پیادهسازی متد
}
public void Execute()
{
byte[] data = new byte[10];
fixed (byte* p = data)
{
// کامپایلر الزام میکند که محل دقیق فراخوانی متد unsafe مشخص باشد
unsafe
{
ProcessData(p, 10);
}
}
}/// معرفی کرده است. از این پس، هر متدی که از اصلاحگر unsafe استفاده میکند، وظیفه دارد تعهدات فراخواننده (Caller Obligations) را در این تگ مستند کند. ابزارهای تحلیل ایستا (Static Analysis) و کامپایلر میتوانند از این تگها برای بازبینی کد (Auditing) استفاده کنند.void* یا int*) در امضای متد، دیگر به خودی خود باعث ناامنی متد نمیشود. تنها عملیات دیرفرنس کردن اشارهگر (Dereferencing) ناامن محسوب میشود. بنابراین، جابجایی اشارهگرها امن است، اما دسترسی به مقدار پشت آنها نیازمند مرز صریح unsafe است.IntPtr قدیمی، از پوینترهای تایپشده مثل byte* یا void* استفاده شود و برای هندلهای بومی از SafeHandle بهره گرفته شود.unsafe روی تعریف خود class یا struct ممنوع شده و منجر به خطای کامپایل میشود. ناامنی باید دقیقاً روی اعضا (Members) مشخص شود.static constructor و finalizer دیگر نمیتوانند لایه بیرونی ناامن داشته باشند؛ چرا که زمانبندی اجرای آنها توسط رانتایم مدیریت میشود و فراخوانندهی مستقیمی برای پذیرش تعهدات وجود ندارد.new() Constraint): اگر یک کلاس دارای سازنده بدون پارامتر ناامن (unsafe parameterless constructor) باشد، دیگر نمیتواند قید ژنریک new() را برآورده کند؛ زیرا ساخت نمونه از آن نیازمند رعایت الزامات امنیتی است..csproj) رفتار پروژه خود را مدیریت کنید:<PropertyGroup> <MemorySafetyRules>2</MemorySafetyRules> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup>
MemorySafetyRules برابر با 1 باشد، رفتار کلاسیک کامپایلر حفظ میشود و با تنظیم آن روی 2 (که در .NET 12 به صورت پیشفرض خواهد بود)، قوانین سختگیرانه جدید فعال میگردند. برای تسهیل این انتقال، مایکروسافت ابزارهای اصلاح خودکار کد نظیر dotnet format را بهروزرسانی کرده است تا کدهای قدیمی را به طور خودکار با افزودن بلاکهای صریح unsafe {} به ساختار جدید پورت کنند.unsafe و اشارهگرها (pointers) هستند.Span که در سال ۲۰۱۸ معرفی شد، بیشتر نیازهای کد unsafe را پوشش داد و slicing حافظه را با سرعتی نزدیک به اشارهگرها ممکن کرد. حالا C# 16 گام بیشتری برداشته و مفهوم unsafe را دقیقتر، شفافتر و ایمنتر بازتعریف کرده است. این مطلب به بررسی هر دو سر طیف میپردازد: از اشارهگرهای کلاسیک تا رویکردهای مدرن و ایمن.unsafe و سوئیچ AllowUnsafeBlocks استفاده کنید. این کار انواع اشارهگر، عملگر آدرس (&)، حسابگری اشارهگر، fixed و stackalloc را فعال میکند.static unsafe void Copy(byte[] source, int sourceOffset,
byte[] target, int targetOffset, int count)
{
if (source == null || target == null)
throw new ArgumentException("source or target is null");
if (sourceOffset < 0 || targetOffset < 0 || count < 0)
throw new ArgumentException("offset or count is negative");
if (source.Length - sourceOffset < count ||
target.Length - targetOffset < count)
throw new ArgumentException("not enough room to copy");
// پین کردن آرایهها تا GC آنها را جابهجا نکند
fixed (byte* pSource = source, pTarget = target)
{
for (int i = 0; i < count; i++)
pTarget[targetOffset + i] = pSource[sourceOffset + i];
}
}fixed برای پین کردن (چون GC اشیاء مدیریتشده را جابهجا میکند)، بررسیهای دستی مرز و مدیریت طول عمر. اشتباه در حسابگری به جای exception، منجر به buffer overrun میشود. اشارهگرها سریع و انعطافپذیرند، اما مسئولیت بررسی مرز، مدیریت طول عمر و ایمنی کاملاً بر عهده توسعهدهنده است.Span یک نوع value type کوچک (از نوع ref struct) است که یک ناحیه پیوسته از حافظه را نشان میدهد: اشاره به عنصر اول به علاوه طول. ویژگیهای کلیدی آن:stackalloc، فیلد داخل شیء، کاراکتر داخل رشته یا حافظه native اشاره کند.fixed نیست.ref struct تضمین میکند.var data = new byte[10]; Span<byte> bytes = data; // تبدیل ضمنی Span<byte> middle = bytes.Slice(start: 2, length: 4); middle[0] = 42; // مستقیماً در data[2] نوشته میشود // middle[4] = 1; // IndexOutOfRangeException
ReadOnlySpan نسخه فقطخواندنی آن است و اجازه slicing رشته بدون تخصیص حافظه جدید را میدهد.Span<int> numbers = stackalloc int[3];
const int StackLimit = 256;
Span<byte> buffer = length <= StackLimit
? stackalloc byte[length]
: new byte[length];ref struct تضمین میکند که Span از stack خارج نشود. برای مواردی که نیاز به نگهداری در heap یا await دارید، از Memory استفاده کنید.unsafe را به طور اساسی بازطراحی کردهاست. این تغییرات الهامگرفته از Rust است و هدف آن تبدیل unsafe از یک زمینه سینتکسی ساده به یک قرارداد صریح و قابل بررسی است که توسعهدهندگان و ابزارها بتوانند آن را به راحتی audit کنند. این مدل در ابتدا opt-in است (با پراپرتی MSBuild جدید EnableRequiresUnsafe) و به تدریج در .NET 11 (preview) و .NET 12 (production) عرضه میشود.unsafe تنها جایی است که کامپایلر مسئولیت را به توسعهدهنده واگذار میکند. هدف جدید: شفاف، محدود و قابل بررسی کردن این مرز.unsafe خارج از زمینه unsafe حالا خطای کامپایل تولید میکند (نه فقط warning).void Caller()
{
M(); // خطا: فراخوانی M() خارج از unsafe context
}
unsafe void M() { }unsafe کرد. unsafe فقط روی متدها، پراپرتیها، فیلدها و local functions اعمال میشود.byte*) دیگر unsafe محسوب نمیشود.data[i]) نیاز به بلوک unsafe دارد.unsafe { ... } قرار گیرند. این کار scoping را محدود و بررسی کد را آسان میکند./// داشته باشد که تعهدات caller را به وضوح توصیف کند.// SAFETY: برای توضیح منطق ایمنی استفاده میشود.safe:/// <safety>
/// Callers must pass a non-null pointer to at least <paramref name="length"/>
/// readable bytes. The memory must stay alive for the duration of the call.
/// </safety>
static unsafe int SumBytes(byte* data, int length)
{
// SAFETY: length توسط wrapper عمومی اعتبارسنجی شده و قرارداد caller
// تضمین میکند data حداقل به این اندازه بایت معتبر اشاره دارد.
int total = 0;
unsafe
{
for (int i = 0; i < length; i++)
total += data[i];
}
return total;
}dotnet format برای مهاجرت خودکار (اضافه کردن بلوکها و جابهجایی modifierها) در نظر گرفته شده، اما نوشتن مستندات همچنان نیاز به قضاوت انسانی دارد، که دقیقاً هدف مدل است.unsafe و pointerها فقط وقتی مراجعه کنید که Span واقعاً ناکافی باشد (interop خاص، fixed-size bufferها و غیره). مدل جدید C# 16 این کار را ایمنتر، شفافتر و قابل نگهداریتر میکند. اشارهگرها حذف نمیشوند؛ فقط در جایی قرار میگیرند که تعلق دارند؛ پشت دری مشخص، با قرارداد صریح و توسط کسانی که میتوانند دلایل ایمنی را به وضوح مستند کنند. این رویکرد تعادل مناسبی بین عملکرد، ایمنی و قابلیت نگهداری ایجاد میکند و توسعهدهندگان حرفهای را قادر میسازد کدهایی بنویسند که هم سریع باشند و هم قابل اعتماد.unsafe را معرفی کرده. داخل unsafe، کامپایلر تقریباً همه بررسیهای ایمنی را خاموش میکند. در عوض:/// # Safety) دقیقاً بنویسد که caller چه تعهداتی باید رعایت کند (مثلاً «این اشارهگر نباید null باشد»، «حافظه باید حداقل X بایت معتبر باشد»، «طول عمر حافظه باید تا پایان فراخوانی تضمین شود» و غیره).// SAFETY: داخل پیادهسازی برای توضیح منطق ایمنی استفاده میشود.unsafe در #C (که بیشتر یک سوئیچ ساده بود) را به سمت همان فلسفه Rust برده است:/// # Safety در Rust. هر متد unsafe باید توضیح دهد caller چه تضمینهایی باید بدهد.