diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..add57be
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+bin/
+obj/
+/packages/
+riderModule.iml
+/_ReSharper.Caches/
\ No newline at end of file
diff --git a/Rhea.Nar/NarWriter.cs b/Rhea.Nar/NarWriter.cs
new file mode 100644
index 0000000..a01cd3d
--- /dev/null
+++ b/Rhea.Nar/NarWriter.cs
@@ -0,0 +1,149 @@
+using System.IO.Enumeration;
+using System.Runtime.InteropServices;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+
+namespace Rhea.Nar;
+
+public ref struct NarConsts
+{
+ public static readonly byte[] Header = NarWriter.GetLengthPrefixedPadded("nix-archive-1"u8);
+
+ public static readonly byte[] LeftParen = NarWriter.GetLengthPrefixedPadded("("u8);
+ public static readonly byte[] RightParen = NarWriter.GetLengthPrefixedPadded(")"u8);
+
+ public static readonly byte[] Regular = NarWriter.GetLengthPrefixedPadded("regular"u8);
+ public static readonly byte[] Directory = NarWriter.GetLengthPrefixedPadded("directory"u8);
+ public static readonly byte[] Symlink = NarWriter.GetLengthPrefixedPadded("symlink"u8);
+
+ public static readonly byte[] Entry = NarWriter.GetLengthPrefixedPadded("entry"u8);
+ public static readonly byte[] Name = NarWriter.GetLengthPrefixedPadded("name"u8);
+ public static readonly byte[] Target = NarWriter.GetLengthPrefixedPadded("target"u8);
+ public static readonly byte[] Node = NarWriter.GetLengthPrefixedPadded("node"u8);
+ public static readonly byte[] Type = NarWriter.GetLengthPrefixedPadded("type"u8);
+ public static readonly byte[] Executable = NarWriter.GetLengthPrefixedPadded("executable"u8);
+
+ public static readonly byte[] Contents = NarWriter.GetLengthPrefixedPadded("contents"u8);
+}
+
+public class NarWriter
+{
+ public Span<byte> GetHeader()
+ {
+ return NarConsts.Header;
+ }
+
+ public IEnumerable<byte[]> GetNarStreamContents(string storePath)
+ {
+ yield return NarConsts.Header;
+ FileAttributes fattr = File.GetAttributes(storePath);
+ if (fattr.HasFlag(FileAttributes.Directory))
+ yield return NarConsts.Directory;
+ else
+ {
+ var linkTarget = new FileInfo(storePath).LinkTarget;
+ if (string.IsNullOrWhiteSpace(linkTarget))
+ foreach (var chunk in GetNarFileContents(new FileInfo(storePath)))
+ yield return chunk;
+
+ else
+ foreach (var chunk in GetNarSymlinkContents(new FileInfo(storePath)))
+ yield return chunk;
+ }
+ // yield return NarConsts.LeftParen;
+ // yield return NarConsts.Type;
+ // FileAttributes fattr = File.GetAttributes(storePath);
+ // if (fattr.HasFlag(FileAttributes.Directory))
+ // yield return NarConsts.Directory;
+ // else
+ // {
+ // var linkTarget = new FileInfo(storePath).LinkTarget;
+ // if (string.IsNullOrWhiteSpace(linkTarget))
+ // yield return NarConsts.Regular;
+ // else yield return NarConsts.Symlink;
+ // }
+ //
+ // foreach (var fe in FileUtils.EnumerateDirectory(storePath).OrderBy(x=>x.Name))
+ // {
+ // yield return NarConsts.Entry;
+ // yield return NarConsts.LeftParen;
+ //
+ // yield return NarConsts.Name;
+ // yield return GetLengthPrefixedPadded(fe.Name.AsBytes().ToArray());
+ //
+ // yield return NarConsts.Type;
+ // if (fe.LinkTarget is not null)
+ // {
+ // yield return NarConsts.Symlink;
+ // yield return NarConsts.Target;
+ // yield return GetLengthPrefixedPadded(fe.LinkTarget.AsBytes().ToArray());
+ // }
+ //
+ // yield return NarConsts.RightParen;
+ // }
+ }
+
+ private IEnumerable<byte[]> GetNarSymlinkContents(FileInfo fe)
+ {
+ yield return NarConsts.LeftParen;
+
+ yield return NarConsts.Type;
+ yield return NarConsts.Symlink;
+ yield return NarConsts.Target;
+ yield return GetLengthPrefixedPadded(fe.LinkTarget!.AsBytes().ToArray());
+
+ yield return NarConsts.RightParen;
+ }
+
+ private IEnumerable<byte[]> GetNarFileContents(FileInfo fe)
+ {
+ yield return NarConsts.LeftParen;
+
+ yield return NarConsts.Type;
+ yield return NarConsts.Regular;
+ if (
+ fe.UnixFileMode.HasFlag(UnixFileMode.UserExecute)
+ || fe.UnixFileMode.HasFlag(UnixFileMode.GroupExecute)
+ || fe.UnixFileMode.HasFlag(UnixFileMode.OtherExecute)
+ )
+ yield return NarConsts.Executable;
+
+ var targetLength = Math.Ceiling(fe.Length / 8d) * 8UL;
+ yield return NarConsts.Contents;
+ yield return BitConverter.GetBytes((ulong)fe.Length);
+
+ using (var fs = File.OpenRead(fe.FullName))
+ {
+ int chunkSize = 32;
+ long remaining = fs.Length;
+ byte[] buf = new byte[chunkSize];
+ while (remaining > 0)
+ {
+ int read;
+ remaining -= read = fs.Read(buf, 0, chunkSize);
+ yield return buf[..read];
+ }
+ }
+
+ var padSize = (int)(targetLength - fe.Length);
+ yield return new byte[padSize];
+
+ yield return NarConsts.RightParen;
+ }
+
+ #region Utility methods
+
+ internal static byte[] GetLengthPrefixedPadded(ReadOnlySpan<byte> data)
+ {
+ byte[] unpadded = [..BitConverter.GetBytes((ulong)data.Length), .. data];
+ var targetLength = Math.Ceiling(unpadded.Length / 8d) * 8;
+ // Console.Write($"{unpadded.Length:X2} - ");
+ // unpadded.HexDump();
+ Array.Resize(ref unpadded, (int)targetLength);
+ // Console.Write($"{unpadded.Length:X2} - ");
+ // unpadded.HexDump();
+ return unpadded;
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/Rhea.Nar/Rhea.Nar.csproj b/Rhea.Nar/Rhea.Nar.csproj
new file mode 100644
index 0000000..bcfc328
--- /dev/null
+++ b/Rhea.Nar/Rhea.Nar.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ArcaneLibs" Version="1.0.1-preview.20260812-173005" />
+ </ItemGroup>
+
+</Project>
diff --git a/Rhea.sln.DotSettings.user b/Rhea.sln.DotSettings.user
new file mode 100644
index 0000000..9293ffa
--- /dev/null
+++ b/Rhea.sln.DotSettings.user
@@ -0,0 +1,2 @@
+<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
+ <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileUtils_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8cf0a8d6a879434ba3723e0c822404d51d600_003Fe3_003F3bdfb238_003FFileUtils_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
\ No newline at end of file
diff --git a/Rhea.slnx b/Rhea.slnx
new file mode 100644
index 0000000..8a1246f
--- /dev/null
+++ b/Rhea.slnx
@@ -0,0 +1,4 @@
+<Solution>
+ <Project Path="Rhea.Nar/Rhea.Nar.csproj" />
+ <Project Path="Rhea/Rhea.csproj" />
+</Solution>
diff --git a/Rhea/Controllers/MetaController.cs b/Rhea/Controllers/MetaController.cs
new file mode 100644
index 0000000..ca20e99
--- /dev/null
+++ b/Rhea/Controllers/MetaController.cs
@@ -0,0 +1,17 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace Rhea.Controllers;
+
+[ApiController]
+public class MetaController(RheaConfiguration cfg) : ControllerBase
+{
+ [HttpGet("/nix-cache-info")]
+ public async Task<string> GetNixCacheInfo()
+ {
+ return $"""
+ StoreDir: {cfg.StorePath}
+ WantMassQuery: 1
+ Priority: 5
+ """;
+ }
+}
\ No newline at end of file
diff --git a/Rhea/Controllers/NarController.cs b/Rhea/Controllers/NarController.cs
new file mode 100644
index 0000000..7ba44ba
--- /dev/null
+++ b/Rhea/Controllers/NarController.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace Rhea.Controllers;
+
+[ApiController]
+public class NarController(RheaConfiguration cfg, StoreMetaService storeSvc) : ControllerBase
+{
+ [HttpHead("/nar/{hash}.nar.zst")]
+ public async Task CheckNixNarExists(string hash)
+ {
+ var path = await storeSvc.GetStorePathByBaseName(hash);
+ Response.StatusCode = string.IsNullOrWhiteSpace(path) ? 404 : 200;
+ }
+
+ [HttpGet("/nar/{hash}.nar.zst")]
+ public async Task<string> GetNixCacheInfo()
+ {
+ return "";
+ }
+}
\ No newline at end of file
diff --git a/Rhea/Controllers/NarInfoController.cs b/Rhea/Controllers/NarInfoController.cs
new file mode 100644
index 0000000..b81b655
--- /dev/null
+++ b/Rhea/Controllers/NarInfoController.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace Rhea.Controllers;
+
+[ApiController]
+public class NarInfoController(RheaConfiguration cfg, StoreMetaService storeSvc) : ControllerBase
+{
+ [HttpHead("/{hash}.narinfo")]
+ public async Task CheckNixNarExists(string hash)
+ {
+ var path = await storeSvc.GetStorePathByBaseName(hash);
+ Response.StatusCode = string.IsNullOrWhiteSpace(path) ? 404 : 200;
+ }
+
+ [HttpGet("/{hash}.narinfo")]
+ public async Task<string> GetNixNarInfo(string hash)
+ {
+ var path = await storeSvc.GetStorePathByBaseName(hash);
+
+ return $"""
+ StorePath: {cfg.StorePath}/{path}
+ URL: nar/09d83cyl9dlfkkbspkgkk7bfydj3mvw6r1x98kvc2v8wl2xd8ldy.nar.zst
+ Compression: zstd
+ FileHash: sha256:0yzqbk88qqh0c7dswwcm27y1pxs5a83hga81vjmcs9d331psv1g8
+ FileSize: 48255244
+ NarHash: sha256:09d83cyl9dlfkkbspkgkk7bfydj3mvw6r1x98kvc2v8wl2xd8ldy
+ NarSize: 209581048
+ Sig: cache.nixos.org-1:sRYhPUkDDRCgZDlgPMvXYEgYvX3GF24wT0v6HOUrtB/4g5lZffgRksiSmsth2wKRPlMijKP/yoDkG4NZDJfJCA==
+ CA: fixed:r:sha256:09d83cyl9dlfkkbspkgkk7bfydj3mvw6r1x98kvc2v8wl2xd8ldy
+ """;
+ }
+}
\ No newline at end of file
diff --git a/Rhea/Program.cs b/Rhea/Program.cs
new file mode 100644
index 0000000..a7310b4
--- /dev/null
+++ b/Rhea/Program.cs
@@ -0,0 +1,67 @@
+using System.Diagnostics;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+using ArcaneLibs.Extensions.Streams;
+using Rhea;
+using Rhea.Nar;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddControllers();
+// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
+builder.Services.AddOpenApi();
+builder.Services.AddSingleton<RheaConfiguration>();
+builder.Services.AddSingleton<StoreMetaService>();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+}
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+new NarWriter().GetHeader().ToArray().HexDump(64);
+
+
+foreach (var srcPath in (string[])["/etc/nix/nix.conf", FileUtils.GetBinDir() + "/Rhea.runtimeconfig.json"])
+{
+ Console.WriteLine($"Original {srcPath}:");
+
+ var pe = new ProcessStartInfo("nix", ["nar", "dump-path", srcPath])
+ {
+ RedirectStandardOutput = true
+ };
+ var origBuf = new List<byte>();
+ var pr = Process.Start(pe);
+ int b;
+ while ((b = pr.StandardOutput.BaseStream.ReadByte()) != -1) origBuf.Add((byte)b);
+ origBuf.ToArray().HexDump(64);
+
+ Console.WriteLine($"\nNew {srcPath}:");
+ List<byte> tbuf = [];
+ List<byte> buf = [];
+ foreach (var chunk in new NarWriter().GetNarStreamContents(srcPath))
+ {
+ tbuf.AddRange(chunk);
+ buf.AddRange(chunk);
+ while (buf.Count > 64)
+ {
+ byte[] data = buf[..64].ToArray();
+ buf = buf[64..];
+ data.HexDump(64);
+ }
+ }
+
+ buf.ToArray().HexDump(64);
+
+ Console.WriteLine("Is equal: " + origBuf.SequenceEqual(tbuf));
+}
+
+// app.Run();
\ No newline at end of file
diff --git a/Rhea/Properties/launchSettings.json b/Rhea/Properties/launchSettings.json
new file mode 100644
index 0000000..20f400a
--- /dev/null
+++ b/Rhea/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5087",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Rhea/Rhea.csproj b/Rhea/Rhea.csproj
new file mode 100644
index 0000000..24387ad
--- /dev/null
+++ b/Rhea/Rhea.csproj
@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ArcaneLibs" Version="1.0.1-preview.20260812-173005" />
+ <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Rhea.Nar\Rhea.Nar.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/Rhea/Rhea.http b/Rhea/Rhea.http
new file mode 100644
index 0000000..5b6e20f
--- /dev/null
+++ b/Rhea/Rhea.http
@@ -0,0 +1,6 @@
+@NixCached_HostAddress = http://localhost:5087
+
+GET {{NixCached_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/Rhea/RheaConfiguration.cs b/Rhea/RheaConfiguration.cs
new file mode 100644
index 0000000..8b18fc7
--- /dev/null
+++ b/Rhea/RheaConfiguration.cs
@@ -0,0 +1,11 @@
+namespace Rhea;
+
+public class RheaConfiguration
+{
+ public RheaConfiguration(IConfiguration config)
+ {
+ config.GetRequiredSection("Rhea").Bind(this);
+ }
+
+ public string StorePath { get; set; }
+}
\ No newline at end of file
diff --git a/Rhea/StoreMetaService.cs b/Rhea/StoreMetaService.cs
new file mode 100644
index 0000000..6b92cd6
--- /dev/null
+++ b/Rhea/StoreMetaService.cs
@@ -0,0 +1,22 @@
+using ArcaneLibs.Collections;
+
+namespace Rhea;
+
+public class StoreMetaService(RheaConfiguration cfg)
+{
+ private LruCache<string> _storePathsByBaseName = new(10000);
+
+ public async Task<string?> GetStorePathByBaseName(string baseName)
+ {
+ return await _storePathsByBaseName.GetOrAddAsync(baseName, async () =>
+ {
+ foreach (var path in Directory.EnumerateFileSystemEntries(cfg.StorePath))
+ {
+ Console.WriteLine(path[(cfg.StorePath.Length + 1)..]);
+ if (path.StartsWith(baseName + '-')) return path;
+ }
+
+ return "";
+ });
+ }
+}
\ No newline at end of file
diff --git a/Rhea/appsettings.Development.json b/Rhea/appsettings.Development.json
new file mode 100644
index 0000000..7ccad18
--- /dev/null
+++ b/Rhea/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+// "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Rhea": {
+ "StorePath": "/nix/store"
+ }
+}
diff --git a/Rhea/appsettings.json b/Rhea/appsettings.json
new file mode 100644
index 0000000..c236d5a
--- /dev/null
+++ b/Rhea/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+// "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
|