diff --git a/.github/workflows/cross-port-interop.yml b/.github/workflows/cross-port-interop.yml
index 5d7aa97..5f1b5fe 100644
--- a/.github/workflows/cross-port-interop.yml
+++ b/.github/workflows/cross-port-interop.yml
@@ -16,6 +16,8 @@ on:
paths:
- 'reference/PCF-SIG-v1.0/**'
- 'implementations/**/pcf-sig/**'
+ - 'reference/PCF-DCP-v1.0/**'
+ - 'implementations/**/pcf-dcp/**'
- 'implementations/ts/package.json'
- 'implementations/ts/package-lock.json'
- 'implementations/dotnet/Directory.Build.props'
@@ -25,6 +27,8 @@ on:
paths:
- 'reference/PCF-SIG-v1.0/**'
- 'implementations/**/pcf-sig/**'
+ - 'reference/PCF-DCP-v1.0/**'
+ - 'implementations/**/pcf-dcp/**'
- 'implementations/ts/package.json'
- 'implementations/ts/package-lock.json'
- 'implementations/dotnet/Directory.Build.props'
@@ -169,3 +173,111 @@ jobs:
/tmp/ts.bin
/tmp/php.bin
/tmp/dotnet.bin
+
+ cross-port-byte-exact-dcp:
+ name: all DCP writers produce identical bytes
+ runs-on: ubuntu-latest
+ # Expected SHA-256 of the canonical 700-byte DCP vector (spec Section 17).
+ env:
+ EXPECTED_SHA256: b9bb59794abed008863063886d8d0daa810c44939c1c5d29449475ced8156b90
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: implementations/ts/package-lock.json
+
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: hash, mbstring
+ coverage: none
+ tools: composer:v2
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ # ---- Rust reference writer --------------------------------------------
+ - name: Generate Rust reference vector
+ run: cargo run -p pcf-dcp --example gen_testvector -- /tmp/rust.bin
+
+ # ---- TypeScript writer ------------------------------------------------
+ - name: Install npm deps and build pcf
+ working-directory: implementations/ts
+ run: |
+ npm ci
+ npm run build -w @kduma-oss/pcf
+ - name: Generate TS vector
+ working-directory: implementations/ts
+ run: npm run gen-testvector -w @kduma-oss/pcf-dcp -- /tmp/ts.bin
+
+ # ---- PHP writer -------------------------------------------------------
+ - name: Install composer deps
+ working-directory: implementations/php/pcf-dcp
+ run: composer install --prefer-dist --no-progress --no-interaction
+ - name: Generate PHP vector
+ working-directory: implementations/php/pcf-dcp
+ run: php examples/gen_testvector.php /tmp/php.bin
+
+ # ---- .NET writer ------------------------------------------------------
+ - name: Generate .NET vector
+ run: |
+ mkdir -p /tmp/dotnet-gen
+ cat > /tmp/dotnet-gen/GenTestVector.csproj <<'EOF'
+
+
+ Exe
+ net8.0
+ disable
+
+
+
+
+
+ EOF
+ cat > /tmp/dotnet-gen/Program.cs <<'EOF'
+ using System.IO;
+ using Pcf.Dcp;
+ File.WriteAllBytes(args[0], ReferenceVector.Build());
+ EOF
+ dotnet run --project /tmp/dotnet-gen/GenTestVector.csproj -c Release -- /tmp/dotnet.bin
+
+ # ---- Compare ----------------------------------------------------------
+ - name: All four DCP writers agree on byte-exact output
+ run: |
+ set -euo pipefail
+ ls -l /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin
+ fail=0
+ for f in /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin; do
+ d=$(sha256sum "$f" | awk '{print $1}')
+ echo "$f -> $d"
+ if [ "$d" != "$EXPECTED_SHA256" ]; then
+ echo "::error::$f sha256 = $d (expected $EXPECTED_SHA256)"
+ fail=1
+ fi
+ if ! cmp -s /tmp/rust.bin "$f"; then
+ echo "::error::$f differs from /tmp/rust.bin"
+ fail=1
+ fi
+ done
+ if [ "$fail" != "0" ]; then
+ echo "Cross-port DCP writer interop FAILED"
+ exit 1
+ fi
+ echo "All four DCP writers produced sha256 = $EXPECTED_SHA256"
+
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: cross-port-vectors-dcp
+ path: |
+ /tmp/rust.bin
+ /tmp/ts.bin
+ /tmp/php.bin
+ /tmp/dotnet.bin
diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml
index 25e7c2d..f47abd8 100644
--- a/.github/workflows/dotnet-ci.yml
+++ b/.github/workflows/dotnet-ci.yml
@@ -6,6 +6,7 @@ on:
paths:
- 'implementations/dotnet/pcf/**'
- 'implementations/dotnet/pcf-sig/**'
+ - 'implementations/dotnet/pcf-dcp/**'
- 'implementations/dotnet/Directory.Build.props'
- '.github/workflows/dotnet-ci.yml'
pull_request:
@@ -13,6 +14,7 @@ on:
paths:
- 'implementations/dotnet/pcf/**'
- 'implementations/dotnet/pcf-sig/**'
+ - 'implementations/dotnet/pcf-dcp/**'
- 'implementations/dotnet/Directory.Build.props'
- '.github/workflows/dotnet-ci.yml'
@@ -24,7 +26,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
- package: [pcf, pcf-sig]
+ package: [pcf, pcf-sig, pcf-dcp]
defaults:
run:
working-directory: implementations/dotnet/${{ matrix.package }}
diff --git a/.github/workflows/php-split.yml b/.github/workflows/php-split.yml
index af99241..72a01bb 100644
--- a/.github/workflows/php-split.yml
+++ b/.github/workflows/php-split.yml
@@ -31,6 +31,7 @@ jobs:
package:
- { dir: 'implementations/php/pcf', repo: 'PHP-PCF-lib' }
- { dir: 'implementations/php/pcf-sig', repo: 'PHP-PCF-SIG-lib' }
+ - { dir: 'implementations/php/pcf-dcp', repo: 'PHP-PCF-DCP-lib' }
steps:
- uses: actions/checkout@v4
with:
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index a40b41c..bd5c8db 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -14,7 +14,7 @@ jobs:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
- package: [pcf, pcf-sig]
+ package: [pcf, pcf-sig, pcf-dcp]
defaults:
run:
working-directory: implementations/php/${{ matrix.package }}
@@ -47,6 +47,7 @@ jobs:
include:
- { package: pcf, output: pcf_testvector.bin, expected: 395 }
- { package: pcf-sig, output: pcfsig_testvector.bin, expected: 966 }
+ - { package: pcf-dcp, output: pcf_dcp_testvector.bin, expected: 700 }
defaults:
run:
working-directory: implementations/php/${{ matrix.package }}
diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml
index 95bfe21..9f8df34 100644
--- a/.github/workflows/release-prepare.yml
+++ b/.github/workflows/release-prepare.yml
@@ -79,14 +79,21 @@ jobs:
sed -i 's/^version = "[^"]*"/version = "'"$NEW"'"/' reference/PCF-DCP-v1.0/Cargo.toml
sed -i 's/^version = "[^"]*"/version = "'"$NEW"'"/' tools/pcf-debug/Cargo.toml
sed -i 's/^version = "[^"]*"/version = "'"$NEW"'"/' tools/pcf-compact/Cargo.toml
+ sed -i 's/^version = "[^"]*"/version = "'"$NEW"'"/' tools/pcf-sig/Cargo.toml
# path-dep version pins on pcf
sed -i 's|pcf = { path = "\.\./PCF-v1.0", version = "[^"]*" }|pcf = { path = "../PCF-v1.0", version = "'"$NEW"'" }|' reference/PFS-MS-v1.0/Cargo.toml
sed -i 's|pcf = { path = "\.\./PCF-v1.0", version = "[^"]*" }|pcf = { path = "../PCF-v1.0", version = "'"$NEW"'" }|' reference/PCF-SIG-v1.0/Cargo.toml
sed -i 's|pcf = { path = "\.\./PCF-v1.0", version = "[^"]*" }|pcf = { path = "../PCF-v1.0", version = "'"$NEW"'" }|' reference/PCF-DCP-v1.0/Cargo.toml
+ # PFS-MS also pins the PCF-SIG library and the pcf-sig CLI library
+ sed -i 's|pcf-sig = { path = "\.\./PCF-SIG-v1.0", version = "[^"]*" }|pcf-sig = { path = "../PCF-SIG-v1.0", version = "'"$NEW"'" }|' reference/PFS-MS-v1.0/Cargo.toml
+ sed -i 's|pcf-sig-cli = { path = "\.\./\.\./tools/pcf-sig", version = "[^"]*" }|pcf-sig-cli = { path = "../../tools/pcf-sig", version = "'"$NEW"'" }|' reference/PFS-MS-v1.0/Cargo.toml
sed -i 's|pcf = { path = "\.\./\.\./reference/PCF-v1.0", version = "[^"]*" }|pcf = { path = "../../reference/PCF-v1.0", version = "'"$NEW"'" }|' tools/pcf-debug/Cargo.toml
sed -i 's|pcf-sig = { path = "\.\./\.\./reference/PCF-SIG-v1.0", version = "[^"]*" }|pcf-sig = { path = "../../reference/PCF-SIG-v1.0", version = "'"$NEW"'" }|' tools/pcf-debug/Cargo.toml
sed -i 's|pcf-dcp = { path = "\.\./\.\./reference/PCF-DCP-v1.0", version = "[^"]*" }|pcf-dcp = { path = "../../reference/PCF-DCP-v1.0", version = "'"$NEW"'" }|' tools/pcf-debug/Cargo.toml
sed -i 's|pcf = { path = "\.\./\.\./reference/PCF-v1.0", version = "[^"]*" }|pcf = { path = "../../reference/PCF-v1.0", version = "'"$NEW"'" }|' tools/pcf-compact/Cargo.toml
+ # pcf-sig CLI pins on pcf and pcf-sig
+ sed -i 's|pcf = { path = "\.\./\.\./reference/PCF-v1.0", version = "[^"]*" }|pcf = { path = "../../reference/PCF-v1.0", version = "'"$NEW"'" }|' tools/pcf-sig/Cargo.toml
+ sed -i 's|pcf-sig = { path = "\.\./\.\./reference/PCF-SIG-v1.0", version = "[^"]*" }|pcf-sig = { path = "../../reference/PCF-SIG-v1.0", version = "'"$NEW"'" }|' tools/pcf-sig/Cargo.toml
- name: Bump TypeScript packages
shell: bash
@@ -102,6 +109,13 @@ jobs:
sed -i 's|"kduma/pcf": "\^[^"]*"|"kduma/pcf": "^'"$NEW"'"|' implementations/php/pcf-sig/composer.json
sed -i 's|"versions": { "kduma/pcf": "[^"]*" }|"versions": { "kduma/pcf": "'"$NEW"'" }|' implementations/php/pcf-sig/composer.json
+ - name: Bump PHP pcf-dcp dependency on pcf
+ shell: bash
+ run: |
+ NEW='${{ steps.version.outputs.version }}'
+ sed -i 's|"kduma/pcf": "\^[^"]*"|"kduma/pcf": "^'"$NEW"'"|' implementations/php/pcf-dcp/composer.json
+ sed -i 's|"versions": { "kduma/pcf": "[^"]*" }|"versions": { "kduma/pcf": "'"$NEW"'" }|' implementations/php/pcf-dcp/composer.json
+
- name: Bump .NET Directory.Build.props
shell: bash
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index be1a07c..1b45644 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -190,6 +190,7 @@ jobs:
- run: npm ci
- run: npm run build -w @kduma-oss/pcf
- run: npm run build -w @kduma-oss/pcf-sig
+ - run: npm run build -w @kduma-oss/pcf-dcp
- name: npm publish pcf (OIDC trusted publishing, auto-provenance)
run: |
if [ "${{ needs.resolve.outputs.dry_run }}" = "true" ]; then
@@ -204,6 +205,13 @@ jobs:
else
npm publish -w @kduma-oss/pcf-sig --access public
fi
+ - name: npm publish pcf-dcp
+ run: |
+ if [ "${{ needs.resolve.outputs.dry_run }}" = "true" ]; then
+ npm publish -w @kduma-oss/pcf-dcp --access public --dry-run
+ else
+ npm publish -w @kduma-oss/pcf-dcp --access public
+ fi
publish-nuget:
name: Publish to NuGet
@@ -291,6 +299,49 @@ jobs:
name: nuget-package-sig
path: implementations/dotnet/pcf-sig/out/*.nupkg
+ publish-nuget-dcp:
+ name: Publish KDuma.Pcf.Dcp to NuGet
+ needs: [resolve, publish-nuget]
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ defaults:
+ run:
+ working-directory: implementations/dotnet/pcf-dcp
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+ - run: dotnet restore
+ - name: dotnet pack
+ run: |
+ dotnet pack src/Pcf.Dcp/Pcf.Dcp.csproj \
+ -c Release \
+ -p:Version='${{ needs.resolve.outputs.version }}' \
+ -o out
+ - name: NuGet login (OIDC trusted publishing)
+ id: nuget-login
+ if: needs.resolve.outputs.dry_run != 'true'
+ uses: NuGet/login@v1
+ with:
+ user: krystianduma
+ - name: dotnet nuget push
+ if: needs.resolve.outputs.dry_run != 'true'
+ run: |
+ dotnet nuget push out/*.nupkg \
+ --source https://api.nuget.org/v3/index.json \
+ --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' \
+ --skip-duplicate
+ - name: Dry-run note
+ if: needs.resolve.outputs.dry_run == 'true'
+ run: 'echo "Dry-run - skipping dotnet nuget push. Package would be out/*.nupkg."'
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nuget-package-dcp
+ path: implementations/dotnet/pcf-dcp/out/*.nupkg
+
split-php:
name: Split PHP to packagist source repo
needs: resolve
diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml
index 80ae7fb..2ff054a 100644
--- a/.github/workflows/ts-ci.yml
+++ b/.github/workflows/ts-ci.yml
@@ -24,6 +24,7 @@ jobs:
- run: npm ci
- run: npm run build -w @kduma-oss/pcf
- run: npm run build -w @kduma-oss/pcf-sig
+ - run: npm run build -w @kduma-oss/pcf-dcp
test:
name: test (${{ matrix.os }})
@@ -41,10 +42,11 @@ jobs:
cache-dependency-path: implementations/ts/package-lock.json
- run: npm ci
- run: npm test -w @kduma-oss/pcf
- # pcf-sig imports the compiled @kduma-oss/pcf dist/; build pcf first
- # so the workspace dependency resolves before vitest runs.
+ # pcf-sig and pcf-dcp import the compiled @kduma-oss/pcf dist/; build pcf
+ # first so the workspace dependency resolves before vitest runs.
- run: npm run build -w @kduma-oss/pcf
- run: npm test -w @kduma-oss/pcf-sig
+ - run: npm test -w @kduma-oss/pcf-dcp
test-vector:
name: regenerate spec test vector
@@ -71,12 +73,19 @@ jobs:
run: |
ls -l pcf-sig/pcfsig_testvector.bin
test "$(wc -c < pcf-sig/pcfsig_testvector.bin)" = "966"
+ - name: Build and run the PCF-DCP test-vector example
+ run: npm run gen-testvector -w @kduma-oss/pcf-dcp -- pcf_dcp_testvector.bin
+ - name: Inspect PCF-DCP test vector
+ run: |
+ ls -l pcf-dcp/pcf_dcp_testvector.bin
+ test "$(wc -c < pcf-dcp/pcf_dcp_testvector.bin)" = "700"
- uses: actions/upload-artifact@v4
with:
name: pcf-testvector-ts
path: |
implementations/ts/pcf/pcf_testvector.bin
implementations/ts/pcf-sig/pcfsig_testvector.bin
+ implementations/ts/pcf-dcp/pcf_dcp_testvector.bin
coverage:
name: code coverage
@@ -95,9 +104,12 @@ jobs:
run: npm run build -w @kduma-oss/pcf
- name: Generate PCF-SIG coverage report (enforces >=90% line / 100% function)
run: npm run coverage -w @kduma-oss/pcf-sig
+ - name: Generate PCF-DCP coverage report (enforces >=85% line / 90% function)
+ run: npm run coverage -w @kduma-oss/pcf-dcp
- uses: actions/upload-artifact@v4
with:
name: coverage-lcov-ts
path: |
implementations/ts/pcf/coverage/lcov.info
implementations/ts/pcf-sig/coverage/lcov.info
+ implementations/ts/pcf-dcp/coverage/lcov.info
diff --git a/implementations/dotnet/pcf-dcp/README.md b/implementations/dotnet/pcf-dcp/README.md
new file mode 100644
index 0000000..0b77df8
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/README.md
@@ -0,0 +1,62 @@
+# KDuma.Pcf.Dcp — PCF Dynamic Container Partition (.NET)
+
+.NET reader/writer for **PCF-DCP v1.0**, an application-level profile that adds
+*dynamic*, fragmentable, dedup-friendly sub-partitions to the
+[Partitioned Container Format](../pcf) without modifying the PCF byte container.
+
+This package mirrors the written specification (`PCF-DCP-spec-v1.0.txt`) and the
+Rust reference implementation field-for-field, and ships the same byte-exact
+700-byte canonical test vector as every other port. It has no cryptographic
+dependency — data/table hashing comes from the base `KDuma.Pcf` package.
+
+## Model at a glance
+
+One new PCF partition type is defined:
+
+| Type | Name | Holds |
+|--------------|-----------------|----------------------------------------------------|
+| `0xAAAC0001` | `DCP_CONTAINER` | An *arena*: a header, an inner partition table, fragment tables, and data extents |
+
+```
+arena:
+[ DCP Header (24 B) | data extents | Fragment Tables | Inner Table Block(s) ]
+```
+
+Each inner partition's logical content is the concatenation of its DATA extents;
+its data hash covers that logical content, so fragmentation, deduplication,
+compaction, and promotion all leave the hash (and any PCF-SIG signature over it)
+unchanged. A generic PCF reader sees a DCP file as **one opaque partition**; only
+a DCP-aware reader looks inside.
+
+## Example
+
+```csharp
+using System.IO;
+using Pcf;
+using Pcf.Dcp;
+
+var arena = new Arena();
+arena.AddInner(0x10, Uid(0xA1), "A", Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+arena.AddInner(0x10, Uid(0xB2), "B", Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+
+var w = new DcpWriter();
+w.AddContainer(Uid(0xDC), "dcp", arena);
+byte[] image = w.ToImage();
+
+var r = DcpReader.Open(new MemoryStream(image));
+r.Verify();
+// System.Text.Encoding.UTF8.GetString(r.ReadInner(Uid(0xB2))) == "World!"
+```
+
+## Operations
+
+`Arena` supports content-defined deduplication, copy-on-write edits
+(`Append` / `Insert` / `Overwrite` / `Delete` / `Truncate`), and
+sharing-preserving `Compact`. `DcpWriter` adds **promotion** (`Promote`,
+dynamic → fixed) and **demotion** (`Demote`, fixed → dynamic), each preserving
+`uid`, `PartitionType`, `Label`, `DataHashAlgo`, and `DataHash` — the promotion
+invariant, identical to the fields a PCF-SIG signature protects.
+
+## Licence
+
+MIT OR Apache-2.0.
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Arena.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Arena.cs
new file mode 100644
index 0000000..e68f0eb
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Arena.cs
@@ -0,0 +1,848 @@
+using System;
+using System.Collections.Generic;
+using Pcf;
+
+namespace Pcf.Dcp;
+
+///
+/// The DCP arena: the in-memory model of one DCP container and its canonical
+/// byte serialisation.
+///
+/// An holds a byte pool plus a list of inner partitions,
+/// each owning a list of fragments. A fragment addresses a byte range in the
+/// pool; two fragments addressing the same range share that extent
+/// (deduplication, spec Section 10.2). Edits work on the fragment list and
+/// append new bytes to the pool, never overwriting bytes a SHARED extent still
+/// names (copy-on-write, spec Section 10.1). always emits
+/// the canonical layout of the spec's Section 17 test vector.
+///
+public sealed class Arena
+{
+ private sealed class Frag
+ {
+ public int Offset;
+ public int Length;
+ public byte Kind;
+ public bool Shared;
+ }
+
+ private sealed class Inner
+ {
+ public uint PartitionType;
+ public byte[] Uid;
+ public byte[] Label;
+ public HashAlgo DataHashAlgo;
+ public List Frags;
+ }
+
+ private byte ProfileVersionMajor = Constants.ProfileVersionMajor;
+ private byte ProfileVersionMinor = Constants.ProfileVersionMinor;
+ private ushort Flags;
+ private HashAlgo _innerTableAlgo = HashAlgo.Sha256;
+ private byte[] _blob = Array.Empty();
+ private int _blobLen;
+ private readonly List _inners = new List();
+
+ /// Choose the hash algorithm used for inner Table Blocks (default SHA-256).
+ public Arena WithInnerTableAlgo(HashAlgo algo)
+ {
+ _innerTableAlgo = algo;
+ return this;
+ }
+
+ // ---- byte pool ---------------------------------------------------------
+
+ private int AppendBlob(byte[] data)
+ {
+ int start = _blobLen;
+ int end = start + data.Length;
+ if (end > _blob.Length)
+ {
+ int cap = _blob.Length == 0 ? 64 : _blob.Length;
+ while (cap < end)
+ {
+ cap *= 2;
+ }
+ var next = new byte[cap];
+ Buffer.BlockCopy(_blob, 0, next, 0, _blobLen);
+ _blob = next;
+ }
+ Buffer.BlockCopy(data, 0, _blob, start, data.Length);
+ _blobLen = end;
+ return start;
+ }
+
+ private bool BlobEquals(int off, int len, byte[] chunk)
+ {
+ if (len != chunk.Length)
+ {
+ return false;
+ }
+ for (int i = 0; i < len; i++)
+ {
+ if (_blob[off + i] != chunk[i])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // ---- parsing -----------------------------------------------------------
+
+ /// Parse an arena from its on-disk bytes (spec Sections 6–8).
+ public static Arena Parse(byte[] bytes)
+ {
+ DcpHeader header = DcpHeader.Read(bytes);
+ if (header.ProfileVersionMajor != Constants.ProfileVersionMajor)
+ {
+ throw PcfDcpException.UnsupportedProfileMajor(header.ProfileVersionMajor);
+ }
+ ulong arenaUsed = header.ArenaUsed;
+
+ var arena = new Arena
+ {
+ ProfileVersionMajor = header.ProfileVersionMajor,
+ ProfileVersionMinor = header.ProfileVersionMinor,
+ Flags = header.Flags,
+ _blob = (byte[])bytes.Clone(),
+ _blobLen = bytes.Length,
+ };
+
+ bool firstBlock = true;
+ ulong off = header.InnerTableOffset;
+ int budget = bytes.Length / (int)Pcf.Constants.TableHeaderSize + 1;
+ while (off != Constants.ArenaNone)
+ {
+ if (budget == 0)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ budget -= 1;
+ int baseOff = checked((int)off);
+ if (baseOff + (int)Pcf.Constants.TableHeaderSize > bytes.Length)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ var hb = new byte[(int)Pcf.Constants.TableHeaderSize];
+ Buffer.BlockCopy(bytes, baseOff, hb, 0, hb.Length);
+ var h = TableBlockHeader.FromBytes(hb);
+ if (firstBlock)
+ {
+ arena._innerTableAlgo = h.TableHashAlgo;
+ firstBlock = false;
+ }
+ for (int i = 0; i < h.PartitionCount; i++)
+ {
+ int eo = baseOff + (int)Pcf.Constants.TableHeaderSize + i * (int)Pcf.Constants.EntrySize;
+ if (eo + (int)Pcf.Constants.EntrySize > bytes.Length)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ var eb = new byte[(int)Pcf.Constants.EntrySize];
+ Buffer.BlockCopy(bytes, eo, eb, 0, eb.Length);
+ var entry = PartitionEntry.FromBytes(eb);
+ var onDisk = FragmentTable.Walk(bytes, entry.StartOffset);
+ var frags = new List(onDisk.Count);
+ foreach (var fe in onDisk)
+ {
+ frags.Add(new Frag
+ {
+ Offset = checked((int)fe.ExtentOffset),
+ Length = checked((int)fe.ExtentLength),
+ Kind = fe.Kind,
+ Shared = fe.IsShared(),
+ });
+ }
+ arena._inners.Add(new Inner
+ {
+ PartitionType = entry.PartitionType,
+ Uid = entry.Uid,
+ Label = entry.Label,
+ DataHashAlgo = entry.DataHashAlgo,
+ Frags = frags,
+ });
+ }
+ off = h.NextTableOffset;
+ }
+
+ foreach (var inner in arena._inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ if ((ulong)(f.Offset + f.Length) > arenaUsed)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ }
+ }
+ return arena;
+ }
+
+ // ---- read-only views ---------------------------------------------------
+
+ /// Number of inner partitions.
+ public int Count => _inners.Count;
+
+ /// Whether the arena has no inner partitions.
+ public bool IsEmpty => _inners.Count == 0;
+
+ /// The uids of all inner partitions, in stored order.
+ public List Uids()
+ {
+ var outList = new List(_inners.Count);
+ foreach (var i in _inners)
+ {
+ outList.Add((byte[])i.Uid.Clone());
+ }
+ return outList;
+ }
+
+ private int IndexOf(byte[] uid)
+ {
+ for (int i = 0; i < _inners.Count; i++)
+ {
+ if (BytesEqual(_inners[i].Uid, uid))
+ {
+ return i;
+ }
+ }
+ throw PcfDcpException.NotFound();
+ }
+
+ private int InnerLogicalLen(Inner inner)
+ {
+ int total = 0;
+ foreach (var f in inner.Frags)
+ {
+ if (f.Kind == Constants.KindData)
+ {
+ total += f.Length;
+ }
+ }
+ return total;
+ }
+
+ private byte[] InnerContent(Inner inner)
+ {
+ var outBytes = new byte[InnerLogicalLen(inner)];
+ int p = 0;
+ foreach (var f in inner.Frags)
+ {
+ if (f.Kind == Constants.KindData)
+ {
+ Buffer.BlockCopy(_blob, f.Offset, outBytes, p, f.Length);
+ p += f.Length;
+ }
+ }
+ return outBytes;
+ }
+
+ private byte[] InnerDataHash(Inner inner) =>
+ inner.DataHashAlgo.Compute(InnerContent(inner));
+
+ private InnerInfo View(Inner inner)
+ {
+ var extents = new List(inner.Frags.Count);
+ foreach (var f in inner.Frags)
+ {
+ extents.Add(new ExtentInfo
+ {
+ ExtentOffset = (ulong)f.Offset,
+ ExtentLength = (ulong)f.Length,
+ Kind = f.Kind,
+ Shared = f.Shared,
+ });
+ }
+ return new InnerInfo
+ {
+ PartitionType = inner.PartitionType,
+ Uid = (byte[])inner.Uid.Clone(),
+ Label = PartitionEntry.DecodeLabel(inner.Label),
+ UsedBytes = (ulong)InnerLogicalLen(inner),
+ DataHashAlgo = inner.DataHashAlgo,
+ DataHash = InnerDataHash(inner),
+ Extents = extents,
+ };
+ }
+
+ /// A read-only view of one inner partition.
+ public InnerInfo GetInner(byte[] uid) => View(_inners[IndexOf(uid)]);
+
+ /// Read-only views of every inner partition, in stored order.
+ public List Inners()
+ {
+ var outList = new List(_inners.Count);
+ foreach (var i in _inners)
+ {
+ outList.Add(View(i));
+ }
+ return outList;
+ }
+
+ /// Reconstruct an inner partition's logical content (spec Section 8.3).
+ public byte[] Content(byte[] uid)
+ {
+ var inner = _inners[IndexOf(uid)];
+ var bytes = InnerContent(inner);
+ int declared = InnerLogicalLen(inner);
+ if (bytes.Length != declared)
+ {
+ throw PcfDcpException.LengthMismatch(declared, bytes.Length);
+ }
+ return bytes;
+ }
+
+ // ---- builder -----------------------------------------------------------
+
+ ///
+ /// Add an inner partition whose is split by
+ /// into extents, deduplicating against extents
+ /// already present (spec Section 10.2).
+ ///
+ public void AddInner(
+ uint partitionType, byte[] uid, string label, byte[] content,
+ HashAlgo dataHashAlgo, Chunker chunker)
+ {
+ if (partitionType == 0)
+ {
+ throw PcfDcpException.ReservedType();
+ }
+ if (partitionType == Constants.DcpContainerType)
+ {
+ throw PcfDcpException.NestedContainer();
+ }
+ if (BytesEqual(uid, Pcf.Constants.NilUid))
+ {
+ throw PcfDcpException.NilUid();
+ }
+ foreach (var i in _inners)
+ {
+ if (BytesEqual(i.Uid, uid))
+ {
+ throw PcfDcpException.DuplicateUid();
+ }
+ }
+ var labelBytes = PartitionEntry.EncodeLabel(label);
+
+ var frags = new List();
+ foreach (var chunk in SplitChunks(chunker, content))
+ {
+ var hit = FindExtent(chunk);
+ if (hit == null)
+ {
+ hit = FindLocal(frags, chunk);
+ }
+ if (hit != null)
+ {
+ int offset = hit.Value.Item1;
+ int length = hit.Value.Item2;
+ MarkShared(offset, length);
+ foreach (var f in frags)
+ {
+ if (f.Offset == offset && f.Length == length)
+ {
+ f.Shared = true;
+ }
+ }
+ frags.Add(new Frag { Offset = offset, Length = length, Kind = Constants.KindData, Shared = true });
+ }
+ else
+ {
+ int offset = AppendBlob(chunk);
+ frags.Add(new Frag { Offset = offset, Length = chunk.Length, Kind = Constants.KindData, Shared = false });
+ }
+ }
+ _inners.Add(new Inner
+ {
+ PartitionType = partitionType,
+ Uid = (byte[])uid.Clone(),
+ Label = labelBytes,
+ DataHashAlgo = dataHashAlgo,
+ Frags = frags,
+ });
+ }
+
+ private static IEnumerable SplitChunks(Chunker chunker, byte[] content)
+ {
+ if (content.Length == 0)
+ {
+ yield break;
+ }
+ if (chunker.IsWhole)
+ {
+ yield return content;
+ yield break;
+ }
+ for (int i = 0; i < content.Length; i += chunker.Size)
+ {
+ int len = Math.Min(chunker.Size, content.Length - i);
+ var chunk = new byte[len];
+ Buffer.BlockCopy(content, i, chunk, 0, len);
+ yield return chunk;
+ }
+ }
+
+ private (int, int)? FindExtent(byte[] chunk)
+ {
+ if (chunk.Length == 0)
+ {
+ return null;
+ }
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ if (f.Kind == Constants.KindData && f.Length == chunk.Length && BlobEquals(f.Offset, f.Length, chunk))
+ {
+ return (f.Offset, f.Length);
+ }
+ }
+ }
+ return null;
+ }
+
+ private (int, int)? FindLocal(List frags, byte[] chunk)
+ {
+ if (chunk.Length == 0)
+ {
+ return null;
+ }
+ foreach (var f in frags)
+ {
+ if (f.Kind == Constants.KindData && f.Length == chunk.Length && BlobEquals(f.Offset, f.Length, chunk))
+ {
+ return (f.Offset, f.Length);
+ }
+ }
+ return null;
+ }
+
+ private void MarkShared(int offset, int length)
+ {
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ if (f.Offset == offset && f.Length == length)
+ {
+ f.Shared = true;
+ }
+ }
+ }
+ }
+
+ // ---- logical edits (copy-on-write) -------------------------------------
+
+ /// Append to an inner partition's content.
+ public void Append(byte[] uid, byte[] bytes)
+ {
+ int idx = IndexOf(uid);
+ if (bytes.Length == 0)
+ {
+ return;
+ }
+ int offset = AppendBlob(bytes);
+ _inners[idx].Frags.Add(new Frag { Offset = offset, Length = bytes.Length, Kind = Constants.KindData, Shared = false });
+ }
+
+ /// Overwrite the logical range [pos, pos+len) with .
+ public void Overwrite(byte[] uid, int pos, int len, byte[] bytes)
+ {
+ Delete(uid, pos, len);
+ Insert(uid, pos, bytes);
+ }
+
+ /// Insert at logical position .
+ public void Insert(byte[] uid, int pos, byte[] bytes)
+ {
+ int idx = IndexOf(uid);
+ int total = InnerLogicalLen(_inners[idx]);
+ if (pos > total)
+ {
+ throw PcfDcpException.PositionOutOfRange();
+ }
+ if (bytes.Length == 0)
+ {
+ return;
+ }
+ int split = SplitAt(idx, pos);
+ int offset = AppendBlob(bytes);
+ _inners[idx].Frags.Insert(split, new Frag { Offset = offset, Length = bytes.Length, Kind = Constants.KindData, Shared = false });
+ }
+
+ /// Delete the logical range [pos, pos+len).
+ public void Delete(byte[] uid, int pos, int len)
+ {
+ int idx = IndexOf(uid);
+ int total = InnerLogicalLen(_inners[idx]);
+ int end = pos + len;
+ if (end > total)
+ {
+ throw PcfDcpException.PositionOutOfRange();
+ }
+ if (len == 0)
+ {
+ return;
+ }
+ int lo = SplitAt(idx, pos);
+ int hi = SplitAt(idx, end);
+ _inners[idx].Frags.RemoveRange(lo, hi - lo);
+ }
+
+ /// Truncate the partition's logical content to bytes.
+ public void Truncate(byte[] uid, int newLen)
+ {
+ int idx = IndexOf(uid);
+ int total = InnerLogicalLen(_inners[idx]);
+ if (newLen > total)
+ {
+ throw PcfDcpException.PositionOutOfRange();
+ }
+ int cut = SplitAt(idx, newLen);
+ var frags = _inners[idx].Frags;
+ if (cut < frags.Count)
+ {
+ frags.RemoveRange(cut, frags.Count - cut);
+ }
+ }
+
+ private int SplitAt(int idx, int pos)
+ {
+ var frags = _inners[idx].Frags;
+ int logical = 0;
+ int i = 0;
+ while (i < frags.Count)
+ {
+ var f = frags[i];
+ int flen = f.Length;
+ if (logical == pos)
+ {
+ return i;
+ }
+ if (pos < logical + flen)
+ {
+ int head = pos - logical;
+ var left = new Frag { Offset = f.Offset, Length = head, Kind = f.Kind, Shared = f.Shared };
+ var right = new Frag { Offset = f.Offset + head, Length = flen - head, Kind = f.Kind, Shared = f.Shared };
+ frags[i] = left;
+ frags.Insert(i + 1, right);
+ return i + 1;
+ }
+ logical += flen;
+ i += 1;
+ }
+ return frags.Count;
+ }
+
+ // ---- promotion support -------------------------------------------------
+
+ ///
+ /// Remove an inner partition, returning the pieces a promotion needs: its
+ /// type, label, hash algorithm, and reconstructed logical content.
+ ///
+ public (uint PartitionType, string Label, HashAlgo DataHashAlgo, byte[] Content) RemoveInner(byte[] uid)
+ {
+ int idx = IndexOf(uid);
+ var content = Content(uid);
+ var inner = _inners[idx];
+ _inners.RemoveAt(idx);
+ return (inner.PartitionType, PartitionEntry.DecodeLabel(inner.Label), inner.DataHashAlgo, content);
+ }
+
+ // ---- deduplication and compaction --------------------------------------
+
+ ///
+ /// Re-chunk every inner partition with and
+ /// deduplicate identical extents across the whole arena. Returns the
+ /// estimated number of bytes the pool shrank by once re-serialised.
+ ///
+ public long Dedup(Chunker chunker)
+ {
+ long before = CanonicalExtentBytes();
+ var rebuilt = new Arena
+ {
+ ProfileVersionMajor = ProfileVersionMajor,
+ ProfileVersionMinor = ProfileVersionMinor,
+ Flags = Flags,
+ _innerTableAlgo = _innerTableAlgo,
+ };
+ foreach (var inner in _inners)
+ {
+ rebuilt.AddInner(
+ inner.PartitionType, inner.Uid, PartitionEntry.DecodeLabel(inner.Label),
+ InnerContent(inner), inner.DataHashAlgo, chunker);
+ }
+ _blob = rebuilt._blob;
+ _blobLen = rebuilt._blobLen;
+ _inners.Clear();
+ _inners.AddRange(rebuilt._inners);
+ long after = CanonicalExtentBytes();
+ return Math.Max(0, before - after);
+ }
+
+ ///
+ /// Compact the arena (spec Section 10.3): drop unreferenced pool bytes and
+ /// normalise the SHARED flag, clearing it on any extent now referenced
+ /// exactly once (rule F2). Returns the number of dead pool bytes reclaimed.
+ ///
+ public long Compact()
+ {
+ var refcount = new Dictionary<(int, int), int>();
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ var k = (f.Offset, f.Length);
+ refcount[k] = refcount.TryGetValue(k, out int c) ? c + 1 : 1;
+ }
+ }
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ if (refcount[(f.Offset, f.Length)] <= 1)
+ {
+ f.Shared = false;
+ }
+ }
+ }
+ long liveBytes = 0;
+ foreach (var k in refcount.Keys)
+ {
+ liveBytes += k.Item2;
+ }
+ long deadBefore = Math.Max(0, _blobLen - liveBytes);
+
+ var newPool = new Arena();
+ var remap = new Dictionary<(int, int), int>();
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ var k = (f.Offset, f.Length);
+ if (!remap.ContainsKey(k))
+ {
+ var region = new byte[f.Length];
+ Buffer.BlockCopy(_blob, f.Offset, region, 0, f.Length);
+ remap[k] = newPool.AppendBlob(region);
+ }
+ }
+ }
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ f.Offset = remap[(f.Offset, f.Length)];
+ }
+ }
+ _blob = newPool._blob;
+ _blobLen = newPool._blobLen;
+ return deadBefore;
+ }
+
+ private long CanonicalExtentBytes()
+ {
+ var seen = new HashSet<(int, int)>();
+ long total = 0;
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ if (seen.Add((f.Offset, f.Length)))
+ {
+ total += f.Length;
+ }
+ }
+ }
+ return total;
+ }
+
+ // ---- canonical serialisation -------------------------------------------
+
+ /// Serialise the arena into its canonical on-disk layout (spec Section 17).
+ public byte[] ToBytes()
+ {
+ var extOrder = new List<(int, int)>();
+ var extIndex = new Dictionary<(int, int), int>();
+ foreach (var inner in _inners)
+ {
+ foreach (var f in inner.Frags)
+ {
+ var k = (f.Offset, f.Length);
+ if (!extIndex.ContainsKey(k))
+ {
+ extIndex[k] = extOrder.Count;
+ extOrder.Add(k);
+ }
+ }
+ }
+
+ int cur = Constants.DcpHeaderSize;
+ var extArenaOff = new int[extOrder.Count];
+ for (int i = 0; i < extOrder.Count; i++)
+ {
+ extArenaOff[i] = cur;
+ cur += extOrder[i].Item2;
+ }
+
+ var fragOff = new int[_inners.Count];
+ for (int ii = 0; ii < _inners.Count; ii++)
+ {
+ fragOff[ii] = cur;
+ cur += FragtableSpan(_inners[ii].Frags.Count);
+ }
+
+ int innerTableOffset = cur;
+ var counts = BlockCounts(_inners.Count);
+ var blockOff = new int[counts.Count];
+ for (int b = 0; b < counts.Count; b++)
+ {
+ blockOff[b] = cur;
+ cur += (int)Pcf.Constants.TableHeaderSize + counts[b] * (int)Pcf.Constants.EntrySize;
+ }
+ int arenaUsed = cur;
+
+ var buf = new byte[arenaUsed];
+
+ var header = new DcpHeader
+ {
+ ProfileVersionMajor = ProfileVersionMajor,
+ ProfileVersionMinor = ProfileVersionMinor,
+ Flags = Flags,
+ InnerTableOffset = (ulong)innerTableOffset,
+ ArenaUsed = (ulong)arenaUsed,
+ };
+ Buffer.BlockCopy(header.ToBytes(), 0, buf, 0, Constants.DcpHeaderSize);
+
+ for (int i = 0; i < extOrder.Count; i++)
+ {
+ Buffer.BlockCopy(_blob, extOrder[i].Item1, buf, extArenaOff[i], extOrder[i].Item2);
+ }
+
+ for (int ii = 0; ii < _inners.Count; ii++)
+ {
+ WriteFragmentTable(buf, fragOff[ii], _inners[ii].Frags, extIndex, extArenaOff);
+ }
+
+ var entries = new List(_inners.Count);
+ for (int ii = 0; ii < _inners.Count; ii++)
+ {
+ var inner = _inners[ii];
+ ulong used = (ulong)InnerLogicalLen(inner);
+ entries.Add(new PartitionEntry
+ {
+ PartitionType = inner.PartitionType,
+ Uid = (byte[])inner.Uid.Clone(),
+ Label = (byte[])inner.Label.Clone(),
+ StartOffset = (ulong)fragOff[ii],
+ MaxLength = used,
+ UsedBytes = used,
+ DataHashAlgo = inner.DataHashAlgo,
+ DataHash = InnerDataHash(inner),
+ });
+ }
+
+ int idx = 0;
+ for (int b = 0; b < counts.Count; b++)
+ {
+ int c = counts[b];
+ ulong next = b + 1 < counts.Count ? (ulong)blockOff[b + 1] : 0;
+ var slice = entries.GetRange(idx, c);
+ var th = TableBlockHeader.ComputeTableHash(_innerTableAlgo, next, slice);
+ var bh = new TableBlockHeader
+ {
+ PartitionCount = (byte)c,
+ NextTableOffset = next,
+ TableHashAlgo = _innerTableAlgo,
+ TableHash = th,
+ };
+ int p = blockOff[b];
+ Buffer.BlockCopy(bh.ToBytes(), 0, buf, p, (int)Pcf.Constants.TableHeaderSize);
+ p += (int)Pcf.Constants.TableHeaderSize;
+ foreach (var e in slice)
+ {
+ Buffer.BlockCopy(e.ToBytes(), 0, buf, p, (int)Pcf.Constants.EntrySize);
+ p += (int)Pcf.Constants.EntrySize;
+ }
+ idx += c;
+ }
+
+ return buf;
+ }
+
+ private static int FragtableSpan(int n)
+ {
+ int span = 0;
+ foreach (int c in BlockCounts(n))
+ {
+ span += Constants.FragTableHeaderSize + c * Constants.FragmentEntrySize;
+ }
+ return span;
+ }
+
+ private static List BlockCounts(int n)
+ {
+ if (n == 0)
+ {
+ return new List { 0 };
+ }
+ var outList = new List();
+ int rem = n;
+ while (rem > 0)
+ {
+ int c = Math.Min(rem, Constants.MaxEntriesPerBlock);
+ outList.Add(c);
+ rem -= c;
+ }
+ return outList;
+ }
+
+ private static void WriteFragmentTable(
+ byte[] buf, int start, List frags,
+ Dictionary<(int, int), int> extIndex, int[] extArenaOff)
+ {
+ var counts = BlockCounts(frags.Count);
+ int blockStart = start;
+ int idx = 0;
+ for (int b = 0; b < counts.Count; b++)
+ {
+ int c = counts[b];
+ int span = Constants.FragTableHeaderSize + c * Constants.FragmentEntrySize;
+ ulong next = b + 1 < counts.Count ? (ulong)(blockStart + span) : 0;
+ var fh = new FragTableHeader { NextFragtableOffset = next, FragmentCount = (byte)c };
+ Buffer.BlockCopy(fh.ToBytes(), 0, buf, blockStart, Constants.FragTableHeaderSize);
+ for (int j = 0; j < c; j++)
+ {
+ var f = frags[idx + j];
+ int arenaOff = extArenaOff[extIndex[(f.Offset, f.Length)]];
+ var fe = new FragmentEntry
+ {
+ ExtentOffset = (ulong)arenaOff,
+ ExtentLength = (ulong)f.Length,
+ Kind = f.Kind,
+ Flags = f.Shared ? Constants.FlagShared : (byte)0,
+ };
+ Buffer.BlockCopy(fe.ToBytes(), 0, buf, blockStart + Constants.FragTableHeaderSize + j * Constants.FragmentEntrySize, Constants.FragmentEntrySize);
+ }
+ blockStart += span;
+ idx += c;
+ }
+ }
+
+ internal static bool BytesEqual(byte[] a, byte[] b)
+ {
+ if (a.Length != b.Length)
+ {
+ return false;
+ }
+ for (int i = 0; i < a.Length; i++)
+ {
+ if (a[i] != b[i])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Chunker.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Chunker.cs
new file mode 100644
index 0000000..f4260da
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Chunker.cs
@@ -0,0 +1,26 @@
+namespace Pcf.Dcp;
+
+///
+/// How a Writer splits an inner partition's content into extents (spec Section
+/// 10.2; chunking is writer-side policy).
+///
+public sealed class Chunker
+{
+ /// Whether this chunker emits one extent for the whole content.
+ public bool IsWhole { get; }
+
+ /// Fixed chunk size in bytes (meaningful only when not whole).
+ public int Size { get; }
+
+ private Chunker(bool whole, int size)
+ {
+ IsWhole = whole;
+ Size = size;
+ }
+
+ /// One extent for the whole content.
+ public static Chunker Whole() => new Chunker(true, 0);
+
+ /// Fixed-size chunks of bytes (0 = whole).
+ public static Chunker Fixed(int n) => new Chunker(n <= 0, n);
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Constants.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Constants.cs
new file mode 100644
index 0000000..a0ddd45
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Constants.cs
@@ -0,0 +1,56 @@
+namespace Pcf.Dcp;
+
+///
+/// On-disk constants defined by PCF-DCP v1.0 (spec Appendix A and B). Every
+/// value here is normative.
+///
+public static class Constants
+{
+ /// PCF partition type carrying one DCP arena.
+ public const uint DcpContainerType = 0xAAAC_0001;
+
+ /// First value reserved by this profile for future types.
+ public const uint DcpTypeReservedLo = 0xAAAC_0000;
+
+ /// Last value reserved by this profile.
+ public const uint DcpTypeReservedHi = 0xAAAC_00FF;
+
+ /// 4-byte magic at the start of a DCP arena: "PDCP".
+ public static readonly byte[] DcpMagic = { 0x50, 0x44, 0x43, 0x50 };
+
+ /// PCF-DCP profile version implemented by this library (major).
+ public const byte ProfileVersionMajor = 1;
+
+ /// PCF-DCP profile version implemented by this library (minor).
+ public const byte ProfileVersionMinor = 0;
+
+ /// Fixed size of the DCP Header, in bytes (spec Section 6).
+ public const int DcpHeaderSize = 24;
+
+ /// Fixed size of a Fragment Table block header, in bytes.
+ public const int FragTableHeaderSize = 9;
+
+ /// Fixed size of one Fragment Entry, in bytes.
+ public const int FragmentEntrySize = 18;
+
+ /// Fragment Entry kind: RESERVED / INVALID guard.
+ public const byte KindInvalid = 0;
+
+ /// Fragment Entry kind: DATA — literal content (only kind in v1.0).
+ public const byte KindData = 1;
+
+ /// Fragment Entry kind: HOLE (RESERVED).
+ public const byte KindHole = 2;
+
+ /// Fragment Entry kind: REF (RESERVED).
+ public const byte KindRef = 3;
+
+ /// Fragment Entry flags bit 0: SHARED (copy-on-write required).
+ public const byte FlagShared = 0x01;
+
+ /// The arena-relative offset value reserved as "none" / terminator.
+ public const ulong ArenaNone = 0;
+
+ /// Max entries per (inner) Table Block and extents per Fragment block.
+ public const int MaxEntriesPerBlock = 255;
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpHeader.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpHeader.cs
new file mode 100644
index 0000000..17b03b2
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpHeader.cs
@@ -0,0 +1,67 @@
+using System;
+
+namespace Pcf.Dcp;
+
+/// The fixed 24-byte DCP Header at arena offset 0 (spec Section 6).
+public sealed class DcpHeader
+{
+ /// PCF-DCP profile major version.
+ public byte ProfileVersionMajor { get; set; }
+
+ /// PCF-DCP profile minor version.
+ public byte ProfileVersionMinor { get; set; }
+
+ /// Reserved; MUST be 0 in v1.0.
+ public ushort Flags { get; set; }
+
+ /// Arena-relative offset of the first Inner Table Block (0 = none).
+ public ulong InnerTableOffset { get; set; }
+
+ /// Bump pointer: arena-relative offset of the first free byte.
+ public ulong ArenaUsed { get; set; }
+
+ /// Serialise to the on-disk 24-byte layout.
+ public byte[] ToBytes()
+ {
+ var b = new byte[Constants.DcpHeaderSize];
+ Buffer.BlockCopy(Constants.DcpMagic, 0, b, 0, 4);
+ b[4] = ProfileVersionMajor;
+ b[5] = ProfileVersionMinor;
+ LittleEndian.WriteU16(b, 6, Flags);
+ LittleEndian.WriteU64(b, 8, InnerTableOffset);
+ LittleEndian.WriteU64(b, 16, ArenaUsed);
+ return b;
+ }
+
+ /// Parse from the on-disk 24-byte layout, validating the magic.
+ public static DcpHeader FromBytes(byte[] b)
+ {
+ for (int i = 0; i < 4; i++)
+ {
+ if (b[i] != Constants.DcpMagic[i])
+ {
+ throw PcfDcpException.BadDcpMagic();
+ }
+ }
+ return new DcpHeader
+ {
+ ProfileVersionMajor = b[4],
+ ProfileVersionMinor = b[5],
+ Flags = LittleEndian.ReadU16(b, 6),
+ InnerTableOffset = LittleEndian.ReadU64(b, 8),
+ ArenaUsed = LittleEndian.ReadU64(b, 16),
+ };
+ }
+
+ /// Read a DCP Header from the start of an arena byte array.
+ public static DcpHeader Read(byte[] arena)
+ {
+ if (arena.Length < Constants.DcpHeaderSize)
+ {
+ throw PcfDcpException.BadDcpMagic();
+ }
+ var fixedBytes = new byte[Constants.DcpHeaderSize];
+ Buffer.BlockCopy(arena, 0, fixedBytes, 0, Constants.DcpHeaderSize);
+ return FromBytes(fixedBytes);
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpReader.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpReader.cs
new file mode 100644
index 0000000..1148eb0
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpReader.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Pcf;
+
+namespace Pcf.Dcp;
+
+/// An inner partition together with the container that holds it.
+public sealed class InnerLocation
+{
+ /// uid of the enclosing DCP container partition.
+ public byte[] ContainerUid { get; set; }
+
+ /// The inner partition's metadata and extents.
+ public InnerInfo Info { get; set; }
+}
+
+/// The result of resolving a uid against top-level ∪ inner (spec 2.1).
+public sealed class Resolved
+{
+ /// Whether the uid resolved to a top-level PCF partition.
+ public bool IsTopLevel { get; set; }
+
+ /// The top-level entry (when is true).
+ public PartitionEntry Entry { get; set; }
+
+ /// The inner partition location (when is false).
+ public InnerLocation Inner { get; set; }
+}
+
+///
+/// A reader for DCP containers layered over a PCF file. It works entirely
+/// through the high-level API, so a DCP file written in
+/// trailer mode reads back transparently.
+///
+public sealed class DcpReader
+{
+ private readonly Container _c;
+
+ private DcpReader(Container c)
+ {
+ _c = c;
+ }
+
+ /// Open a PCF file for DCP-aware reading.
+ public static DcpReader Open(Stream storage) => new DcpReader(Container.Open(storage));
+
+ /// Borrow the underlying PCF container.
+ public Container Container => _c;
+
+ /// All top-level entries, in chain order.
+ public List Entries() => _c.Entries();
+
+ /// The top-level DCP container entries.
+ public List Containers()
+ {
+ var outList = new List();
+ foreach (var e in _c.Entries())
+ {
+ if (e.PartitionType == Constants.DcpContainerType)
+ {
+ outList.Add(e);
+ }
+ }
+ return outList;
+ }
+
+ /// Parse the arena of a DCP container entry.
+ public Arena OpenArena(PartitionEntry entry)
+ {
+ if (entry.PartitionType != Constants.DcpContainerType)
+ {
+ throw PcfDcpException.NotADcpContainer();
+ }
+ return Arena.Parse(_c.ReadPartitionData(entry));
+ }
+
+ /// Every inner partition across every DCP container, in file order.
+ public List InnerPartitions()
+ {
+ var outList = new List();
+ foreach (var cont in Containers())
+ {
+ var arena = OpenArena(cont);
+ foreach (var info in arena.Inners())
+ {
+ outList.Add(new InnerLocation { ContainerUid = (byte[])cont.Uid.Clone(), Info = info });
+ }
+ }
+ return outList;
+ }
+
+ /// Resolve a uid against the flattened set top-level ∪ inner (spec 2.1).
+ public Resolved ResolveUid(byte[] uid)
+ {
+ foreach (var e in _c.Entries())
+ {
+ if (Arena.BytesEqual(e.Uid, uid))
+ {
+ return new Resolved { IsTopLevel = true, Entry = e };
+ }
+ }
+ foreach (var loc in InnerPartitions())
+ {
+ if (Arena.BytesEqual(loc.Info.Uid, uid))
+ {
+ return new Resolved { IsTopLevel = false, Inner = loc };
+ }
+ }
+ throw PcfDcpException.NotFound();
+ }
+
+ /// Reconstruct an inner partition's logical content by uid.
+ public byte[] ReadInner(byte[] uid)
+ {
+ foreach (var cont in Containers())
+ {
+ var arena = OpenArena(cont);
+ foreach (var u in arena.Uids())
+ {
+ if (Arena.BytesEqual(u, uid))
+ {
+ return arena.Content(uid);
+ }
+ }
+ }
+ throw PcfDcpException.NotFound();
+ }
+
+ ///
+ /// Full DCP-aware verification: PCF integrity, each inner Table Block's
+ /// table_hash, reconstruction length and (when algorithmic) data_hash, no
+ /// nested container, and file-wide uid uniqueness.
+ ///
+ public void Verify()
+ {
+ _c.Verify();
+
+ var seen = new HashSet();
+ foreach (var e in _c.Entries())
+ {
+ if (!seen.Add(Hex(e.Uid)))
+ {
+ throw PcfDcpException.DuplicateUid();
+ }
+ }
+
+ foreach (var cont in Containers())
+ {
+ var data = _c.ReadPartitionData(cont);
+ VerifyInnerTableHashes(data);
+
+ var arena = Arena.Parse(data);
+ foreach (var info in arena.Inners())
+ {
+ if (info.PartitionType == Constants.DcpContainerType)
+ {
+ throw PcfDcpException.NestedContainer();
+ }
+ if (!seen.Add(Hex(info.Uid)))
+ {
+ throw PcfDcpException.DuplicateUid();
+ }
+ var content = arena.Content(info.Uid);
+ if ((ulong)content.Length != info.UsedBytes)
+ {
+ throw PcfDcpException.LengthMismatch((long)info.UsedBytes, content.Length);
+ }
+ if (!info.DataHashAlgo.Verify(content, info.DataHash))
+ {
+ throw PcfDcpException.HashMismatch();
+ }
+ }
+ }
+ }
+
+ private static void VerifyInnerTableHashes(byte[] arena)
+ {
+ DcpHeader header = DcpHeader.Read(arena);
+ ulong off = header.InnerTableOffset;
+ int budget = arena.Length / (int)Pcf.Constants.TableHeaderSize + 1;
+ while (off != 0)
+ {
+ if (budget == 0)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ budget -= 1;
+ int baseOff = checked((int)off);
+ if (baseOff + (int)Pcf.Constants.TableHeaderSize > arena.Length)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ var hb = new byte[(int)Pcf.Constants.TableHeaderSize];
+ Buffer.BlockCopy(arena, baseOff, hb, 0, hb.Length);
+ var h = TableBlockHeader.FromBytes(hb);
+ var entries = new List(h.PartitionCount);
+ for (int i = 0; i < h.PartitionCount; i++)
+ {
+ int eo = baseOff + (int)Pcf.Constants.TableHeaderSize + i * (int)Pcf.Constants.EntrySize;
+ if (eo + (int)Pcf.Constants.EntrySize > arena.Length)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ var eb = new byte[(int)Pcf.Constants.EntrySize];
+ Buffer.BlockCopy(arena, eo, eb, 0, eb.Length);
+ entries.Add(PartitionEntry.FromBytes(eb));
+ }
+ if (h.TableHashAlgo.Verifies())
+ {
+ var computed = TableBlockHeader.ComputeTableHash(h.TableHashAlgo, h.NextTableOffset, entries);
+ int n = h.TableHashAlgo.DigestLen();
+ for (int i = 0; i < n; i++)
+ {
+ if (computed[i] != h.TableHash[i])
+ {
+ throw PcfDcpException.HashMismatch();
+ }
+ }
+ }
+ off = h.NextTableOffset;
+ }
+ }
+
+ private static string Hex(byte[] b)
+ {
+ var sb = new StringBuilder(b.Length * 2);
+ foreach (var x in b)
+ {
+ sb.Append(x.ToString("x2"));
+ }
+ return sb.ToString();
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpWriter.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpWriter.cs
new file mode 100644
index 0000000..2f13f39
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/DcpWriter.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Pcf;
+
+namespace Pcf.Dcp;
+
+///
+/// Building and rewriting PCF files that carry DCP containers. The writer keeps
+/// the whole file as an in-memory list of top-level partitions and emits a
+/// fresh, canonical PCF image on demand. Every mutating operation is a logical
+/// edit of that list followed by a rebuild — simple and always correct for a
+/// reference implementation; the result is a fully conforming PCF v1.0 file.
+///
+public sealed class DcpWriter
+{
+ private sealed class TopPart
+ {
+ public uint PartitionType;
+ public byte[] Uid;
+ public string Label;
+ public HashAlgo DataHashAlgo;
+ public byte[] PlainData; // non-null for a plain partition
+ public Arena Arena; // non-null for a DCP container
+ }
+
+ private readonly List _parts = new List();
+ private readonly HashAlgo _tableHashAlgo = HashAlgo.Sha256;
+ private bool _trailer;
+
+ /// Load an existing PCF file into the writer's model.
+ public static DcpWriter Open(Stream storage)
+ {
+ var c = Container.Open(storage);
+ var w = new DcpWriter();
+ foreach (var e in c.Entries())
+ {
+ var data = c.ReadPartitionData(e);
+ var label = PartitionEntry.DecodeLabel(e.Label);
+ var part = new TopPart
+ {
+ PartitionType = e.PartitionType,
+ Uid = (byte[])e.Uid.Clone(),
+ Label = label,
+ DataHashAlgo = e.DataHashAlgo,
+ };
+ if (e.PartitionType == Constants.DcpContainerType)
+ {
+ part.Arena = Arena.Parse(data);
+ }
+ else
+ {
+ part.PlainData = data;
+ }
+ w._parts.Add(part);
+ }
+ return w;
+ }
+
+ /// Finalise emitted images in trailer mode (append-only host).
+ public void SetTrailer(bool on) => _trailer = on;
+
+ private void EnsureUnique(byte[] uid)
+ {
+ foreach (var p in _parts)
+ {
+ if (Arena.BytesEqual(p.Uid, uid))
+ {
+ throw PcfDcpException.DuplicateUid();
+ }
+ }
+ }
+
+ /// Add a DCP container partition holding .
+ public void AddContainer(byte[] uid, string label, Arena arena)
+ {
+ EnsureUnique(uid);
+ _parts.Add(new TopPart
+ {
+ PartitionType = Constants.DcpContainerType,
+ Uid = (byte[])uid.Clone(),
+ Label = label,
+ DataHashAlgo = HashAlgo.None,
+ Arena = arena,
+ });
+ }
+
+ /// Add an ordinary top-level partition.
+ public void AddPlain(uint partitionType, byte[] uid, string label, byte[] data, HashAlgo dataHashAlgo)
+ {
+ EnsureUnique(uid);
+ _parts.Add(new TopPart
+ {
+ PartitionType = partitionType,
+ Uid = (byte[])uid.Clone(),
+ Label = label,
+ DataHashAlgo = dataHashAlgo,
+ PlainData = data,
+ });
+ }
+
+ private Arena ContainerArena(byte[] uid)
+ {
+ foreach (var p in _parts)
+ {
+ if (Arena.BytesEqual(p.Uid, uid))
+ {
+ if (p.Arena == null)
+ {
+ throw PcfDcpException.NotADcpContainer();
+ }
+ return p.Arena;
+ }
+ }
+ throw PcfDcpException.NotFound();
+ }
+
+ /// Borrow a container's arena for inspection or in-place editing.
+ public Arena GetArena(byte[] containerUid) => ContainerArena(containerUid);
+
+ // ---- migration: promotion / demotion -----------------------------------
+
+ ///
+ /// Promote an inner partition out of its DCP container to a top-level PCF
+ /// partition (dynamic → fixed), preserving uid, type, label, hash algorithm
+ /// and data_hash (the promotion invariant, spec Section 10.4).
+ ///
+ public void Promote(byte[] containerUid, byte[] innerUid)
+ {
+ var arena = ContainerArena(containerUid);
+ var piece = arena.RemoveInner(innerUid);
+ _parts.Add(new TopPart
+ {
+ PartitionType = piece.PartitionType,
+ Uid = (byte[])innerUid.Clone(),
+ Label = piece.Label,
+ DataHashAlgo = piece.DataHashAlgo,
+ PlainData = piece.Content,
+ });
+ }
+
+ ///
+ /// Demote a top-level partition into a DCP container as an inner partition
+ /// (fixed → dynamic), preserving uid, type, label, hash algorithm and
+ /// data_hash. The content becomes a single DATA extent.
+ ///
+ public void Demote(byte[] partUid, byte[] containerUid)
+ {
+ int pos = -1;
+ for (int i = 0; i < _parts.Count; i++)
+ {
+ if (Arena.BytesEqual(_parts[i].Uid, partUid))
+ {
+ pos = i;
+ break;
+ }
+ }
+ if (pos < 0)
+ {
+ throw PcfDcpException.NotFound();
+ }
+ var p = _parts[pos];
+ if (p.PartitionType == Constants.DcpContainerType || p.PlainData == null)
+ {
+ throw PcfDcpException.NestedContainer();
+ }
+ var arena = ContainerArena(containerUid);
+ arena.AddInner(p.PartitionType, partUid, p.Label, p.PlainData, p.DataHashAlgo, Chunker.Whole());
+ _parts.RemoveAt(pos);
+ }
+
+ // ---- container-level maintenance ---------------------------------------
+
+ /// Re-chunk and deduplicate a container's inner partitions.
+ public long Dedup(byte[] containerUid, Chunker chunker) => ContainerArena(containerUid).Dedup(chunker);
+
+ /// Compact / defragment a container's arena. Returns bytes reclaimed.
+ public long Defrag(byte[] containerUid) => ContainerArena(containerUid).Compact();
+
+ // ---- serialisation -----------------------------------------------------
+
+ /// Build a fresh, canonical PCF image of the whole file.
+ public byte[] ToImage()
+ {
+ uint cap = (uint)Math.Max(1, _parts.Count);
+ var stream = new MemoryStream();
+ var c = Container.CreateWith(stream, cap, _tableHashAlgo);
+ foreach (var p in _parts)
+ {
+ byte[] data = p.Arena != null ? p.Arena.ToBytes() : p.PlainData;
+ c.AddPartition(p.PartitionType, p.Uid, p.Label, data, 0, p.DataHashAlgo);
+ }
+ if (_trailer)
+ {
+ c.FinalizeWithTrailer();
+ }
+ return ((MemoryStream)c.Storage).ToArray();
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/FragTableHeader.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/FragTableHeader.cs
new file mode 100644
index 0000000..e697e47
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/FragTableHeader.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+
+namespace Pcf.Dcp;
+
+/// The 9-byte header that begins each Fragment Table block (spec 8.1).
+public sealed class FragTableHeader
+{
+ /// Arena-relative offset of the next block of this partition, or 0.
+ public ulong NextFragtableOffset { get; set; }
+
+ /// Number of Fragment Entries packed immediately after this header.
+ public byte FragmentCount { get; set; }
+
+ /// Serialise to the on-disk 9-byte layout.
+ public byte[] ToBytes()
+ {
+ var b = new byte[Constants.FragTableHeaderSize];
+ LittleEndian.WriteU64(b, 0, NextFragtableOffset);
+ b[8] = FragmentCount;
+ return b;
+ }
+
+ /// Parse from the on-disk 9-byte layout.
+ public static FragTableHeader FromBytes(byte[] b, int offset = 0)
+ {
+ return new FragTableHeader
+ {
+ NextFragtableOffset = LittleEndian.ReadU64(b, offset + 0),
+ FragmentCount = b[offset + 8],
+ };
+ }
+}
+
+/// Static helpers for walking and reconstructing Fragment Tables.
+public static class FragmentTable
+{
+ ///
+ /// Walk an inner partition's Fragment Table chain starting at arena-relative
+ /// , returning its entries in logical order.
+ ///
+ public static List Walk(byte[] arena, ulong firstOff)
+ {
+ var outList = new List();
+ ulong off = firstOff;
+ int budget = arena.Length / Constants.FragTableHeaderSize + 1;
+ while (off != Constants.ArenaNone)
+ {
+ if (budget == 0)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ budget -= 1;
+ int baseOff = checked((int)off);
+ if (baseOff + Constants.FragTableHeaderSize > arena.Length)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ var h = FragTableHeader.FromBytes(arena, baseOff);
+ int eo = baseOff + Constants.FragTableHeaderSize;
+ for (int i = 0; i < h.FragmentCount; i++)
+ {
+ if (eo + Constants.FragmentEntrySize > arena.Length)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ outList.Add(FragmentEntry.FromBytes(arena, eo));
+ eo += Constants.FragmentEntrySize;
+ }
+ off = h.NextFragtableOffset;
+ }
+ return outList;
+ }
+
+ ///
+ /// Reconstruct the logical content from Fragment Entries (spec Section 8.3):
+ /// concatenate the bytes of the DATA extents in order.
+ ///
+ public static byte[] Reconstruct(byte[] arena, IReadOnlyList frags, ulong arenaUsed)
+ {
+ long total = 0;
+ foreach (var f in frags)
+ {
+ if (!f.IsData())
+ {
+ throw PcfDcpException.BadFragmentKind(f.Kind);
+ }
+ ulong end = f.ExtentOffset + f.ExtentLength;
+ if (end > arenaUsed || end > (ulong)arena.Length)
+ {
+ throw PcfDcpException.OffsetOutOfRange();
+ }
+ total += (long)f.ExtentLength;
+ }
+ var outBytes = new byte[total];
+ int p = 0;
+ foreach (var f in frags)
+ {
+ Buffer.BlockCopy(arena, (int)f.ExtentOffset, outBytes, p, (int)f.ExtentLength);
+ p += (int)f.ExtentLength;
+ }
+ return outBytes;
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/FragmentEntry.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/FragmentEntry.cs
new file mode 100644
index 0000000..ae83847
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/FragmentEntry.cs
@@ -0,0 +1,46 @@
+namespace Pcf.Dcp;
+
+/// One Fragment Entry: a single extent of an inner partition (spec 8.2).
+public sealed class FragmentEntry
+{
+ /// Arena-relative start of the extent's bytes.
+ public ulong ExtentOffset { get; set; }
+
+ /// Length of the extent in bytes.
+ public ulong ExtentLength { get; set; }
+
+ /// Extent kind (1 = DATA; 0 invalid; 2/3 reserved).
+ public byte Kind { get; set; }
+
+ /// flags byte (bit 0 = SHARED; others reserved 0).
+ public byte Flags { get; set; }
+
+ /// Serialise to the on-disk 18-byte layout.
+ public byte[] ToBytes()
+ {
+ var b = new byte[Constants.FragmentEntrySize];
+ LittleEndian.WriteU64(b, 0, ExtentOffset);
+ LittleEndian.WriteU64(b, 8, ExtentLength);
+ b[16] = Kind;
+ b[17] = Flags;
+ return b;
+ }
+
+ /// Parse from the on-disk 18-byte layout.
+ public static FragmentEntry FromBytes(byte[] b, int offset = 0)
+ {
+ return new FragmentEntry
+ {
+ ExtentOffset = LittleEndian.ReadU64(b, offset + 0),
+ ExtentLength = LittleEndian.ReadU64(b, offset + 8),
+ Kind = b[offset + 16],
+ Flags = b[offset + 17],
+ };
+ }
+
+ /// Whether this entry's kind is DATA.
+ public bool IsData() => Kind == Constants.KindData;
+
+ /// Whether the SHARED flag (bit 0) is set.
+ public bool IsShared() => (Flags & Constants.FlagShared) != 0;
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/InnerInfo.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/InnerInfo.cs
new file mode 100644
index 0000000..0d00a82
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/InnerInfo.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+using Pcf;
+
+namespace Pcf.Dcp;
+
+/// A read-only view of one extent, for tooling and tests.
+public sealed class ExtentInfo
+{
+ /// Arena/pool-relative offset of the extent.
+ public ulong ExtentOffset { get; set; }
+
+ /// Length of the extent in bytes.
+ public ulong ExtentLength { get; set; }
+
+ /// Extent kind (1 = DATA).
+ public byte Kind { get; set; }
+
+ /// Whether the SHARED flag is set.
+ public bool Shared { get; set; }
+}
+
+/// A read-only view of one inner partition.
+public sealed class InnerInfo
+{
+ /// Application partition type.
+ public uint PartitionType { get; set; }
+
+ /// 16-byte uid (unique file-wide).
+ public byte[] Uid { get; set; }
+
+ /// Decoded label.
+ public string Label { get; set; }
+
+ /// Logical content length (= used_bytes).
+ public ulong UsedBytes { get; set; }
+
+ /// Hash algorithm protecting the logical content.
+ public HashAlgo DataHashAlgo { get; set; }
+
+ /// The 64-byte data-hash field over the logical content.
+ public byte[] DataHash { get; set; }
+
+ /// The partition's extents in logical order.
+ public List Extents { get; set; }
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/LittleEndian.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/LittleEndian.cs
new file mode 100644
index 0000000..f36d93d
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/LittleEndian.cs
@@ -0,0 +1,35 @@
+namespace Pcf.Dcp;
+
+///
+/// Explicit little-endian integer helpers. PCF mandates little-endian for every
+/// multi-byte integer; reading/writing the bytes by hand keeps the encoding
+/// independent of the host's native byte order.
+///
+internal static class LittleEndian
+{
+ public static void WriteU16(byte[] b, int o, ushort v)
+ {
+ b[o] = (byte)(v & 0xFF);
+ b[o + 1] = (byte)((v >> 8) & 0xFF);
+ }
+
+ public static void WriteU64(byte[] b, int o, ulong v)
+ {
+ for (int i = 0; i < 8; i++)
+ {
+ b[o + i] = (byte)((v >> (8 * i)) & 0xFF);
+ }
+ }
+
+ public static ushort ReadU16(byte[] b, int o) => (ushort)(b[o] | (b[o + 1] << 8));
+
+ public static ulong ReadU64(byte[] b, int o)
+ {
+ ulong v = 0;
+ for (int i = 0; i < 8; i++)
+ {
+ v |= (ulong)b[o + i] << (8 * i);
+ }
+ return v;
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Pcf.Dcp.csproj b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Pcf.Dcp.csproj
new file mode 100644
index 0000000..27af897
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/Pcf.Dcp.csproj
@@ -0,0 +1,30 @@
+
+
+
+ netstandard2.0
+ latest
+ disable
+ disable
+ Pcf.Dcp
+ Pcf.Dcp
+ true
+ Reader/writer for PCF-DCP v1.0, the PCF Dynamic Container Partition profile.
+
+ KDuma.Pcf.Dcp
+ pcf;pcf-dcp;container;deduplication;fragmentation
+ README.md
+ true
+ snupkg
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/PcfDcpException.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/PcfDcpException.cs
new file mode 100644
index 0000000..796387a
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/PcfDcpException.cs
@@ -0,0 +1,90 @@
+using System;
+
+namespace Pcf.Dcp;
+
+/// Discriminant identifying which kind of occurred.
+public enum PcfDcpErrorKind
+{
+ /// The arena did not begin with the "PDCP" magic.
+ BadDcpMagic,
+ /// The arena's profile major version is not implemented.
+ UnsupportedProfileMajor,
+ /// A Fragment Entry carried an unsupported kind (HOLE/REF/unknown).
+ BadFragmentKind,
+ /// An extent range escapes [0, arena_used).
+ OffsetOutOfRange,
+ /// Reconstructed length did not match used_bytes, or a hash failed.
+ LengthMismatch,
+ /// A stored hash (inner table_hash or data_hash) did not verify.
+ HashMismatch,
+ /// No inner (or top-level) partition with the requested uid.
+ NotFound,
+ /// A uid is used by more than one partition file-wide.
+ DuplicateUid,
+ /// An inner partition is itself a DCP container (nesting forbidden).
+ NestedContainer,
+ /// A partition uid is the PCF NIL uid.
+ NilUid,
+ /// A partition type is the PCF reserved type 0x00000000.
+ ReservedType,
+ /// A top-level partition expected to be a DCP container is not one.
+ NotADcpContainer,
+ /// A logical edit addressed a position beyond the partition's content.
+ PositionOutOfRange,
+}
+
+/// All ways a PCF-DCP operation can fail.
+public sealed class PcfDcpException : Exception
+{
+ /// The kind of failure.
+ public PcfDcpErrorKind Kind { get; }
+
+ private PcfDcpException(PcfDcpErrorKind kind, string message) : base(message)
+ {
+ Kind = kind;
+ }
+
+ internal static PcfDcpException BadDcpMagic() =>
+ new PcfDcpException(PcfDcpErrorKind.BadDcpMagic, "arena does not begin with \"PDCP\" magic");
+
+ internal static PcfDcpException UnsupportedProfileMajor(int v) =>
+ new PcfDcpException(PcfDcpErrorKind.UnsupportedProfileMajor,
+ $"unsupported PCF-DCP profile major version {v}");
+
+ internal static PcfDcpException BadFragmentKind(int k) =>
+ new PcfDcpException(PcfDcpErrorKind.BadFragmentKind, $"unsupported fragment kind {k}");
+
+ internal static PcfDcpException OffsetOutOfRange() =>
+ new PcfDcpException(PcfDcpErrorKind.OffsetOutOfRange, "extent range escapes the arena");
+
+ internal static PcfDcpException LengthMismatch(long expected, long got) =>
+ new PcfDcpException(PcfDcpErrorKind.LengthMismatch,
+ $"logical length mismatch: expected {expected}, got {got}");
+
+ internal static PcfDcpException HashMismatch() =>
+ new PcfDcpException(PcfDcpErrorKind.HashMismatch, "stored hash does not verify");
+
+ internal static PcfDcpException NotFound() =>
+ new PcfDcpException(PcfDcpErrorKind.NotFound, "no partition with that uid");
+
+ internal static PcfDcpException DuplicateUid() =>
+ new PcfDcpException(PcfDcpErrorKind.DuplicateUid, "uid is not unique file-wide");
+
+ internal static PcfDcpException NestedContainer() =>
+ new PcfDcpException(PcfDcpErrorKind.NestedContainer,
+ "an inner partition may not be a DCP container");
+
+ internal static PcfDcpException NilUid() =>
+ new PcfDcpException(PcfDcpErrorKind.NilUid, "uid is the NIL uid");
+
+ internal static PcfDcpException ReservedType() =>
+ new PcfDcpException(PcfDcpErrorKind.ReservedType,
+ "partition type is the reserved type 0x00000000");
+
+ internal static PcfDcpException NotADcpContainer() =>
+ new PcfDcpException(PcfDcpErrorKind.NotADcpContainer, "partition is not a DCP container");
+
+ internal static PcfDcpException PositionOutOfRange() =>
+ new PcfDcpException(PcfDcpErrorKind.PositionOutOfRange,
+ "logical position is past end of content");
+}
diff --git a/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/ReferenceVector.cs b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/ReferenceVector.cs
new file mode 100644
index 0000000..e007cd6
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/src/Pcf.Dcp/ReferenceVector.cs
@@ -0,0 +1,38 @@
+using System.Text;
+using Pcf;
+
+namespace Pcf.Dcp;
+
+/// The canonical PCF-DCP v1.0 test vector (spec Section 17).
+public static class ReferenceVector
+{
+ private static byte[] Fill(byte b)
+ {
+ var u = new byte[16];
+ for (int i = 0; i < 16; i++)
+ {
+ u[i] = b;
+ }
+ return u;
+ }
+
+ ///
+ /// Build the byte-exact 700-byte reference file from spec Section 17: one
+ /// DCP container ("dcp", uid 16×0xDC) holding inner "A" ("Hello, World!" as
+ /// two extents, the second shared) and inner "B" ("World!" deduplicated onto
+ /// A's second extent). Building this logical container and emitting the
+ /// canonical layout MUST reproduce these exact bytes.
+ ///
+ public static byte[] Build()
+ {
+ var arena = new Arena();
+ arena.AddInner(0x0000_0010, Fill(0xA1), "A",
+ Encoding.UTF8.GetBytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ arena.AddInner(0x0000_0010, Fill(0xB2), "B",
+ Encoding.UTF8.GetBytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+
+ var w = new DcpWriter();
+ w.AddContainer(Fill(0xDC), "dcp", arena);
+ return w.ToImage();
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/testdata/canonical.bin b/implementations/dotnet/pcf-dcp/testdata/canonical.bin
new file mode 100644
index 0000000..834aea4
Binary files /dev/null and b/implementations/dotnet/pcf-dcp/testdata/canonical.bin differ
diff --git a/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/CanonicalVectorTests.cs b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/CanonicalVectorTests.cs
new file mode 100644
index 0000000..b0e1d80
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/CanonicalVectorTests.cs
@@ -0,0 +1,54 @@
+using System.IO;
+using Pcf;
+using Pcf.Dcp;
+using Xunit;
+
+namespace Pcf.Dcp.Tests;
+
+public class CanonicalVectorTests
+{
+ private const string ExpectedSha256 =
+ "b9bb59794abed008863063886d8d0daa810c44939c1c5d29449475ced8156b90";
+
+ private static byte[] Canonical() =>
+ File.ReadAllBytes(Path.Combine(
+ Path.GetDirectoryName(typeof(CanonicalVectorTests).Assembly.Location)!,
+ "testdata", "canonical.bin"));
+
+ [Fact]
+ public void ShipsExpectedSha256AndLength()
+ {
+ var c = Canonical();
+ Assert.Equal(700, c.Length);
+ Assert.Equal(ExpectedSha256, TestSupport.Sha256Hex(c));
+ }
+
+ [Fact]
+ public void RegeneratesByteExact()
+ {
+ var image = ReferenceVector.Build();
+ Assert.Equal(700, image.Length);
+ Assert.Equal(ExpectedSha256, TestSupport.Sha256Hex(image));
+ Assert.Equal(TestSupport.Hex(Canonical()), TestSupport.Hex(image));
+ }
+
+ [Fact]
+ public void IsValidPcf()
+ {
+ var c = Container.Open(new MemoryStream(Canonical()));
+ c.Verify();
+ var entries = c.Entries();
+ Assert.Single(entries);
+ Assert.Equal(0xAAAC0001u, entries[0].PartitionType);
+ Assert.Equal(465ul, entries[0].UsedBytes);
+ }
+
+ [Fact]
+ public void IsValidDcp()
+ {
+ var r = DcpReader.Open(new MemoryStream(Canonical()));
+ r.Verify();
+ Assert.Equal("Hello, World!", TestSupport.Str(r.ReadInner(TestSupport.Fill(0xA1))));
+ Assert.Equal("World!", TestSupport.Str(r.ReadInner(TestSupport.Fill(0xB2))));
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/CoverageTests.cs b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/CoverageTests.cs
new file mode 100644
index 0000000..e33d305
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/CoverageTests.cs
@@ -0,0 +1,132 @@
+using System.IO;
+using Pcf;
+using Pcf.Dcp;
+using Xunit;
+
+namespace Pcf.Dcp.Tests;
+
+public class CoverageTests
+{
+ private static PcfDcpErrorKind KindOf(System.Action fn)
+ {
+ var ex = Assert.Throws(fn);
+ return ex.Kind;
+ }
+
+ [Fact]
+ public void RejectsBadArenaMagic()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(1), "x", TestSupport.Bytes("hi"), HashAlgo.Sha256, Chunker.Whole());
+ var bytes = a.ToBytes();
+ bytes[0] = 0x58;
+ Assert.Equal(PcfDcpErrorKind.BadDcpMagic, KindOf(() => Arena.Parse(bytes)));
+ }
+
+ [Fact]
+ public void RejectsUnsupportedProfileMajor()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(1), "x", TestSupport.Bytes("hi"), HashAlgo.Sha256, Chunker.Whole());
+ var bytes = a.ToBytes();
+ bytes[4] = 2;
+ Assert.Equal(PcfDcpErrorKind.UnsupportedProfileMajor, KindOf(() => Arena.Parse(bytes)));
+ }
+
+ [Fact]
+ public void RejectsReservedNestedAndNilUid()
+ {
+ var a = new Arena();
+ Assert.Equal(PcfDcpErrorKind.ReservedType,
+ KindOf(() => a.AddInner(0, TestSupport.Fill(1), "x", TestSupport.Bytes(""), HashAlgo.None, Chunker.Whole())));
+ Assert.Equal(PcfDcpErrorKind.NestedContainer,
+ KindOf(() => a.AddInner(0xAAAC0001, TestSupport.Fill(1), "x", TestSupport.Bytes(""), HashAlgo.None, Chunker.Whole())));
+ Assert.Equal(PcfDcpErrorKind.NilUid,
+ KindOf(() => a.AddInner(0x10, new byte[16], "x", TestSupport.Bytes(""), HashAlgo.None, Chunker.Whole())));
+ }
+
+ [Fact]
+ public void RejectsDuplicateUidWithinArena()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(1), "x", TestSupport.Bytes("a"), HashAlgo.None, Chunker.Whole());
+ Assert.Equal(PcfDcpErrorKind.DuplicateUid,
+ KindOf(() => a.AddInner(0x10, TestSupport.Fill(1), "y", TestSupport.Bytes("b"), HashAlgo.None, Chunker.Whole())));
+ }
+
+ [Fact]
+ public void RejectsBadKindAndOutOfRangeExtent()
+ {
+ Assert.Equal(PcfDcpErrorKind.BadFragmentKind,
+ KindOf(() => FragmentTable.Reconstruct(new byte[64],
+ new[] { new FragmentEntry { ExtentOffset = 24, ExtentLength = 1, Kind = 2, Flags = 0 } }, 64)));
+ Assert.Equal(PcfDcpErrorKind.OffsetOutOfRange,
+ KindOf(() => FragmentTable.Reconstruct(new byte[64],
+ new[] { new FragmentEntry { ExtentOffset = 60, ExtentLength = 100, Kind = 1, Flags = 0 } }, 64)));
+ }
+
+ [Fact]
+ public void AllowsEmptyInner()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(1), "empty", TestSupport.Bytes(""), HashAlgo.Sha256, Chunker.Whole());
+ var info = a.GetInner(TestSupport.Fill(1));
+ Assert.Equal(0ul, info.UsedBytes);
+ Assert.Empty(info.Extents);
+ var parsed = Arena.Parse(a.ToBytes());
+ Assert.Empty(parsed.Content(TestSupport.Fill(1)));
+ }
+
+ [Fact]
+ public void ChainsInnerTableBeyond255()
+ {
+ var a = new Arena();
+ for (int i = 0; i < 300; i++)
+ {
+ var uid = new byte[16];
+ uid[0] = (byte)(i & 0xFF);
+ uid[1] = (byte)((i >> 8) & 0xFF);
+ uid[15] = 1;
+ a.AddInner(0x10, uid, "n", new byte[] { (byte)(i & 0xFF), (byte)((i >> 8) & 0xFF) }, HashAlgo.Sha256, Chunker.Whole());
+ }
+ Assert.Equal(300, a.Count);
+ Assert.Equal(300, Arena.Parse(a.ToBytes()).Count);
+
+ var w = new DcpWriter();
+ w.AddContainer(TestSupport.Fill(0xDC), "big", a);
+ DcpReader.Open(new MemoryStream(w.ToImage())).Verify();
+ }
+
+ [Fact]
+ public void ChainsFragmentTableBeyond255()
+ {
+ var a = new Arena();
+ var distinct = new byte[300];
+ for (int i = 0; i < 300; i++) distinct[i] = (byte)(i & 0xFF);
+ a.AddInner(0x10, TestSupport.Fill(2), "frag", distinct, HashAlgo.Sha256, Chunker.Fixed(1));
+ var parsed = Arena.Parse(a.ToBytes());
+ Assert.Equal(TestSupport.Hex(distinct), TestSupport.Hex(parsed.Content(TestSupport.Fill(2))));
+ }
+
+ [Fact]
+ public void VerifyDetectsFileWideUidCollision()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(0xB2), "B", TestSupport.Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+ var w = new DcpWriter();
+ w.AddContainer(TestSupport.Fill(0xDC), "dcp", a);
+ w.AddPlain(0x10, TestSupport.Fill(0xB2), "dup", TestSupport.Bytes("x"), HashAlgo.Sha256);
+ var r = DcpReader.Open(new MemoryStream(w.ToImage()));
+ Assert.Equal(PcfDcpErrorKind.DuplicateUid, KindOf(() => r.Verify()));
+ }
+
+ [Fact]
+ public void OpenArenaRejectsNonDcpPartition()
+ {
+ var c = Container.CreateWith(new MemoryStream(), 4, HashAlgo.Sha256);
+ c.AddPartition(0x10, TestSupport.Fill(7), "plain", TestSupport.Bytes("hi"), 0, HashAlgo.Sha256);
+ var r = DcpReader.Open(c.Storage);
+ var entry = r.Entries()[0];
+ Assert.Equal(PcfDcpErrorKind.NotADcpContainer, KindOf(() => r.OpenArena(entry)));
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/Pcf.Dcp.Tests.csproj b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/Pcf.Dcp.Tests.csproj
new file mode 100644
index 0000000..ae37d52
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/Pcf.Dcp.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ testdata\canonical.bin
+ PreserveNewest
+
+
+
+
diff --git a/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/RoundtripTests.cs b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/RoundtripTests.cs
new file mode 100644
index 0000000..95b63b2
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/RoundtripTests.cs
@@ -0,0 +1,130 @@
+using System.IO;
+using Pcf;
+using Pcf.Dcp;
+using Xunit;
+
+namespace Pcf.Dcp.Tests;
+
+public class RoundtripTests
+{
+ private static byte[] BuildTwoInnerFile()
+ {
+ var arena = new Arena();
+ arena.AddInner(0x10, TestSupport.Fill(0xA1), "A", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ arena.AddInner(0x10, TestSupport.Fill(0xB2), "B", TestSupport.Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+ var w = new DcpWriter();
+ w.AddContainer(TestSupport.Fill(0xDC), "dcp", arena);
+ return w.ToImage();
+ }
+
+ [Fact]
+ public void EditsReconstructCorrectly()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(1), "f", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+
+ a.Append(TestSupport.Fill(1), TestSupport.Bytes("!!"));
+ Assert.Equal("Hello, World!!!", TestSupport.Str(a.Content(TestSupport.Fill(1))));
+
+ a.Insert(TestSupport.Fill(1), 5, TestSupport.Bytes("XYZ"));
+ Assert.Equal("HelloXYZ, World!!!", TestSupport.Str(a.Content(TestSupport.Fill(1))));
+
+ a.Delete(TestSupport.Fill(1), 5, 3);
+ Assert.Equal("Hello, World!!!", TestSupport.Str(a.Content(TestSupport.Fill(1))));
+
+ a.Overwrite(TestSupport.Fill(1), 0, 5, TestSupport.Bytes("HOWDY"));
+ Assert.Equal("HOWDY, World!!!", TestSupport.Str(a.Content(TestSupport.Fill(1))));
+
+ a.Truncate(TestSupport.Fill(1), 5);
+ Assert.Equal("HOWDY", TestSupport.Str(a.Content(TestSupport.Fill(1))));
+ }
+
+ [Fact]
+ public void CopyOnWriteDoesNotDisturbSharedBytes()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(0xA1), "A", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ a.AddInner(0x10, TestSupport.Fill(0xB2), "B", TestSupport.Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+ a.Overwrite(TestSupport.Fill(0xA1), 7, 6, TestSupport.Bytes("PLANET"));
+ Assert.Equal("Hello, PLANET", TestSupport.Str(a.Content(TestSupport.Fill(0xA1))));
+ Assert.Equal("World!", TestSupport.Str(a.Content(TestSupport.Fill(0xB2))));
+ }
+
+ [Fact]
+ public void DedupThenDefragPreserveContent()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(1), "A", TestSupport.Bytes("abcabc"), HashAlgo.Sha256, Chunker.Whole());
+ a.AddInner(0x10, TestSupport.Fill(2), "B", TestSupport.Bytes("abcabc"), HashAlgo.Sha256, Chunker.Whole());
+ var h1 = a.GetInner(TestSupport.Fill(1)).DataHash;
+
+ long saved = a.Dedup(Chunker.Fixed(3));
+ Assert.True(saved > 0);
+ Assert.Equal("abcabc", TestSupport.Str(a.Content(TestSupport.Fill(1))));
+ Assert.Equal("abcabc", TestSupport.Str(a.Content(TestSupport.Fill(2))));
+ Assert.Equal(TestSupport.Hex(h1), TestSupport.Hex(a.GetInner(TestSupport.Fill(1)).DataHash));
+
+ a.Compact();
+ Assert.Equal("abcabc", TestSupport.Str(a.Content(TestSupport.Fill(2))));
+ }
+
+ [Fact]
+ public void DefragClearsSharedWhenNoLongerAliased()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(0xA1), "A", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ a.AddInner(0x10, TestSupport.Fill(0xB2), "B", TestSupport.Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+ a.RemoveInner(TestSupport.Fill(0xB2));
+ a.Compact();
+ var ia = a.GetInner(TestSupport.Fill(0xA1));
+ Assert.All(ia.Extents, e => Assert.False(e.Shared));
+ Assert.Equal("Hello, World!", TestSupport.Str(a.Content(TestSupport.Fill(0xA1))));
+ }
+
+ [Fact]
+ public void PromotePreservesUidAndDataHash()
+ {
+ var w = DcpWriter.Open(new MemoryStream(BuildTwoInnerFile()));
+ byte[] before;
+ {
+ var r0 = DcpReader.Open(new MemoryStream(w.ToImage()));
+ before = r0.InnerPartitions().Find(l => l.Info.Uid[0] == 0xB2)!.Info.DataHash;
+ }
+
+ w.Promote(TestSupport.Fill(0xDC), TestSupport.Fill(0xB2));
+ var r = DcpReader.Open(new MemoryStream(w.ToImage()));
+ r.Verify();
+ var resolved = r.ResolveUid(TestSupport.Fill(0xB2));
+ Assert.True(resolved.IsTopLevel);
+ Assert.Equal(TestSupport.Hex(before), TestSupport.Hex(resolved.Entry!.DataHash));
+ Assert.Equal(6ul, resolved.Entry.UsedBytes);
+ Assert.Equal("Hello, World!", TestSupport.Str(r.ReadInner(TestSupport.Fill(0xA1))));
+ }
+
+ [Fact]
+ public void DemoteThenPromoteIsIdentityForContent()
+ {
+ var w = DcpWriter.Open(new MemoryStream(BuildTwoInnerFile()));
+ w.Promote(TestSupport.Fill(0xDC), TestSupport.Fill(0xB2));
+ w.Demote(TestSupport.Fill(0xB2), TestSupport.Fill(0xDC));
+ var r = DcpReader.Open(new MemoryStream(w.ToImage()));
+ r.Verify();
+ Assert.Equal("World!", TestSupport.Str(r.ReadInner(TestSupport.Fill(0xB2))));
+ Assert.False(r.ResolveUid(TestSupport.Fill(0xB2)).IsTopLevel);
+ }
+
+ [Fact]
+ public void TrailerModeReadsBackIdentically()
+ {
+ var arena = new Arena();
+ arena.AddInner(0x10, TestSupport.Fill(0xA1), "A", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ arena.AddInner(0x10, TestSupport.Fill(0xB2), "B", TestSupport.Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+ var w = new DcpWriter();
+ w.AddContainer(TestSupport.Fill(0xDC), "dcp", arena);
+ w.SetTrailer(true);
+ var r = DcpReader.Open(new MemoryStream(w.ToImage()));
+ r.Verify();
+ Assert.Equal("Hello, World!", TestSupport.Str(r.ReadInner(TestSupport.Fill(0xA1))));
+ Assert.Equal(2, r.InnerPartitions().Count);
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/SpecComplianceTests.cs b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/SpecComplianceTests.cs
new file mode 100644
index 0000000..7253c20
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/SpecComplianceTests.cs
@@ -0,0 +1,103 @@
+using Pcf;
+using Pcf.Dcp;
+using Xunit;
+
+namespace Pcf.Dcp.Tests;
+
+public class SpecComplianceTests
+{
+ [Fact]
+ public void StructureSizesMatchAppendixA()
+ {
+ Assert.Equal(24, Pcf.Dcp.Constants.DcpHeaderSize);
+ Assert.Equal(9, Pcf.Dcp.Constants.FragTableHeaderSize);
+ Assert.Equal(18, Pcf.Dcp.Constants.FragmentEntrySize);
+ Assert.Equal(0xAAAC0001u, Pcf.Dcp.Constants.DcpContainerType);
+ }
+
+ [Fact]
+ public void HeaderRoundTripsAndCarriesMagic()
+ {
+ var h = new DcpHeader
+ {
+ ProfileVersionMajor = 1,
+ ProfileVersionMinor = 0,
+ Flags = 0,
+ InnerTableOffset = 109,
+ ArenaUsed = 465,
+ };
+ var b = h.ToBytes();
+ Assert.Equal(new byte[] { 0x50, 0x44, 0x43, 0x50 }, new[] { b[0], b[1], b[2], b[3] });
+ var parsed = DcpHeader.FromBytes(b);
+ Assert.Equal(109ul, parsed.InnerTableOffset);
+ Assert.Equal(465ul, parsed.ArenaUsed);
+ Assert.Equal(1, parsed.ProfileVersionMajor);
+ Assert.Equal(0, parsed.ProfileVersionMinor);
+ }
+
+ [Fact]
+ public void FragmentRecordsRoundTrip()
+ {
+ var e = new FragmentEntry { ExtentOffset = 31, ExtentLength = 6, Kind = 1, Flags = 1 };
+ var pe = FragmentEntry.FromBytes(e.ToBytes());
+ Assert.Equal(31ul, pe.ExtentOffset);
+ Assert.Equal(6ul, pe.ExtentLength);
+ Assert.Equal(1, pe.Kind);
+ Assert.True(pe.IsShared());
+
+ var fh = new FragTableHeader { NextFragtableOffset = 0, FragmentCount = 2 };
+ var pfh = FragTableHeader.FromBytes(fh.ToBytes());
+ Assert.Equal(0ul, pfh.NextFragtableOffset);
+ Assert.Equal(2, pfh.FragmentCount);
+ }
+
+ [Fact]
+ public void ReconstructionEqualsLogicalContent()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(1), "x", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ Assert.Equal("Hello, World!", TestSupport.Str(a.Content(TestSupport.Fill(1))));
+ var info = a.GetInner(TestSupport.Fill(1));
+ Assert.Equal(13ul, info.UsedBytes);
+ Assert.Equal(2, info.Extents.Count);
+ }
+
+ [Fact]
+ public void DataHashIsInvariantUnderFragmentation()
+ {
+ string Mk(Chunker c)
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(7), "x", TestSupport.Bytes("abcdefghij"), HashAlgo.Sha256, c);
+ return TestSupport.Hex(a.GetInner(TestSupport.Fill(7)).DataHash);
+ }
+ Assert.Equal(Mk(Chunker.Whole()), Mk(Chunker.Fixed(3)));
+ Assert.Equal(Mk(Chunker.Whole()), TestSupport.Hex(HashAlgo.Sha256.Compute(TestSupport.Bytes("abcdefghij"))));
+ }
+
+ [Fact]
+ public void DedupSetsSharedOnAllAliasesRuleF1()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(0xA1), "A", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ a.AddInner(0x10, TestSupport.Fill(0xB2), "B", TestSupport.Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+
+ var ia = a.GetInner(TestSupport.Fill(0xA1));
+ var ib = a.GetInner(TestSupport.Fill(0xB2));
+ Assert.False(ia.Extents[0].Shared);
+ Assert.True(ia.Extents[1].Shared);
+ Assert.Single(ib.Extents);
+ Assert.True(ib.Extents[0].Shared);
+ Assert.Equal(TestSupport.Hex(HashAlgo.Sha256.Compute(TestSupport.Bytes("World!"))), TestSupport.Hex(ib.DataHash));
+ }
+
+ [Fact]
+ public void ParseRoundTripsCanonicalArenaByteExact()
+ {
+ var a = new Arena();
+ a.AddInner(0x10, TestSupport.Fill(0xA1), "A", TestSupport.Bytes("Hello, World!"), HashAlgo.Sha256, Chunker.Fixed(7));
+ a.AddInner(0x10, TestSupport.Fill(0xB2), "B", TestSupport.Bytes("World!"), HashAlgo.Sha256, Chunker.Whole());
+ var bytes = a.ToBytes();
+ Assert.Equal(TestSupport.Hex(bytes), TestSupport.Hex(Arena.Parse(bytes).ToBytes()));
+ }
+}
diff --git a/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/TestSupport.cs b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/TestSupport.cs
new file mode 100644
index 0000000..26d3f59
--- /dev/null
+++ b/implementations/dotnet/pcf-dcp/tests/Pcf.Dcp.Tests/TestSupport.cs
@@ -0,0 +1,32 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Pcf.Dcp.Tests;
+
+internal static class TestSupport
+{
+ /// A 16-byte uid all equal to .
+ public static byte[] Fill(byte b)
+ {
+ var u = new byte[16];
+ for (int i = 0; i < 16; i++) u[i] = b;
+ return u;
+ }
+
+ public static byte[] Bytes(string s) => Encoding.UTF8.GetBytes(s);
+
+ public static string Str(byte[] b) => Encoding.UTF8.GetString(b);
+
+ public static string Hex(byte[] b)
+ {
+ var sb = new StringBuilder(b.Length * 2);
+ foreach (var x in b) sb.Append(x.ToString("x2"));
+ return sb.ToString();
+ }
+
+ public static string Sha256Hex(byte[] b)
+ {
+ using var sha = SHA256.Create();
+ return Hex(sha.ComputeHash(b));
+ }
+}
diff --git a/implementations/php/pcf-dcp/.gitignore b/implementations/php/pcf-dcp/.gitignore
new file mode 100644
index 0000000..977d1ba
--- /dev/null
+++ b/implementations/php/pcf-dcp/.gitignore
@@ -0,0 +1,20 @@
+# --- Composer ---
+/vendor/
+composer.lock
+
+# --- PHPUnit ---
+/.phpunit.cache/
+.phpunit.result.cache
+
+# --- Generated artifacts ---
+*.bin
+!testdata/canonical.bin
+
+# --- Editors ---
+.idea/
+.vscode/
+*.swp
+*~
+
+# --- macOS ---
+.DS_Store
diff --git a/implementations/php/pcf-dcp/README.md b/implementations/php/pcf-dcp/README.md
new file mode 100644
index 0000000..9f4d921
--- /dev/null
+++ b/implementations/php/pcf-dcp/README.md
@@ -0,0 +1,66 @@
+# kduma/pcf-dcp — PCF Dynamic Container Partition (PHP)
+
+PHP reader/writer for **PCF-DCP v1.0**, an application-level profile that adds
+*dynamic*, fragmentable, dedup-friendly sub-partitions to the
+[Partitioned Container Format](https://github.com/kduma-OSS/Partitioned-Container-Format)
+(`kduma/pcf`) without modifying the PCF byte container.
+
+This package mirrors the written specification (`PCF-DCP-spec-v1.0.txt`) and the
+Rust reference implementation field-for-field, and ships the same byte-exact
+700-byte canonical test vector as every other port. It has no cryptographic
+dependency — data/table hashing comes from `kduma/pcf` (`ext-hash`).
+
+## Model at a glance
+
+One new PCF partition type is defined:
+
+| Type | Name | Holds |
+|--------------|-----------------|----------------------------------------------------|
+| `0xAAAC0001` | `DCP_CONTAINER` | An *arena*: a header, an inner partition table, fragment tables, and data extents |
+
+```
+arena:
+[ DCP Header (24 B) | data extents | Fragment Tables | Inner Table Block(s) ]
+```
+
+Each inner partition's logical content is the concatenation of its DATA extents;
+its data hash covers that logical content, so fragmentation, deduplication,
+compaction, and promotion all leave the hash (and any PCF-SIG signature over it)
+unchanged. A generic PCF reader sees a DCP file as **one opaque partition**; only
+a DCP-aware reader looks inside.
+
+## Example
+
+```php
+use Kduma\PCF\HashAlgo;
+use Kduma\PCF\Storage\MemoryStorage;
+use Kduma\PCFDCP\Arena;
+use Kduma\PCFDCP\Chunker;
+use Kduma\PCFDCP\DcpReader;
+use Kduma\PCFDCP\DcpWriter;
+
+$arena = new Arena();
+$arena->addInner(0x10, str_repeat("\xA1", 16), 'A', 'Hello, World!', HashAlgo::Sha256, Chunker::fixed(7));
+$arena->addInner(0x10, str_repeat("\xB2", 16), 'B', 'World!', HashAlgo::Sha256, Chunker::whole());
+
+$w = new DcpWriter();
+$w->addContainer(str_repeat("\xDC", 16), 'dcp', $arena);
+$image = $w->toImage();
+
+$r = DcpReader::open(new MemoryStorage($image));
+$r->verify();
+echo $r->readInner(str_repeat("\xB2", 16)); // "World!"
+```
+
+## Operations
+
+`Arena` supports content-defined deduplication, copy-on-write edits
+(`append` / `insert` / `overwrite` / `delete` / `truncate`), and
+sharing-preserving `compact`. `DcpWriter` adds **promotion** (`promote`,
+dynamic → fixed) and **demotion** (`demote`, fixed → dynamic), each preserving
+`uid`, `partitionType`, `label`, `dataHashAlgo`, and `dataHash` — the promotion
+invariant, identical to the fields a PCF-SIG signature protects.
+
+## Licence
+
+MIT.
diff --git a/implementations/php/pcf-dcp/composer.json b/implementations/php/pcf-dcp/composer.json
new file mode 100644
index 0000000..9caa546
--- /dev/null
+++ b/implementations/php/pcf-dcp/composer.json
@@ -0,0 +1,47 @@
+{
+ "name": "kduma/pcf-dcp",
+ "description": "PHP implementation of PCF-DCP v1.0, the PCF Dynamic Container Partition profile",
+ "type": "library",
+ "license": "MIT",
+ "keywords": ["pcf", "pcf-dcp", "container", "deduplication", "fragmentation"],
+ "homepage": "https://github.com/kduma-OSS/Partitioned-Container-Format",
+ "support": {
+ "issues": "https://github.com/kduma-OSS/Partitioned-Container-Format/issues",
+ "source": "https://github.com/kduma-OSS-splits/PHP-PCF-DCP-lib"
+ },
+ "require": {
+ "php": ">=8.1",
+ "ext-hash": "*",
+ "kduma/pcf": "^0.0.9"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5 || ^11.0"
+ },
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../pcf",
+ "options": {
+ "symlink": true,
+ "versions": { "kduma/pcf": "0.0.9" }
+ }
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Kduma\\PCFDCP\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Kduma\\PCFDCP\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "gen-testvector": "php examples/gen_testvector.php"
+ },
+ "config": {
+ "sort-packages": true
+ }
+}
diff --git a/implementations/php/pcf-dcp/examples/gen_testvector.php b/implementations/php/pcf-dcp/examples/gen_testvector.php
new file mode 100644
index 0000000..9da7892
--- /dev/null
+++ b/implementations/php/pcf-dcp/examples/gen_testvector.php
@@ -0,0 +1,31 @@
+` (defaults to
+ * ./pcf_dcp_testvector.bin). Everything is fixed and deterministic so that
+ * independent implementations can reproduce the file byte-for-byte.
+ */
+
+require __DIR__ . '/../vendor/autoload.php';
+
+use Kduma\PCF\Container;
+use Kduma\PCF\Storage\MemoryStorage;
+use Kduma\PCFDCP\DcpReader;
+use Kduma\PCFDCP\ReferenceVector;
+
+$path = $argv[1] ?? 'pcf_dcp_testvector.bin';
+
+$image = ReferenceVector::build();
+file_put_contents($path, $image);
+
+// It is a conforming PCF v1.0 file ...
+Container::open(new MemoryStorage($image))->verify();
+
+// ... and a conforming DCP file.
+DcpReader::open(new MemoryStorage($image))->verify();
+
+fwrite(STDERR, sprintf("wrote %s (%d bytes)\n", $path, \strlen($image)));
+fwrite(STDERR, 'sha256 = ' . bin2hex(hash('sha256', $image, true)) . "\n");
diff --git a/implementations/php/pcf-dcp/phpunit.xml.dist b/implementations/php/pcf-dcp/phpunit.xml.dist
new file mode 100644
index 0000000..87bda63
--- /dev/null
+++ b/implementations/php/pcf-dcp/phpunit.xml.dist
@@ -0,0 +1,20 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/implementations/php/pcf-dcp/src/Arena.php b/implementations/php/pcf-dcp/src/Arena.php
new file mode 100644
index 0000000..2d18f76
--- /dev/null
+++ b/implementations/php/pcf-dcp/src/Arena.php
@@ -0,0 +1,742 @@
+}.
+ */
+final class Arena
+{
+ private int $profileVersionMajor = Consts::PROFILE_VERSION_MAJOR;
+ private int $profileVersionMinor = Consts::PROFILE_VERSION_MINOR;
+ private int $flags = 0;
+ private HashAlgo $innerTableAlgo = HashAlgo::Sha256;
+ private string $blob = '';
+
+ /** @var list