summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2026-08-14 05:21:15 +0200
committerRory& <root@rory.gay>2026-08-14 05:21:15 +0200
commitf3d4f84fde24bb92e7cf6a22d94a703c753f3cbe (patch)
treed50b915a252fdb2e5d96b8ff9c6607a9cf9b78a2
downloadRhea-f3d4f84fde24bb92e7cf6a22d94a703c753f3cbe.tar.xz
Working files and symlinks?
-rw-r--r--.gitignore5
-rw-r--r--Rhea.Nar/NarWriter.cs149
-rw-r--r--Rhea.Nar/Rhea.Nar.csproj14
-rw-r--r--Rhea.sln.DotSettings.user2
-rw-r--r--Rhea.slnx4
-rw-r--r--Rhea/Controllers/MetaController.cs17
-rw-r--r--Rhea/Controllers/NarController.cs20
-rw-r--r--Rhea/Controllers/NarInfoController.cs32
-rw-r--r--Rhea/Program.cs67
-rw-r--r--Rhea/Properties/launchSettings.json14
-rw-r--r--Rhea/Rhea.csproj18
-rw-r--r--Rhea/Rhea.http6
-rw-r--r--Rhea/RheaConfiguration.cs11
-rw-r--r--Rhea/StoreMetaService.cs22
-rw-r--r--Rhea/appsettings.Development.json11
-rw-r--r--Rhea/appsettings.json9
16 files changed, 401 insertions, 0 deletions
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": "*" +}