> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# مديرو الحزم Package Managers: npm و pip و Maven و Cargo
- URL: https://arabroot.io/articles/مديرو-الحزم-npm-و-pip-و-maven-و-cargo/
- Published: 2026-10-30T22:49:33.000Z
- Updated: 2026-10-30T23:48:59.000Z
- Description: نجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة

لنفرض أنك انضممت إلى شركة ناشئة تبيع برنامج فواتير للمحلات الصغيرة، وفي أسبوعك الأول وجدت أربعة مستودعات: واجهة API مكتوبة بلغة JavaScript على Node.js، وسكربتات تقارير بلغة Python، وخدمة مدفوعات قديمة بلغة Java، وخدمة جديدة لحساب الضرائب بدأ زميلك كتابتها بلغة Rust. وفي كل مستودع ملف صغير يقول «هذا المشروع يحتاج هذه المكتبات»، وأداة مختلفة تقرأه: npm وpip وMaven وCargo. وفي نفس الأسبوع حدثت ثلاثة أشياء: بناء خدمة Java نجح ولكن السجلات Logs اختفت من الإنتاج دون أي رسالة خطأ، وسكربت Python رفض أن يثبت أي مكتبة على السيرفر الجديد، وكتب زميلك اسم مكتبة Rust من ذاكرته فكاد يضيف مكتبة غير التي يقصدها. وكل هذه المشكلات لها نفس المصدر: الأداة التي تجلب كود غيرك إلى مشروعك، وتقرر أي إصدار تأخذ، ومن أين، وماذا تشغل أثناء ذلك.

وفي مقال [أدوات البناء Build Tools](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D9%86%D8%A7%D8%A1-build-tools-%D9%85%D9%86-make-%D8%A7%D9%84%D9%89-maven-%D9%88-gradle-%D9%88-dotnet/) رأينا ما يحدث بعد أن تصل المكتبات إلى مشروعك، أي الترجمة والاختبار والتغليف، وذكرنا ملفات القفل باختصار. وفي مقال [أمن سلسلة التوريد البرمجية](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) ومقال [فحص الاعتماديات SCA](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/) شرحنا التهديدات والدفاعات وأدوات الفحص. وهنا سوف ندخل إلى الأداة نفسها: كيف يحل مدير الحزم Package Manager شجرة الاعتماديات، وماذا يعني الرقم `^4.18.0`، ولماذا يختار Maven إصداراً قديماً دون أن يخبرك، وكيف تحصل على نفس الشجرة على جهازك وعلى سيرفر CI، مع تجربة حقيقية على الأدوات الأربع وقضية انتحال أسماء موثقة من نشرة أمنية رسمية.

وسوف نناقش في هذا المقال ما يلي:

- لماذا وجدت مديرو الحزم، وما الخطوات المشتركة بينها: ملف التعريف Manifest، والحل Resolver، والسجل Registry، وملف القفل Lockfile.
- الإصدار الدلالي SemVer والنطاقات Ranges، وما يقبله كل نطاق بالأرقام.
- npm: كم حزمة تأتي مع اعتمادية واحدة، وملف القفل، و`npm ci`، والسكربتات التي تعمل أثناء التثبيت.
- pip: البيئات الافتراضية، والملف `pylock.toml` الجديد، ووضع التحقق من الهاش.
- Maven: قاعدة «الأقرب يفوز» Nearest Wins، وتعارض حقيقي أخفى السجلات، وإصلاحه.
- Cargo: القفل والشجرة دون بناء، والإصدارات المكررة، والخيار `--locked`.
- انتحال الأسماء Typosquatting: قضية `rustdecimal` من نشرة RustSec، ثم التثبيت القابل للتكرار Reproducible Installs، وقائمة تحقق وأخطاء شائعة.

## لماذا وجدت مديرو الحزم Package Managers؟

قبل مديري الحزم كانت الطريقة المعتادة أن تنزل ملفاً مضغوطاً للمكتبة من موقعها، وتضعه في مجلد داخل مشروعك، وتضيفه إلى المستودع، وتكتب في ملف README أي إصدار نزلت. وهذه الطريقة تعمل مع مكتبة أو اثنتين، ثم تبدأ المشكلات: المكتبة تحتاج مكتبة أخرى بإصدار معين، وتلك تحتاج ثالثة، وتكتشف ذلك من رسائل الخطأ واحدة واحدة. ثم يصدر إصلاح أمني للمكتبة الثالثة، ولا أحد في الفريق يعرف أنها موجودة أصلاً لأنها لم تكتب في أي مكان. ثم تحتاج مكتبتان في مشروعك إصدارين مختلفين من نفس الحزمة، وهذا ما يسمى جحيم الاعتماديات Dependency Hell.

ومدير الحزم يحل هذه المشكلات بفكرة واحدة: أن تكتب ما تحتاجه مباشرة في ملف تعريف Manifest، وتترك للأداة أن تكتشف الباقي من بيانات كل حزمة في السجل Registry، وتختار إصداراً لكل حزمة يرضي كل المتطلبات، وتنزلها وتتحقق منها وتضعها في مكانها. وكل لغة كبيرة اليوم لها مدير حزم وسجل عام: npm مع السجل npmjs.org للغة JavaScript، وpip مع PyPI للغة Python، وMaven مع Maven Central للغة Java، وCargo مع crates.io للغة Rust. والثمن الذي تدفعه مقابل هذه الراحة أن مشروعك صار يعتمد على كود كثير لم تقرأه، كتبه أشخاص لا تعرفهم، ويتغير دون أن تطلب، وهذا ما سوف نقيسه بالأرقام.

## تجهيز المختبر Lab Setup: أربع أدوات في Container واحد

كل التجارب تعمل في Container واحد مبني على Debian 13، فيه Python وpip وMaven وJava 21 وCargo من حزم Debian، وNode.js 22 مع npm منسوخة من الـ Image الرسمية:

```bash
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates python3 python3-venv python3-pip maven openjdk-21-jdk-headless cargo git && rm -rf /var/lib/apt/lists/*
# Node.js 22 and npm from the official image (Debian trixie ships Node 20)
COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:22-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
WORKDIR /lab
```

```bash
docker build -t scp5-pm:1 .
docker run --rm scp5-pm:1 sh -c 'cat /etc/debian_version; node --version; npm --version; python3 --version; pip3 --version; mvn -v | head -3; cargo --version; rustc --version'
```

```text
13.7
v22.23.4
10.9.9
Python 3.13.5
pip 25.1.1 from /usr/lib/python3/dist-packages/pip (python 3.13)
Apache Maven 3.9.9
Maven home: /usr/share/maven
Java version: 21.0.13, vendor: Debian, runtime: /usr/lib/jvm/java-21-openjdk-amd64
cargo 1.85.1 (d73d2caf9 2024-12-31)
rustc 1.85.1 (4eb161250 2025-03-15) (built from a source tarball)
```

وفي مجلد المختبر أربعة مشاريع صغيرة بنفس الاسم `invoice-api`، كل منها بلغة، وسكربت لكل أداة يعمل بالأمر `docker run --rm -v "$PWD":/lab scp5-pm:1 sh /lab/run-npm.sh` وما يشبهه. وكل تشغيل يبدأ بذاكرة مؤقتة Cache فارغة، لأن الـ Container جديد في كل مرة، وهذا يشبه سيرفر CI نظيفاً أكثر مما يشبه جهاز المطور.

## ماذا يحدث عندما تكتب install؟ الحل Resolver والسجل Registry والقفل Lockfile

كل مديري الحزم، مهما اختلفت أسماؤهم، يمرون بنفس الخطوات، والصورة التالية تجمعها مع الفرق الأهم بين الأدوات الأربع:

![خطوات مدير الحزم: ملف التعريف مثل package.json وrequirements.in وpom.xml وCargo.toml، ثم الحل الذي يختار إصداراً لكل حزمة، ثم ملف القفل، ثم التنزيل من السجل npmjs.org وPyPI وMaven Central وcrates.io، ثم التحقق من الهاش، ثم التثبيت الذي قد يشغل كوداً من الحزمة، وتحتها بطاقة لكل أداة: npm وCargo بقفل افتراضي، وpip بقفل تجريبي، وMaven دون قفل](https://arabroot.io/content/images/2026/10/package-managers-01-what-pm-does.webp)

ست خطوات مشتركة، والفرق بين الأدوات في القفل والتحقق والكود الذي يعمل أثناء التثبيت

- ملف التعريف Manifest: ما تكتبه أنت، أي الاعتماديات المباشرة Direct Dependencies ونطاق الإصدارات الذي تقبله لكل منها، مثل `package.json` في npm و`pom.xml` في Maven و`Cargo.toml` في Cargo، وفي pip ملف المتطلبات `requirements.in` أو قسم الاعتماديات في `pyproject.toml`.
- الحل Resolver: الجزء الأصعب في الأداة. يقرأ من السجل اعتماديات كل حزمة، ثم اعتماديات اعتمادياتها، ويبحث عن مجموعة إصدارات ترضي كل النطاقات في نفس الوقت. وهذه مسألة صعبة حسابياً في الحالة العامة، ولذلك يختلف سلوك الأدوات عندما لا يوجد حل كامل: npm يسمح بأكثر من إصدار للحزمة نفسها في الشجرة، وCargo كذلك بشرط أن تختلف الإصدارات في الرقم المتوافق، وpip يتراجع ويجرب إصدارات أقدم كما في [صفحة حل الاعتماديات في توثيق pip](https://pip.pypa.io/en/stable/topics/dependency-resolution/?ref=arabroot.io)، وMaven يختار إصداراً واحداً بقاعدة بسيطة كما سوف ترى.
- ملف القفل Lockfile: نتيجة الحل مكتوبة في ملف، أي الإصدار الدقيق لكل حزمة في الشجرة، ومصدرها، وهاش محتواها. وهذا الملف هو ما يجعل التثبيت القادم على جهاز آخر يعطي نفس الشجرة.
- السجل Registry: سيرفر عام يحفظ الحزم وبياناتها، وأي شخص له حساب يستطيع أن ينشر فيه حزمة باسم لم يأخذه أحد، وهذه النقطة هي أصل أغلب مشكلات الأمان في هذا المقال.
- التحقق Verify: مقارنة هاش الملف المنزل بالهاش المكتوب في القفل، فإذا تغير محتوى الحزمة في السجل أو في الطريق رفضت الأداة التثبيت.
- التثبيت Install: فك الحزمة في مكانها، وفي بعض الأدوات تشغيل كود منها، مثل سكربت `postinstall` في npm، أو الملف `build.rs` في Cargo الذي يعمل عند البناء، وهذه نقطة سوف نجربها بأنفسنا.

## الإصدار الدلالي SemVer والنطاقات Ranges

أغلب الحزم ترقم إصداراتها بثلاثة أرقام حسب مواصفة [الإصدار الدلالي Semantic Versioning](https://semver.org/?ref=arabroot.io): الرقم الرئيسي MAJOR يتغير عندما يتغير شيء يكسر التوافق مع الكود القديم، والرقم الثانوي MINOR لميزة جديدة متوافقة، ورقم الإصلاح PATCH لإصلاح لا يغير السلوك المعلن. وهذا وعد من صاحب الحزمة، لا ضمان تقني، فلا يوجد ما يمنع أحداً من نشر تغيير كاسر في إصدار إصلاح، ولكن هذا الوعد هو ما يسمح لك بأن تكتب نطاقاً بدل رقم ثابت، أي أن تقول «أقبل أي إصلاح أو ميزة جديدة دون أن أغير ملف التعريف». والنطاقات في npm موثقة في [صفحة الإصدار الدلالي في توثيق npm](https://docs.npmjs.com/about-semantic-versioning?ref=arabroot.io) وفي مكتبة [node-semver](https://github.com/npm/node-semver?ref=arabroot.io) التي يستخدمها npm نفسه، فسألناها مباشرة عن بعض النطاقات:

```bash
npm view express@^4.18.0 version | tail -4
node -e 'const s=require("/usr/local/lib/node_modules/npm/node_modules/semver");for(const [r,v] of [["^4.18.0","4.21.2"],["^4.18.0","5.0.0"],["~4.18.0","4.19.0"],["^0.2.3","0.2.9"],["^0.2.3","0.3.0"],[">=4.18.0","5.1.0"]])console.log(r.padEnd(10),v.padEnd(7),s.satisfies(v,r))'
```

```text
express@4.22.0 '4.22.0'
express@4.22.1 '4.22.1'
express@4.22.2 '4.22.2'
express@4.22.3 '4.22.3'
^4.18.0    4.21.2  true
^4.18.0    5.0.0   false
~4.18.0    4.19.0  false
^0.2.3     0.2.9   true
^0.2.3     0.3.0   false
>=4.18.0   5.1.0   true
```

في المخرج أعلاه لاحظ التالي:

- الأسطر الأربعة الأولى هي آخر الإصدارات التي يقبلها النطاق `^4.18.0` في السجل يوم التجربة، وآخرها 4.22.3، أي أن من كتب هذا النطاق قبل سنوات يحصل اليوم على إصدار لم يكن موجوداً حين كتبه.
- العلامة `^` تقبل كل ما لا يغير الرقم الأول، والعلامة `~` تقبل الإصلاحات فقط، و`>=` تقبل كل شيء أعلى، حتى الإصدار الرئيسي القادم الذي سوف يكسر كودك.
- قبل الإصدار 1.0 تتغير القاعدة: `^0.2.3` لا يقبل `0.3.0`، لأن المواصفة تعتبر كل ما قبل 1.0 غير مستقر، فيعامل الرقم الثاني كأنه الرقم الرئيسي.

وكل أداة تكتب النطاقات بشكل مختلف قليلاً، والصورة التالية تجمع ما رأيناه في التجارب الأربع:

![ست بطاقات للنطاقات: ^4.18.0 في npm يقبل 4.21.2 ويرفض 5.0.0 والمثبت 4.22.3، و~4.18.0 يقبل إصلاحات 4.18 فقط، وقبل الإصدار 1.0 النطاق ^0.2.3 يرفض 0.3.0 وفي Cargo أخذ reqwest 0.12.28 مع وجود 0.13.5، و1 في Cargo يعني ^1 فأخذ rust_decimal 1.43.0، و~=3.0 في pip من 3.0 حتى أقل من 4.0 فأخذ Flask 3.1.3، و>=4.18.0 دون حد أعلى](https://arabroot.io/content/images/2026/10/package-managers-03-semver.webp)

النطاق يقول ما تقبل به في المستقبل، والقيم من مخرجات المختبر

وفي pip تكتب النطاقات حسب [مواصفة محددات الإصدار](https://packaging.python.org/en/latest/specifications/version-specifiers/?ref=arabroot.io)، والعلامة `~=3.0` تعني «إصدار متوافق»، أي من 3.0 حتى أقل من 4.0\. وفي Cargo الرقم وحده `"1"` يعني `^1` كما في [توثيق تحديد الاعتماديات في Cargo](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html?ref=arabroot.io). أما Maven فالمعتاد فيه أن تكتب إصداراً ثابتاً، ولديه صيغة نطاقات مثل `[1.0,2.0)` ولكن قليلاً ما تستخدم، كما في [توثيق آلية الاعتماديات في Maven](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html?ref=arabroot.io).

## npm: شجرة اعتماديات Dependency Tree كاملة من سطر واحد

مشروع Node.js في المختبر يحتاج مكتبة واحدة فقط، وهي إطار الويب express، وهذا ملف التعريف كاملاً:

```json
{
  "name": "invoice-api",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "express": "^4.18.0"
  }
}
```

```bash
npm install --no-audit --no-fund
npm ls --depth=0
```

```text
added 68 packages in 8s
elapsed 7741 ms
invoice-api@1.0.0 /lab/npm
`-- express@4.22.3
```

حذفنا من المخرج أسطر `npm notice` التي تقترح إصداراً أحدث من npm، والسطر `elapsed` تطبعه دالة صغيرة اسمها `timeit` في أول كل سكربت تقيس الوقت حول الأمر، لأن الأداة `/usr/bin/time` غير موجودة في الـ Image. وسطر واحد في ملف التعريف أدخل إلى المشروع 68 حزمة، أي أن 67 منها لم يكتبها أحد في الفريق ولم يخترها. ولنعرف أين وضعها npm، نعد مجلدات الحزم في `node_modules`، ونبحث في ملف القفل عن أعمق مسار:

```bash
find node_modules -name package.json | grep -cE "^node_modules/(@[^/]+/)?[^/]+/package.json$"
find node_modules -name package.json | grep -E "node_modules/[^/]+/node_modules/[^/]+/package.json$"
```

```text
67
node_modules/send/node_modules/ms/package.json
```

```bash
echo "entries in package-lock.json: $(node -e 'console.log(Object.keys(require("./package-lock.json").packages).length - 1)')"
echo "max depth: $(node -e 'const p=require("./package-lock.json").packages;let m=0,w="";for(const k of Object.keys(p)){const d=(k.match(/node_modules\//g)||[]).length;if(d>m){m=d;w=k}}console.log(m, w)')"
du -sh node_modules package-lock.json
npm ls ms
```

```text
entries in package-lock.json: 68
max depth: 2 node_modules/send/node_modules/ms
3.3M	node_modules
32K	package-lock.json
invoice-api@1.0.0 /lab/npm
`-- express@4.22.3
  +-- debug@2.6.9
  | `-- ms@2.0.0
  `-- send@0.19.2
    `-- ms@2.1.3
```

في المخرج أعلاه لاحظ التالي:

- 67 حزمة في المستوى الأول من `node_modules`، وحزمة واحدة متداخلة. فالشجرة المنطقية عميقة، ولكن npm يرفع كل حزمة إلى أعلى مستوى ممكن Hoisting، حتى تشترك فيها كل الحزم التي تحتاجها بنفس الإصدار، ولا تتكرر على القرص.
- الحالة الوحيدة التي لم يستطع فيها الرفع هي الحزمة `ms`: المكتبة `debug` تطلب الإصدار 2.0.0 بالضبط، والمكتبة `send` تطلب نطاقاً أعطى 2.1.3، فوضع npm الأولى في الأعلى، والثانية داخل مجلد `send` نفسه. وهذا هو الفرق الكبير بين npm وأدوات مثل Maven وpip: npm يسمح بإصدارين من نفس الحزمة في نفس المشروع، فلا يحدث تعارض، والثمن حجم أكبر واحتمال أن يوجد كائنان من نفس المكتبة لا يعرف أحدهما الآخر.
- الحجم 3.3 MB وملف القفل 32 KB، وهذا مشروع صغير جداً. وفي مشاريع الواجهات الأمامية مع أدوات البناء ليس غريباً أن تجد مئات الحزم.

### ماذا يوجد داخل ملف القفل package-lock.json؟

الملف `package-lock.json` يكتبه npm تلقائياً بعد كل تثبيت، وصيغته موثقة في [صفحة package-lock.json في توثيق npm](https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json?ref=arabroot.io). وهذا السجل الخاص بالمكتبة express، مقطوعاً عند قائمة اعتمادياتها:

```text
{
  "node_modules/express": {
    "version": "4.22.3",
    "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
    "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
    "license": "MIT",
    "dependencies": {
      "accepts": "~1.3.8",
      "array-flatten": "1.1.1",
      "body-parser": "~1.20.5",
      "content-disposition": "~0.5.4",
      "content-type": "~1.0.4",
      "cookie": "~0.7.1",
      "cookie-signature": "~1.0.6",
      "debug": "2.6.9",
      "depd": "2.0.0",
```

كل حزمة في الشجرة لها ثلاث معلومات تكفي لإعادة تثبيتها بالضبط: الإصدار `version`، والرابط الذي نزلت منه `resolved`، والهاش `integrity` من نوع SHA-512 لمحتوى الملف المضغوط. ولاحظ أن express نفسها تكتب اعتمادياتها بالعلامة `~` أو بأرقام ثابتة، أي أنها لا تقبل إلا إصلاحات، وهذا اختيار حذر من أصحابها يقلل المفاجآت لمن يستخدمها.

### التثبيت من القفل: npm ci

الأمر `npm install` يقرأ ملف القفل، ولكنه قد يعدله إذا وجد أن ملف التعريف تغير، أما [npm ci](https://docs.npmjs.com/cli/v10/commands/npm-ci?ref=arabroot.io) فيثبت من القفل حرفياً، ويحذف `node_modules` قبل أن يبدأ، ويرفض العمل إذا لم يطابق القفل ملف التعريف. ولنتأكد أنه يعطي نفس الشجرة، حفظنا ناتج `npm ls --all`، ثم حذفنا `node_modules` وثبتنا من جديد وقارنا الملفين:

```bash
npm ls --all > /tmp/tree1.txt
rm -rf node_modules
npm ci --no-audit --no-fund
npm ls --all > /tmp/tree2.txt
cmp /tmp/tree1.txt /tmp/tree2.txt && echo "same tree after npm ci"
```

```text
added 68 packages in 2s
elapsed 2385 ms
same tree after npm ci
```

نفس الشجرة، والتثبيت الثاني أخذ 2.4 ثانية بدل 7.7، لأن الحزم صارت في الذاكرة المؤقتة لمدير الحزم في `~/.npm`، وهذا سبب أن خطوط CI الجيدة تحفظ هذا المجلد بين التشغيلات. والآن نجرب ما يحدث لو أضاف أحد الزملاء مكتبة إلى `package.json` بيده ونسي أن يحدث القفل:

```text
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: dayjs@1.11.23 from lock file
```

رفض `npm ci` أن يكمل، وقال بالضبط ما الذي ينقص القفل. وهذا هو السلوك الذي تريده في خط CI: أن يفشل البناء عند أول اختلاف بين ما كتبه المطور وما اختبره، بدل أن يحل الشجرة من جديد بصمت ويبني شيئاً لم يجربه أحد.

### ماذا يحدث إذا كان في الحزمة سكربت تثبيت Install Script؟ postinstall

npm يسمح لكل حزمة أن تعرف سكربتات تعمل تلقائياً عند تثبيتها، مثل `preinstall` و`postinstall`، كما في [صفحة السكربتات في توثيق npm](https://docs.npmjs.com/cli/v10/using-npm/scripts?ref=arabroot.io). والغرض الأصلي مشروع، مثل بناء جزء مكتوب بلغة C أو تنزيل ملف تحتاجه المكتبة، ولكن معناه العملي أن تثبيت حزمة هو تشغيل كود من صاحبها على جهازك، قبل أن تستدعي منها سطراً واحداً. ولنرى ذلك، كتبنا حزمة محلية صغيرة للمختبر سكربتها يطبع المستخدم والمجلد:

```json
{
  "name": "evil-demo",
  "version": "1.0.0",
  "description": "Lab only: shows that npm runs install scripts of every package it installs",
  "scripts": {
    "postinstall": "node -e \"console.log('postinstall ran: user=' + require('os').userInfo().username + ' cwd=' + process.cwd() + ' HOME=' + process.env.HOME)\""
  }
}
```

```bash
npm install ./evil-demo --no-audit --no-fund --foreground-scripts
npm uninstall evil-demo --no-audit --no-fund
npm install ./evil-demo --no-audit --no-fund --foreground-scripts --ignore-scripts
```

```text
> evil-demo@1.0.0 postinstall
> node -e "console.log('postinstall ran: user=' + require('os').userInfo().username + ' cwd=' + process.cwd() + ' HOME=' + process.env.HOME)"
postinstall ran: user=root cwd=/lab/npm/evil-demo HOME=/root
added 1 package in 470ms
added 1 package in 553ms
```

في المخرج أعلاه لاحظ التالي:

- في التثبيت الأول عمل السكربت بصلاحيات `root` ومعه مجلد `HOME` كامل، أي أنه يستطيع أن يقرأ `~/.npmrc` بما فيه من توكن النشر، ومفاتيح SSH، ومتغيرات البيئة التي فيها أسرار CI، وأن يرسلها إلى أي مكان. وهذا ليس افتراضاً نظرياً، فقد كانت سكربتات التثبيت الطريق الذي استخدمته حزم خبيثة كثيرة في السجلات العامة، وعرضنا أمثلة منها في مقال أمن سلسلة التوريد.
- الخيار `--foreground-scripts` جعلنا نرى المخرج، فبدونه يخفي npm مخرج السكربتات الناجحة، أي أن السكربت يعمل دون أن تراه أصلاً.
- في التثبيت الثاني مع `--ignore-scripts` لم يعمل السكربت، وثبتت الحزمة. ويمكنك أن تجعل هذا الخيار افتراضياً في ملف `.npmrc` بالسطر `ignore-scripts=true` كما في [صفحة الإعدادات في توثيق npm](https://docs.npmjs.com/cli/v10/using-npm/config?ref=arabroot.io)، والثمن أن الحزم التي تحتاج سكربتها فعلاً، مثل بعض المكتبات التي تبني كوداً أصلياً، سوف تحتاج أن تشغل سكربتها بنفسك.

وفي Cargo يوجد ما يقابل هذا الخطر، وهو الملف `build.rs` الذي يعمل عند بناء المكتبة، وأيضاً الماكرو الإجرائية Procedural Macros التي تعمل داخل المترجم. وفي pip تشغل الحزم التي تأتي كملف مصدري Source Distribution كودها عند البناء، أما الحزم الجاهزة Wheels فلا تشغل شيئاً عند التثبيت. والقاعدة العامة: تثبيت أي حزمة قرار ثقة بصاحبها، لا بالكود الذي سوف تستدعيه منها فقط.

## pip والبيئات الافتراضية Virtual Environments

مشروع Python في المختبر يحتاج مكتبتين: `requests` للطلبات و`flask` للويب، وهذا ملف المتطلبات:

```text
requests>=2.31,<3
flask~=3.0
```

### المشكلة الأولى: النظام يرفض pip، والبيئة المدارة Externally Managed

أول ما فعلناه هو ما يفعله أغلب الناس على سيرفر جديد، أي تثبيت المكتبة مباشرة:

```bash
pip3 install requests
```

```text
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
```

هذا الخطأ جديد نسبياً على كثيرين، ومصدره [PEP 668](https://peps.python.org/pep-0668/?ref=arabroot.io): توزيعات Linux الحديثة، ومنها Debian 13، تعلن أن Python النظام يديره مدير حزم التوزيعة `apt`، فيرفض pip أن يكتب فوقه. والسبب أن أدوات النظام نفسها مكتوبة بلغة Python، وتثبيت إصدار آخر من مكتبة تعتمد عليها قد يعطل أداة في النظام لا علاقة لها بمشروعك. والحل الأنسب ليس الخيار `--break-system-packages` الذي يتجاوز الحماية، وإنما بيئة افتراضية Virtual Environment لكل مشروع بالوحدة [venv](https://docs.python.org/3/library/venv.html?ref=arabroot.io)، وهي مجلد فيه نسخة معزولة من مكان المكتبات:

```bash
python3 -m venv .venv
. .venv/bin/activate
pip --version
timeit pip install -q -r requirements.in 2>&1
pip list --format=freeze
```

```text
pip 25.1.1 from /lab/pip/.venv/lib/python3.13/site-packages/pip (python 3.13)
elapsed 24559 ms
blinker==1.9.0
certifi==2026.10.23
charset-normalizer==3.5.2
click==8.5.0
Flask==3.1.3
idna==3.20
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.4
pip==25.1.1
requests==2.34.2
urllib3==2.8.0
Werkzeug==3.1.9
```

في المخرج أعلاه لاحظ التالي:

- السطر الأول يؤكد أن pip الآن من داخل `.venv` لا من النظام، وهذا أول ما تفحصه عندما يثبت شيء في مكان لا تتوقعه.
- مكتبتان مباشرتان أدخلتا 12 حزمة، دون حساب pip نفسه، وهذا أقل بكثير من npm، لأن مكتبات Python تميل إلى أن تكون أكبر وأقل عدداً، ولأن المكتبة القياسية في Python واسعة.
- التثبيت أخذ 24.6 ثانية مع ذاكرة مؤقتة فارغة، ومعظمها تنزيل وتحليل للبيانات من PyPI.
- المكتبة `certifi` برقم إصدار هو تاريخ، `2026.10.23`، لأنها تحمل قائمة شهادات الجهات الموثوقة، وتصدر حسب التاريخ لا حسب SemVer، فلا تفترض أن كل الحزم تتبع نفس نظام الترقيم.

ولمعرفة من أدخل كل حزمة، يعطيك الأمر `pip show` قائمة الاعتماديات المباشرة لكل مكتبة:

```text
Name: requests
Version: 2.34.2
Requires: certifi, charset_normalizer, idna, urllib3
Name: Flask
Version: 3.1.3
Requires: blinker, click, itsdangerous, jinja2, markupsafe, werkzeug
```

### ملف القفل في Python: pip lock و pylock.toml

لسنوات طويلة لم يكن في pip ملف قفل حقيقي، وكان الفريق يستخدم `pip freeze` لكتابة الإصدارات الحالية في ملف، أو أدوات خارجية مثل pip-tools وPoetry وuv، ولكل منها صيغته. ثم اعتمدت مواصفة موحدة في [PEP 751](https://peps.python.org/pep-0751/?ref=arabroot.io) باسم [pylock.toml](https://packaging.python.org/en/latest/specifications/pylock-toml/?ref=arabroot.io)، وأضاف pip في الإصدار 25.1 الأمر [pip lock](https://pip.pypa.io/en/stable/cli/pip%5Flock/?ref=arabroot.io) لكتابتها، وما زال يوصف بأنه تجريبي:

```bash
pip lock -r requirements.in -o pylock.toml
grep -c '^\[\[packages\]\]' pylock.toml
```

```text
12
```

اثنتا عشرة حزمة، نفس العدد الذي ثبت، وهذا سجل `requests` فيها:

```text
name = "requests"
version = "2.34.2"

[[packages.wheels]]
name = "requests-2.34.2-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl"

[packages.wheels.hashes]
sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"
```

نفس المعلومات الثلاث التي رأيناها في npm: الإصدار، والرابط، والهاش. والفرق أن الحزمة في Python قد يكون لها أكثر من ملف جاهز Wheel، لكل نظام تشغيل وإصدار Python ملف، فيحمل القفل هاشاً لكل ملف منها.

### ماذا يحدث إذا تغير الملف؟ وضع التحقق من الهاش Hash-Checking Mode

حتى دون `pylock.toml` يستطيع pip أن يرفض أي ملف لا يطابق هاشاً تكتبه أنت في ملف المتطلبات، وهذا ما يسميه توثيقه [وضع التحقق من الهاش](https://pip.pypa.io/en/stable/topics/secure-installs/?ref=arabroot.io). نحسب الهاش الحقيقي لملف بالأمر [pip hash](https://pip.pypa.io/en/stable/cli/pip%5Fhash/?ref=arabroot.io)، ثم نثبت به مرة، ثم نضع هاشاً خاطئاً لنرى ما يحدث لو تغير الملف في السجل أو في الطريق:

```bash
pip download -q -d wheels "idna==3.10" --no-deps
pip hash wheels/idna-3.10-py3-none-any.whl
echo "idna==3.10 --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" > requirements.lock
pip install --require-hashes --no-deps -r requirements.lock
echo "idna==3.10 --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000" > requirements.lock
pip install --force-reinstall --require-hashes --no-deps -r requirements.lock
```

```text
wheels/idna-3.10-py3-none-any.whl:
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
Successfully installed idna-3.10
  Using cached idna-3.10-py3-none-any.whl (70 kB)
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    idna==3.10 from https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl (from -r requirements.lock (line 1)):
        Expected sha256 0000000000000000000000000000000000000000000000000000000000000000
             Got        946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
```

مع الهاش الصحيح ثبتت الحزمة، ومع الهاش الخاطئ رفض pip التثبيت حتى وهي موجودة في الذاكرة المؤقتة، وكتب الهاش المتوقع والهاش الفعلي، ونبه إلى أن أحداً ربما عبث بالملف. وفي السكربت الأصلي أخذنا الهاش من مخرج `pip hash` إلى متغير، وكتبناه هنا كاملاً للوضوح. ولاحظ أن وضع التحقق يطلب هاشاً لكل حزمة في الشجرة، لا للمباشرة فقط، ولذلك يكتب ملف التحقق عادة بأداة مثل `pip lock` أو pip-tools، لا باليد.

## Maven: إصدارات ثابتة وقاعدة «الأقرب يفوز» Nearest Wins

في عالم Java الوضع مختلف: لا يوجد ملف قفل، والسبب أن الاعتماديات في `pom.xml` تكتب عادة بإصدارات ثابتة، فالحل يعطي نفس النتيجة كل مرة ما دام الملف لم يتغير. ولكن هذا لا يعني أنه لا توجد قرارات، فالمكتبات المباشرة تحمل إصدارات ثابتة لاعتمادياتها هي، وعندما يطلب مساران في الشجرة نفس الحزمة بإصدارين، يجب أن يختار Maven إصداراً واحداً، لأن آلة Java الافتراضية لا تستطيع تحميل كلاسين بنفس الاسم من إصدارين في نفس المسار. وخدمة المدفوعات في المختبر تحتاج مكتبتين: عميل HTTP ومكتبة سجلات:

```xml
<dependencies>
    <!-- HTTP client: brings slf4j-api 1.7.x transitively -->
    <dependency>
      <groupId>org.apache.httpcomponents.client5</groupId>
      <artifactId>httpclient5</artifactId>
      <version>5.3.1</version>
    </dependency>
    <!-- Logging: needs slf4j-api 2.0.x -->
    <dependency>
      <groupId>ch.qos.logback</groupId>
      <artifactId>logback-classic</artifactId>
      <version>1.5.6</version>
    </dependency>
  </dependencies>
```

والكود يكتب سطر سجل واحداً عند البدء، ويطبع اسم الكلاس الذي ينفذ السجلات فعلاً:

```java
package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Main {
    private static final Logger log = LoggerFactory.getLogger(Main.class);

    public static void main(String[] args) {
        log.info("invoice-api started");
        System.out.println("logger implementation: " + log.getClass().getName());
    }
}
```

نطلب الشجرة بالإضافة [dependency:tree](https://maven.apache.org/plugins/maven-dependency-plugin/tree-mojo.html?ref=arabroot.io)، مرة عادية ومرة بالخيار `-Dverbose` الذي يظهر ما حذفه Maven:

```bash
mvn -B dependency:tree 2>&1 | sed -n '/--- dependency/,/BUILD/p' | sed 's/^\[INFO\] //'
mvn -B dependency:tree -Dverbose 2>&1 | sed -n '/--- dependency/,/BUILD/p' | sed 's/^\[INFO\] //'
```

والأمر `sed` يقطع جزء الشجرة من مخرج Maven ويحذف البادئة `[INFO]` من كل سطر، وهذا مخرج الأمر الثاني:

```text
com.example:invoice-api:jar:1.0.0
+- org.apache.httpcomponents.client5:httpclient5:jar:5.3.1:compile
|  +- org.apache.httpcomponents.core5:httpcore5:jar:5.2.4:compile
|  +- org.apache.httpcomponents.core5:httpcore5-h2:jar:5.2.4:compile
|  |  \- (org.apache.httpcomponents.core5:httpcore5:jar:5.2.4:compile - omitted for duplicate)
|  \- org.slf4j:slf4j-api:jar:1.7.36:compile
\- ch.qos.logback:logback-classic:jar:1.5.6:compile
   +- ch.qos.logback:logback-core:jar:1.5.6:compile
   \- (org.slf4j:slf4j-api:jar:2.0.13:compile - omitted for conflict with 1.7.36)
```

في المخرج أعلاه لاحظ التالي:

- الشجرة فيها ستة ملفات JAR فقط، وهذا عدد صغير مقارنة بالأدوات الأخرى، لأن مكتبات Java الكبيرة تميل إلى أن تجمع وظائفها داخلها.
- `omitted for duplicate` يعني أن نفس الحزمة بنفس الإصدار ظهرت مرتين، فأخذت مرة واحدة، ولا مشكلة هنا.
- السطر الأخير هو المشكلة: `logback-classic` طلبت `slf4j-api` بالإصدار 2.0.13، وعميل HTTP طلبها بالإصدار 1.7.36، فحذف Maven الإصدار الأحدث. والقاعدة في [توثيق Maven](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html?ref=arabroot.io) اسمها الأقرب يفوز Nearest Definition: يختار الإصدار الأقرب إلى مشروعك في الشجرة، وإذا تساوى العمق، كما هنا حيث كلاهما في المستوى الثاني، يفوز الذي ظهر أولاً في `pom.xml`. فترتيب سطرين في الملف قرر أي إصدار يعمل في الإنتاج.
- والبناء نجح، وهذا أخطر ما في الأمر: لا خطأ ولا تحذير في `mvn package`.

### ماذا يحدث عند التشغيل؟ السجلات Logs تختفي

```bash
mvn -B -q compile
mvn -B -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
java -cp target/classes:$(cat /tmp/cp.txt) com.example.Main
```

```text
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
logger implementation: org.slf4j.helpers.NOPLogger
```

هذه هي المشكلة التي وصفناها في أول المقال: السطر `invoice-api started` لم يظهر أبداً. فالمكتبة `slf4j-api` بالإصدار 1.7 تبحث عن التطبيق الفعلي للسجلات بكلاس اسمه `StaticLoggerBinder`، بينما logback 1.5 مكتوبة لطريقة الاكتشاف الجديدة في SLF4J 2، فلم تجد 1.7 أي تطبيق، واستخدمت سجلاً لا يفعل شيئاً NOP Logger، كما تشرح [صفحة رموز SLF4J](https://www.slf4j.org/codes.html?ref=arabroot.io). والتحذير الوحيد ثلاثة أسطر في بداية التشغيل، تضيع بسهولة بين مئات الأسطر في سجلات Container. وفي الإنتاج معنى ذلك أن الخدمة تعمل، ولكن أي خطأ فيها لن يكتب في أي مكان.

![قبل الإصلاح: شجرة Maven فيها httpclient5 مع slf4j-api 1.7.36 المختار، وlogback-classic مع slf4j-api 2.0.13 المحذوف بسبب التعارض، وعند التشغيل Defaulting to no-operation logger، وبعد الإصلاح: slf4j-api 2.0.13 في dependencyManagement، وعند التشغيل يظهر سطر INFO والـ Logger هو ch.qos.logback.classic.Logger](https://arabroot.io/content/images/2026/10/package-managers-04-maven-conflict.webp)

ترتيب سطرين في `pom.xml` قرر إصدار المكتبة، والبناء نجح والسجلات اختفت

### الإصلاح: dependencyManagement وقاعدة التقارب Dependency Convergence

الحل الأنسب ليس تبديل ترتيب السطرين، لأن هذا يصلح المشكلة اليوم ويترك القاعدة نفسها تقرر غداً، وإنما أن تعلن الإصدار الذي تريده صراحة في القسم `dependencyManagement`، فيصبح هو الإصدار لهذه الحزمة أينما ظهرت في الشجرة:

```xml
<dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>2.0.13</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
```

```bash
mvn -B dependency:tree 2>&1 | grep slf4j | sed 's/^\[INFO\] //'
mvn -B -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
java -cp target/classes:$(cat /tmp/cp.txt) com.example.Main
```

```text
|  \- org.slf4j:slf4j-api:jar:2.0.13:compile
23:36:39.641 [main] INFO com.example.Main -- invoice-api started
logger implementation: ch.qos.logback.classic.Logger
```

الآن الشجرة فيها الإصدار 2.0.13، وظهر سطر السجل بتنسيق logback، والكلاس الفعلي هو `ch.qos.logback.classic.Logger`. ولكي لا يتكرر هذا مع مكتبة أخرى، أضف إلى المشروع القاعدة [dependencyConvergence](https://maven.apache.org/enforcer/enforcer-rules/dependencyConvergence.html?ref=arabroot.io) من إضافة Maven Enforcer، وهي تفشل البناء عندما تطلب مسارات الشجرة إصدارات مختلفة من نفس الحزمة، فيتحول التعارض الصامت إلى خطأ صريح في CI يجبرك على قرار واع. وعلى الجهة الأخرى، Gradle يختار في نفس الحالة الإصدار الأحدث لا الأقرب كما في [توثيق حل الاعتماديات في Gradle](https://docs.gradle.org/current/userguide/dependency%5Fresolution.html?ref=arabroot.io)، وهذا اختيار له مشكلاته أيضاً، فالأحدث قد يكسر المكتبة التي طلبت الأقدم.

## Cargo: القفل Cargo.lock وشجرة الاعتماديات Dependency Tree دون بناء

خدمة الضرائب في المختبر مكتوبة بلغة Rust، وتحتاج خمس مكتبات مشهورة: `rust_decimal` للأرقام المالية، و`serde` و`serde_json` للتحويل إلى JSON، و`tokio` للتنفيذ غير المتزامن، و`reqwest` للطلبات:

```ini
[package]
name = "invoice-api"
version = "0.1.0"
edition = "2021"

[dependencies]
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
```

وميزة Cargo أنه يستطيع أن يحل الشجرة ويكتب القفل دون أن يترجم شيئاً، بالأمر [cargo generate-lockfile](https://doc.rust-lang.org/cargo/commands/cargo-generate-lockfile.html?ref=arabroot.io)، ثم يعرضها بالأمر [cargo tree](https://doc.rust-lang.org/cargo/commands/cargo-tree.html?ref=arabroot.io):

```bash
timeit cargo generate-lockfile 2>&1 | tail -3
echo "packages in Cargo.lock: $(grep -c '^\[\[package\]\]' Cargo.lock)"
echo "lines in cargo tree: $(cargo tree --prefix none | sort -u | wc -l)"
cargo tree --depth 1
```

```text
      Adding reqwest v0.12.28 (available: v0.13.5)
      Adding wasip2 v1.0.4+wasi-0.2.12 (requires Rust 1.87.0)
elapsed 16507 ms
packages in Cargo.lock: 187
lines in cargo tree: 152
invoice-api v0.1.0 (/lab/cargo)
├── reqwest v0.12.28
├── rust_decimal v1.43.0
├── serde v1.0.229
├── serde_json v1.0.154
└── tokio v1.54.1
```

في المخرج أعلاه لاحظ التالي:

- خمس مكتبات مباشرة أدخلت إلى القفل 187 حزمة، أي أكثر من npm مع express. ولكن الشجرة الفعلية على Linux 152 حزمة فقط، لأن `Cargo.lock` يحل الشجرة لكل المنصات مرة واحدة، فيحمل حزماً لا تستخدم إلا على Windows أو في WebAssembly. وعددنا في القفل 15 حزمة يبدأ اسمها بكلمة `windows`، وحزماً مثل `wasm-bindgen` و`web-sys`، وهذا مقصود حتى يكون القفل واحداً لكل المطورين مهما كان نظامهم.
- السطر الأول يقول إن `reqwest` أخذت 0.12.28 مع أن 0.13.5 متاحة، وهذه قاعدة ما قبل 1.0 التي رأيناها في npm: النطاق `"0.12"` يعني `^0.12`، والإصدار 0.13 قد يكسر التوافق، فلا يقبله Cargo إلا إذا غيرت ملف التعريف بيدك.
- السطر الثاني يقول إن الحزمة `wasip2` تحتاج Rust 1.87 بينما المترجم في Debian 1.85.1\. وهي حزمة لمنصة WASI فقط، فلا تمنع البناء على Linux، ولكنها تنبهك إلى أن السجل يتحرك أسرع من مترجم التوزيعة، ولذلك يحدد كثير من المشاريع أقل إصدار مدعوم Minimum Supported Rust Version في `Cargo.toml` بالحقل `rust-version`.
- والأمر `cargo tree` نزل ملفات الحزم كلها، أكثر من 140 سطر `Downloaded` في المخرج الكامل، لأنه يحتاج أن يقرأ ملف `Cargo.toml` داخل كل حزمة ليعرف المزايا Features المفعلة، مع أنه لم يترجم أي حزمة.

وهذا سجل `rust_decimal` في القفل:

```text
name = "rust_decimal"
version = "1.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
dependencies = [
 "arrayvec",
 "borsh",
 "bytes",
 "num-traits",
 "rand 0.8.8",
 "rand 0.9.5",
 "serde",
 "serde_json",
 "wasm-bindgen",
]
```

نفس المعلومات مرة أخرى: الإصدار، والمصدر، والهاش `checksum` من نوع SHA-256\. ولاحظ في الاعتماديات `rand 0.8.8` و`rand 0.9.5` معاً، فالمكتبة نفسها تعتمد على إصدارين من `rand`، وهذا ما يسمح به Cargo عندما يختلف الإصداران في الرقم المتوافق.

### الإصدارات المكررة Duplicates

```bash
cargo tree -d --depth 0
```

```text
base64 v0.22.1
base64 v0.23.1
syn v2.0.119
syn v3.0.9
```

الخيار `-d` يعرض الحزم التي دخلت الشجرة بأكثر من إصدار، وهنا `base64` بإصدارين و`syn` بإصدارين رئيسيين. والمكتبة `syn` هي محلل كود Rust الذي تستخدمه الماكرو الإجرائية، وهي من أثقل الحزم في الترجمة، فترجمتها مرتين تضيف وقتاً ملحوظاً إلى كل بناء نظيف. وهذا الأمر هو أول ما تشغله عندما يكبر وقت البناء أو حجم الملف التنفيذي دون سبب واضح، وغالباً يكون الحل تحديث مكتبة مباشرة إلى إصدار يستخدم نفس الإصدار الذي يستخدمه الباقون.

### ماذا يحدث إذا تغير Cargo.toml ولم يتغير القفل؟ --locked

أضفنا مكتبة إلى `Cargo.toml` دون تحديث القفل، ثم طلبنا الشجرة بالخيار `--locked`، وهو الخيار الذي يستخدم في CI مع `cargo build` كما في [توثيق cargo build](https://doc.rust-lang.org/cargo/commands/cargo-build.html?ref=arabroot.io):

```bash
sed -i 's/^serde_json = "1"/serde_json = "1"\nuuid = "1"/' Cargo.toml
cargo tree --locked --depth 0
```

```text
    Updating crates.io index
error: the lock file /lab/cargo/Cargo.lock needs to be updated but --locked was passed to prevent this
If you want to try to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.
```

نفس سلوك `npm ci`: القفل لم يعد يطابق ملف التعريف، فرفض Cargo أن يكمل بدل أن يحل الشجرة بصمت. ودون `--locked` كان سوف يضيف `uuid` وأي حزم تحتاجها إلى القفل ويكمل، وهذا مناسب على جهاز المطور وغير مناسب إطلاقاً في CI. وقد كانت التوصية القديمة في Cargo ألا تحفظ `Cargo.lock` في مستودعات المكتبات، ثم تغيرت لتقول احفظه في أغلب الحالات، كما في [دليل Cargo.toml مقابل Cargo.lock](https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html?ref=arabroot.io).

## المقارنة Comparison: ما تكتبه وما يصل إلى مشروعك

![أعمدة لكل أداة: npm اعتمادية مباشرة واحدة express أدخلت 68 حزمة، وpip اثنتان requests وflask أدخلتا 12، وMaven اثنتان httpclient5 وlogback-classic أدخلتا 6، وCargo خمس أدخلت 187 في القفل منها 152 على Linux](https://arabroot.io/content/images/2026/10/package-managers-02-tree-sizes.webp)

كل اعتمادية تكتبها تجلب معها شجرة، وحجم الشجرة يختلف كثيراً بين النظم

|                                | npm                       | pip                                  | Maven                        | Cargo                                  |
| ------------------------------ | ------------------------- | ------------------------------------ | ---------------------------- | -------------------------------------- |
| المباشرة ← الشجرة في المختبر   | 1 ← 68                    | 2 ← 12                               | 2 ← 6                        | 5 ← 187 (152 على Linux)                |
| ملف القفل                      | package-lock.json افتراضي | pylock.toml بالأمر pip lock التجريبي | لا يوجد                      | Cargo.lock افتراضي                     |
| التثبيت الصارم من القفل        | npm ci                    | \--require-hashes                    | إصدارات ثابتة في pom.xml     | \--locked                              |
| إصداران من نفس الحزمة          | مسموح (ms 2.0.0 و2.1.3)   | غير مسموح                            | غير مسموح، «الأقرب يفوز»     | مسموح بين أرقام غير متوافقة (syn 2 و3) |
| كود يعمل عند التثبيت أو البناء | سكربتات install           | بناء الحزم المصدرية                  | الإضافات Plugins عند البناء  | build.rs والماكرو                      |
| البيئة المعزولة                | node\_modules في المشروع  | venv يدوياً                          | المستودع المحلي \~/.m2 مشترك | \~/.cargo مشترك والبناء في المشروع     |

والملاحظ في Maven أن الشجرة ست حزم فقط، ولكن المستودع المحلي `~/.m2` حمل بعد التجربة 78 ملف JAR بحجم 28 MB، أغلبها إضافات Maven نفسها مثل إضافة الاعتماديات وإضافة الترجمة، وهذه أيضاً كود من السجل يعمل على جهازك وفي CI، وتنطبق عليه نفس قواعد الثقة والتثبيت.

## انتحال الأسماء Typosquatting: قضية rustdecimal

الآن نصل إلى المشكلة الثالثة في قصتنا: الزميل الذي كتب اسم المكتبة من ذاكرته. فالسجلات العامة لا تراجع الحزم قبل نشرها، وأي شخص يستطيع أن ينشر حزمة باسم يشبه اسماً مشهوراً، بحرف ناقص أو شرطة مختلفة، وينتظر من يخطئ في الكتابة. وهذا ما حدث في crates.io كما في [إعلان فريق Rust الأمني في مايو 2022](https://blog.rust-lang.org/2022/05/10/malicious-crate-rustdecimal.html?ref=arabroot.io) و[النشرة RUSTSEC-2022-0042](https://rustsec.org/advisories/RUSTSEC-2022-0042.html?ref=arabroot.io)، والمسجلة أيضاً في قاعدة GitHub برقم [GHSA-7pwq-f4pq-78gm](https://github.com/advisories/GHSA-7pwq-f4pq-78gm?ref=arabroot.io).

💡

في 25 مارس 2022 نشرت على crates.io مكتبة اسمها `rustdecimal`، أي اسم المكتبة المشهورة `rust_decimal` دون الشرطة السفلية، وفيها نفس كود المكتبة الأصلية ونفس وظائفها، إلا الدالة `Decimal::new`. فعند استدعائها كانت تفحص وجود متغير البيئة `GITLAB_CI`، فإذا وجدته نزلت ملفاً تنفيذياً إلى `/tmp/git-updater.bin` وشغلته، على Linux وmacOS. وفي 2 مايو 2022 أبلغ فريق crates.io، فحذفها نهائياً وفحص كل الحزم الأخرى بحثاً عن كود مشابه فلم يجد، وكانت قد نزلت أقل من 500 مرة ولم تعتمد عليها أي حزمة في السجل. والنصيحة في النشرة: إذا كان مشروعك يعمل على GitLab CI واعتمد على هذه الحزمة منذ 25 مارس، فاعتبر بيئة CI مخترقة.

![تسلسل قضية rustdecimal: نشرت في 25 مارس 2022 على crates.io، وعند استدعاء Decimal::new إذا وجد المتغير GITLAB_CI نزلت ملفاً إلى /tmp/git-updater.bin وشغلته، وفي 2 مايو 2022 أبلغ فريق crates.io وحذفت بأقل من 500 تنزيل، والنشرات الرسمية RUSTSEC-2022-0042 وGHSA-7pwq-f4pq-78gm، وبطاقتان: لماذا GitLab CI لأن سيرفرات CI تحمل أسرار النشر، وتكرر في 2025 مع faster_log وasync_println](https://arabroot.io/content/images/2026/10/package-managers-06-typosquat.webp)

شرطة سفلية واحدة بين المكتبة الحقيقية والمكتبة الخبيثة

لاحظ ذكاء الاختيار: المكتبة الخبيثة تعمل تماماً مثل الأصلية، فلن يلاحظ المطور أي فرق، والاختبارات تنجح، والكود الخبيث لا يعمل على جهاز المطور أصلاً، وإنما في CI فقط، حيث توجد أسرار النشر والتوكنات ومفاتيح السحابة، وحيث تشغل الاختبارات كل الدوال تلقائياً. والقصة لم تنته في 2022، ففي سبتمبر 2025 حذف فريق crates.io مكتبتين أخريين، `faster_log` و`async_println`، نسختا مكتبة سجلات حقيقية وأضافتا كوداً يبحث في ملفات السجلات عن مفاتيح محافظ رقمية ويرسلها، بعد 7181 و1243 تنزيلاً، كما في [إعلان crates.io عنهما](https://blog.rust-lang.org/2025/09/24/crates.io-malicious-crates-fasterlog-and-asyncprintln/?ref=arabroot.io).

ولنرى ماذا يرى المطور اليوم، سألنا السجل عن الاسمين، ثم حاولنا إضافة الاسم الخاطئ:

```bash
cargo search rust_decimal --limit 3
cargo search rustdecimal --limit 3
cargo add rustdecimal
```

```text
rust_decimal = "1.43.0"           # Decimal number implementation written in pure Rust suitable for financial and fixed-precision…
rust_decimal_macros = "1.40.0"    # Shorthand macros to assist creating Decimal types.
nutype = "0.8.0"                  # The newtype with guarantees.
... and 3907 crates more (use --limit N to see more)
note: to learn more about a package, run `cargo info <name>`
cratestack-macros = "0.17.0"    # Rust-native schema-first framework for typed HTTP APIs, generated clients, and backend services…
decimal-bytes = "0.6.0"         # Arbitrary precision decimals with lexicographically sortable byte encoding
helios-fhir = "0.2.6"           # The helios-fhir crate is the comprehensive FHIR (Fast Healthcare Interoperability Resources) sp…
... and 7 crates more (use --limit N to see more)
note: to learn more about a package, run `cargo info <name>`
    Updating crates.io index
error: the crate `rustdecimal` could not be found in registry index.
```

الاسم الخاطئ لم يعد موجوداً، والبحث عنه يعطي نتائج لا علاقة لها، و`cargo add` يرفض. ولكن هذا لأن الحزمة حذفت بعد اكتشافها، والخطر في الأيام أو الأشهر التي تسبق الاكتشاف. وفي npm تجد أثر قضية مشابهة قديمة، وهي الحزمة `crossenv` التي انتحلت اسم `cross-env` في 2017 كما شرحنا في مقال أمن سلسلة التوريد، فقد استبدلها npm بحزمة فارغة حتى لا ينشر أحد بالاسم من جديد:

```bash
npm view crossenv versions description
```

```text
versions = [ '0.0.1-security', '0.0.2-security', '1.0.0', '1.0.1' ]
description = 'security holding package'
```

الوصف `security holding package` والإصدارات المنتهية بكلمة `security` هي ما يضعه npm مكان الحزم التي حذفها لأسباب أمنية، حسب [سياسة الأمان في npm](https://docs.npmjs.com/policies/security?ref=arabroot.io). والدروس العملية من القضيتين:

- لا تكتب اسم مكتبة من ذاكرتك، وانسخه من التوثيق الرسمي للمكتبة أو من صفحتها في السجل، وتأكد من عدد التنزيلات وتاريخ أول إصدار ورابط المستودع.
- مراجعة الكود Code Review يجب أن تنتبه لأي اعتمادية جديدة في ملف التعريف كأنها كود جديد، لأنها كذلك فعلاً.
- أدوات فحص الاعتماديات تعرف الحزم الخبيثة المعروفة، فالنشرة RUSTSEC-2022-0042 مسجلة في قاعدة OSV باسم `MAL-2022-1`، وقد شرحنا تشغيل هذه الأدوات في مقال فحص الاعتماديات SCA.
- بيئة CI هي الهدف الأول، فاعطها أقل الصلاحيات، وأسراراً قصيرة العمر، وشغل التثبيت دون سكربتات حيث أمكن.

## التثبيت القابل للتكرار Reproducible Installs في CI

جمعنا ما رأيناه في الأدوات الأربع في صورة واحدة، والفكرة في كل الحالات: ملف القفل في المستودع، والتثبيت في CI يرفض أي اختلاف عنه بدل أن يحل الشجرة من جديد:

![مقارنة: دون قفل يعطي النطاق نفسه إصداراً مختلفاً على جهاز المطور في يناير وسيرفر CI في مارس، وأي إصدار خبيث داخل النطاق يدخل تلقائياً، ومع القفل والتثبيت الصارم npm ci يعطي نفس الشجرة، والقفل غير المطابق يرفضه npm ci بالخطأ EUSAGE وcargo --locked، والهاش غير المطابق يرفضه pip --require-hashes، وتحتها الهاش في كل ملف قفل](https://arabroot.io/content/images/2026/10/package-managers-05-lockfile.webp)

النطاق يقول ما تقبل به، والقفل يقول ما اخترته فعلاً، والتثبيت الصارم يفرض القفل

| الأداة | على جهاز المطور                          | في CI                                                                                   | ماذا تحفظ في المستودع           |
| ------ | ---------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------- |
| npm    | npm install                              | npm ci --ignore-scripts مع تشغيل السكربتات اللازمة صراحة                                | package.json وpackage-lock.json |
| pip    | pip install -r requirements.in داخل venv | pip install --require-hashes -r ملف بهاشات كاملة، أو التثبيت من pylock.toml بأداة تدعمه | ملف المتطلبات والملف المقفل     |
| Maven  | mvn package                              | mvn -B package مع قاعدة dependencyConvergence وإصدارات ثابتة للإضافات                   | pom.xml                         |
| Cargo  | cargo build                              | cargo build --locked                                                                    | Cargo.toml وCargo.lock          |

والتثبيت القابل للتكرار يحل نصف المشكلة فقط: يضمن أنك تبني اليوم نفس ما اختبرته أمس، ولكنه لا يضمن أن ما اخترته أمس آمن. والنصف الآخر هو التحديث المنتظم للقفل بأداة مثل Renovate أو Dependabot مع مراجعة كل تحديث وفحصه، وهذا ما شرحناه بالتفصيل في مقال فحص الاعتماديات SCA. وإذا كانت عندك حزم داخلية بأسماء خاصة، فهناك هجوم آخر اسمه الخلط بين الحزم Dependency Confusion، حيث ينشر المهاجم في السجل العام حزمة بنفس اسم حزمتك الداخلية برقم إصدار أعلى، وحله مصدر واحد للحزم Proxy Registry وأسماء بنطاق Scope تملكه، كما في مقال أمن سلسلة التوريد.

وهناك سؤال أخير يظهر في المؤسسات التي تبني على سيرفرات لا تصل إلى الإنترنت، أو تريد أن تبني نفس الإصدار بعد خمس سنوات حتى لو حذفت حزمة من السجل: هل يكفي القفل؟ والإجابة لا، فالقفل يحفظ اسم الحزمة وهاشها، ولكنه لا يحفظ الحزمة نفسها، فإذا اختفت من السجل فشل التثبيت مع أن القفل سليم. والحل أن تحتفظ بنسخة من الحزم نفسها، إما بسجل داخلي يعمل كوسيط Proxy ويحفظ كل ما مر به، مثل الأدوات التي ذكرناها في مقال أمن سلسلة التوريد، وإما بنسخ الاعتماديات داخل المشروع Vendoring، مثل الأمر `cargo vendor` في Cargo الذي ينسخ مصادر كل الحزم إلى مجلد في المستودع، أو `pip download` الذي نزل به ملف `idna` في تجربتنا ثم التثبيت بالخيار `--no-index` من ذلك المجلد. والسجل الداخلي هو الأنسب لأغلب الفرق، لأنه يخدم كل المشاريع مرة واحدة ولا يضخم المستودعات، ويعطيك مكاناً واحداً تمنع فيه حزمة معروفة الخطر قبل أن تصل إلى أي جهاز.

## قائمة تحقق Checklist لكل مشروع جديد

- ملف القفل محفوظ في المستودع، وCI يثبت منه بالوضع الصارم: `npm ci` أو `--locked` أو `--require-hashes`.
- النطاقات في ملف التعريف لها حد أعلى، ولا يوجد `>=` مفتوح ولا `*`.
- كل اعتمادية جديدة تمر بمراجعة: الاسم منسوخ من التوثيق الرسمي، والمستودع موجود ونشط، وعدد التنزيلات وتاريخ النشر منطقيان.
- Python في `venv` لكل مشروع، لا في Python النظام ولا بالخيار `--break-system-packages`.
- سكربتات التثبيت في npm معطلة افتراضياً في CI، وتشغل صراحة للحزم التي تحتاجها.
- في Maven قاعدة `dependencyConvergence` مفعلة، والإصدارات المشتركة معلنة في `dependencyManagement`.
- في Cargo يعرض `cargo tree -d` دورياً، وتعالج الإصدارات المكررة الثقيلة.
- الذاكرة المؤقتة لمدير الحزم محفوظة بين تشغيلات CI لتسريع التثبيت، والقفل هو مفتاحها.
- فحص للاعتماديات من الثغرات والحزم الخبيثة المعروفة في كل بناء، وتحديث منتظم للقفل مع مراجعة.

## أخطاء شائعة Common Mistakes

- عدم حفظ ملف القفل في المستودع، أو حذفه عند كل مشكلة: كل تثبيت بعدها يحل الشجرة من جديد بإصدارات لم يختبرها أحد.
- استخدام `npm install` في CI بدل `npm ci`: الأول قد يعدل القفل بصمت، والثاني رفض الاختلاف بخطأ `EUSAGE` واضح.
- الظن أن الاعتمادية الواحدة حزمة واحدة: express أدخلت 68 حزمة، وخمس مكتبات Rust أدخلت 187.
- تجاهل قاعدة ما قبل 1.0: `^0.2.3` لا يقبل `0.3.0`، و`reqwest = "0.12"` لن يصل إلى 0.13 دون تعديل يدوي.
- تجاوز خطأ `externally-managed-environment` بالخيار `--break-system-packages` بدل بيئة افتراضية.
- ترك Maven يختار إصدار المكتبات المشتركة: ترتيب سطرين اختار `slf4j-api` 1.7.36، فاختفت كل السجلات والبناء ناجح.
- تجاهل التحذيرات في أول أسطر التشغيل: `Defaulting to no-operation (NOP) logger` كان التنبيه الوحيد.
- تشغيل التثبيت في CI بصلاحيات كاملة وأسرار متاحة: سكربت `postinstall` عمل كـ `root` ومعه `HOME` كاملاً.
- كتابة اسم مكتبة من الذاكرة: `rustdecimal` بدل `rust_decimal` كانت كافية لتشغيل برنامج خبيث في GitLab CI.
- الظن أن القفل يعني الأمان: القفل يثبت ما اخترته، ولا يقول إن ما اخترته خال من الثغرات.

## حذف المختبر Cleanup

```bash
docker rmi scp5-pm:1
rm -rf lab/npm/node_modules lab/pip/.venv lab/pip/wheels lab/cargo/target lab/maven/target
```

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- كل مدير حزم يمر بنفس الخطوات: ملف تعريف تكتبه أنت، وحل يختار الإصدارات، وقفل يحفظ الاختيار بالهاش، وتنزيل من سجل عام، وتحقق، وتثبيت قد يشغل كوداً.
- الإصدار الدلالي وعد من صاحب الحزمة، والنطاق `^` يقبل كل ما لا يغير الرقم الرئيسي، وقبل 1.0 يصبح الرقم الثاني هو الرئيسي.
- اعتمادية واحدة في npm أدخلت 68 حزمة، وnpm يسمح بإصدارين من نفس الحزمة، و`npm ci` أعطى نفس الشجرة ورفض القفل غير المطابق.
- سكربت `postinstall` عمل كـ `root` لحظة التثبيت ومخرجه مخفي افتراضياً، و`--ignore-scripts` يمنعه.
- Python النظام في Debian 13 يرفض pip بسبب PEP 668، والحل `venv` لكل مشروع، و`pip lock` يكتب الآن `pylock.toml` الموحد، ووضع الهاش رفض ملفاً لا يطابق.
- Maven دون قفل، ويختار إصداراً واحداً بقاعدة الأقرب ثم الأسبق، فأخفى تعارض `slf4j-api` كل السجلات، والإصلاح `dependencyManagement` مع قاعدة `dependencyConvergence`.
- Cargo يكتب القفل لكل المنصات، 187 حزمة منها 152 على Linux، و`cargo tree -d` كشف إصدارين من `syn`، و`--locked` رفض التغيير.
- قضية `rustdecimal` في 2022 وقضيتا 2025 تبين أن حرفاً واحداً في الاسم يكفي، وأن CI هو الهدف، وأن نسخ الأسماء من التوثيق ومراجعة كل اعتمادية جديدة جزء من الأمان.

[![](https://arabroot.io/content/images/2026/10/build-tools-cover-cover.webp)دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D9%86%D8%A7%D8%A1-build-tools-%D9%85%D9%86-make-%D8%A7%D9%84%D9%89-maven-%D9%88-gradle-%D9%88-dotnet/)[![](https://arabroot.io/content/images/2026/10/supply-chain-cover-cover.webp)دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/sca-cover-cover.webp)دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/)[![](https://arabroot.io/content/images/2026/10/compilation-jit-cover-cover.webp)دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%AA%D8%B1%D8%AC%D9%85-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%B3%D8%B1-%D9%88-jit-%D9%88-webassembly-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/)[![](https://arabroot.io/content/images/2026/10/language-specs-cover-cover.webp)دليلاللغة شيء والمترجم شيء آخر: من يقرر مستقبل لغات البرمجة؟لماذا لا تصح جمل مثل «Python بطيئة»، وما الفرق بين مواصفة اللغة والبرامج التي تطبقها، ومن يقرر مستقبلها.](https://arabroot.io/articles/%D9%85%D9%88%D8%A7%D8%B5%D9%81%D8%A7%D8%AA-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA%D9%87%D8%A7-%D9%88%D8%AD%D9%88%D9%83%D9%85%D8%AA%D9%87%D8%A7/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب داخل Container من Debian 13.7 (trixie) مع Node.js 22.23.4 وnpm 10.9.9 وPython 3.13.5 وpip 25.1.1 وApache Maven 3.9.9 مع OpenJDK 21.0.13 وcargo 1.85.1، على Docker Engine 29.8.0.