diff --git a/src/CoreApi/DagApi.cs b/src/CoreApi/DagApi.cs index fbdf4ee..93a953b 100644 --- a/src/CoreApi/DagApi.cs +++ b/src/CoreApi/DagApi.cs @@ -154,23 +154,32 @@ public async Task StatAsync(string cid, IProgress ExportAsync(string path, CancellationToken cancellationToken = default) { - return ipfs.DownloadAsync("dag/export", cancellationToken, path); + // Kubo expects POST for dag/export + return ipfs.PostDownloadAsync("dag/export", cancellationToken, path); } public async Task ImportAsync(Stream stream, bool? pinRoots = null, bool stats = false, CancellationToken cancellationToken = default) { - string[] options = [ - $"pin-roots={pinRoots.ToString().ToLowerInvariant()}", - $"stats={stats.ToString().ToLowerInvariant()}" - ]; + // Respect Kubo default (pin roots = true) by omitting the flag when null. + var optionsList = new System.Collections.Generic.List(); + if (pinRoots.HasValue) + optionsList.Add($"pin-roots={pinRoots.Value.ToString().ToLowerInvariant()}"); + + optionsList.Add($"stats={stats.ToString().ToLowerInvariant()}"); + var options = optionsList.ToArray(); using var resultStream = await ipfs.Upload2Async("dag/import", cancellationToken, stream, null, options); // Read line-by-line using var reader = new StreamReader(resultStream); - // First output is always of type CarImportOutput + // First output line may be absent on older Kubo when pin-roots=false var json = await reader.ReadLineAsync(); + if (string.IsNullOrEmpty(json)) + { + return new CarImportOutput(); + } + var res = JsonConvert.DeserializeObject(json); if (res is null) throw new InvalidDataException($"The response did not deserialize to {nameof(CarImportOutput)}."); @@ -179,11 +188,14 @@ public async Task ImportAsync(Stream stream, bool? pinRoots = n if (stats) { json = await reader.ReadLineAsync(); - var importStats = JsonConvert.DeserializeObject(json); - if (importStats is null) - throw new InvalidDataException($"The response did not deserialize a {nameof(CarImportStats)}."); + if (!string.IsNullOrEmpty(json)) + { + var importStats = JsonConvert.DeserializeObject(json); + if (importStats is null) + throw new InvalidDataException($"The response did not deserialize a {nameof(CarImportStats)}."); - res.Stats = importStats; + res.Stats = importStats; + } } return res; diff --git a/src/CoreApi/FileSystemApi.cs b/src/CoreApi/FileSystemApi.cs index 50c0b75..3b8ff26 100644 --- a/src/CoreApi/FileSystemApi.cs +++ b/src/CoreApi/FileSystemApi.cs @@ -270,7 +270,10 @@ private string[] ToApiOptions(AddFileOptions? options) opts.Add($"nocopy={options.NoCopy.ToString().ToLowerInvariant()}"); if (options.Pin is not null) - opts.Add("pin=false"); + opts.Add($"pin={options.Pin.ToString().ToLowerInvariant()}"); + + if (!string.IsNullOrEmpty(options.PinName)) + opts.Add($"pin-name={options.PinName}"); if (options.Wrap is not null) opts.Add($"wrap-with-directory={options.Wrap.ToString().ToLowerInvariant()}"); @@ -291,10 +294,10 @@ private string[] ToApiOptions(AddFileOptions? options) opts.Add("progress=true"); if (options.Hash is not null) - opts.Add($"hash=${options.Hash}"); + opts.Add($"hash={options.Hash}"); if (options.FsCache is not null) - opts.Add($"fscache={options.Wrap.ToString().ToLowerInvariant()}"); + opts.Add($"fscache={options.FsCache.ToString().ToLowerInvariant()}"); if (options.ToFiles is not null) opts.Add($"to-files={options.ToFiles}"); diff --git a/src/CoreApi/PinApi.cs b/src/CoreApi/PinApi.cs index d53eb95..94ede19 100644 --- a/src/CoreApi/PinApi.cs +++ b/src/CoreApi/PinApi.cs @@ -1,11 +1,13 @@ -using Google.Protobuf; -using Ipfs.CoreApi; -using Newtonsoft.Json.Linq; +using Ipfs.CoreApi; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using System; +using System.IO; +using Newtonsoft.Json; +#nullable enable namespace Ipfs.Http { class PinApi : IPinApi @@ -17,41 +19,174 @@ internal PinApi(IpfsClient ipfs) this.ipfs = ipfs; } - public async Task> AddAsync(string path, bool recursive = true, CancellationToken cancel = default(CancellationToken)) + public async Task> AddAsync(string path, PinAddOptions options, CancellationToken cancel = default) { - var opts = "recursive=" + recursive.ToString().ToLowerInvariant(); - var json = await ipfs.DoCommandAsync("pin/add", cancel, path, opts); - return ((JArray)JObject.Parse(json)["Pins"]) - .Select(p => (Cid)(string)p); + options ??= new PinAddOptions(); + var optList = new List + { + "recursive=" + options.Recursive.ToString().ToLowerInvariant() + }; + if (!string.IsNullOrEmpty(options.Name)) + { + optList.Add("name=" + options.Name); + } + var json = await ipfs.DoCommandAsync("pin/add", cancel, path, optList.ToArray()); + var dto = JsonConvert.DeserializeObject(json); + var pins = dto?.Pins ?? new List(); + return pins.Select(p => (Cid)p); } - public async Task> ListAsync(CancellationToken cancel = default(CancellationToken)) + public async Task> AddAsync(string path, PinAddOptions options, IProgress progress, CancellationToken cancel = default) { - var json = await ipfs.DoCommandAsync("pin/ls", cancel); - var keys = (JObject)(JObject.Parse(json)["Keys"]); - return keys - .Properties() - .Select(p => (Cid)p.Name); + options ??= new PinAddOptions(); + var optList = new List + { + "recursive=" + options.Recursive.ToString().ToLowerInvariant(), + "progress=true" + }; + if (!string.IsNullOrEmpty(options.Name)) + { + optList.Add("name=" + options.Name); + } + var pinned = new List(); + var stream = await ipfs.PostDownloadAsync("pin/add", cancel, path, optList.ToArray()); + using var sr = new StreamReader(stream); + while (!sr.EndOfStream && !cancel.IsCancellationRequested) + { + var line = await sr.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(line)) + continue; + var dto = JsonConvert.DeserializeObject(line); + if (dto is null) + continue; + if (dto.Progress.HasValue) + { + progress?.Report(new BlocksPinnedProgress { BlocksPinned = dto.Progress.Value }); + } + if (dto.Pins != null) + { + foreach (var p in dto.Pins) + { + pinned.Add((Cid)p); + } + } + } + return pinned; } - public async Task> ListAsync(PinType type, CancellationToken cancel = default(CancellationToken)) + public async IAsyncEnumerable ListAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancel = default) { - var typeOpt = type.ToString().ToLowerInvariant(); - var json = await ipfs.DoCommandAsync("pin/ls", cancel, - null, - $"type={typeOpt}"); - var keys = (JObject)(JObject.Parse(json)["Keys"]); - return keys - .Properties() - .Select(p => (Cid)p.Name); + // Default non-streaming, no names + foreach (var item in await ListItemsOnceAsync(null, new List(), cancel)) + { + yield return item; + } + } + + public async IAsyncEnumerable ListAsync(PinType type, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancel = default) + { + var opts = new List { $"type={type.ToString().ToLowerInvariant()}" }; + foreach (var item in await ListItemsOnceAsync(null, opts, cancel)) + { + yield return item; + } + } + + public async IAsyncEnumerable ListAsync(PinListOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancel = default) + { + options ??= new PinListOptions(); + var opts = new List(); + if (options.Type != PinType.All) + opts.Add($"type={options.Type.ToString().ToLowerInvariant()}"); + if (!string.IsNullOrEmpty(options.Name)) + { + opts.Add($"name={options.Name}"); + opts.Add("names=true"); + } + else if (options.Names) + { + opts.Add("names=true"); + } + + if (options.Stream) + { + await foreach (var item in ListItemsStreamAsync(null, opts, options.Names, cancel)) + { + yield return item; + } + } + else + { + foreach (var item in await ListItemsOnceAsync(null, opts, cancel)) + { + yield return item; + } + } } public async Task> RemoveAsync(Cid id, bool recursive = true, CancellationToken cancel = default(CancellationToken)) { var opts = "recursive=" + recursive.ToString().ToLowerInvariant(); var json = await ipfs.DoCommandAsync("pin/rm", cancel, id, opts); - return ((JArray)JObject.Parse(json)["Pins"]) - .Select(p => (Cid)(string)p); + var dto = JsonConvert.DeserializeObject(json); + var pins = dto?.Pins ?? new List(); + return pins.Select(p => (Cid)p); + } + + // Internal helper used by ListAsync overloads + + async IAsyncEnumerable ListItemsStreamAsync(string? path, List opts, bool includeNames, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancel) + { + opts = new List(opts) { "stream=true" }; + var stream = await ipfs.PostDownloadAsync("pin/ls", cancel, path, opts.ToArray()); + using var sr = new StreamReader(stream); + while (!sr.EndOfStream && !cancel.IsCancellationRequested) + { + var line = await sr.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(line)) + continue; + var dto = JsonConvert.DeserializeObject(line); + if (dto is null || string.IsNullOrEmpty(dto.Cid)) + continue; + yield return new PinListItem + { + Cid = (Cid)dto.Cid!, + Type = ParseType(dto.Type), + Name = dto.Name + }; + } + } + + async Task> ListItemsOnceAsync(string? path, List opts, CancellationToken cancel) + { + var json = await ipfs.DoCommandAsync("pin/ls", cancel, path, opts.ToArray()); + var root = JsonConvert.DeserializeObject(json); + var list = new List(); + if (root?.Keys != null) + { + foreach (var kv in root.Keys) + { + list.Add(new PinListItem + { + Cid = (Cid)kv.Key!, + Type = ParseType(kv.Value?.Type), + Name = string.IsNullOrEmpty(kv.Value?.Name) ? null : kv.Value!.Name + }); + } + } + return list; + } + + static PinType ParseType(string? t) + { + return t?.ToLowerInvariant() switch + { + "direct" => PinType.Direct, + "indirect" => PinType.Indirect, + "recursive" => PinType.Recursive, + "all" => PinType.All, + _ => PinType.All + }; } } diff --git a/src/CoreApi/PinDto.cs b/src/CoreApi/PinDto.cs new file mode 100644 index 0000000..d78b13e --- /dev/null +++ b/src/CoreApi/PinDto.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +#nullable enable +namespace Ipfs.Http +{ + /// + /// Non-streaming response DTO for /api/v0/pin/ls. + /// + internal record PinListResponseDto + { + public Dictionary? Keys { get; init; } + } + + /// + /// DTO for entry value in PinListResponseDto.Keys. + /// + internal record PinInfoDto + { + public string? Name { get; init; } + public string? Type { get; init; } + } + + /// + /// Streaming response DTO for /api/v0/pin/ls?stream=true. + /// + internal record PinLsObjectDto + { + public string? Cid { get; init; } + public string? Name { get; init; } + public string? Type { get; init; } + } + + /// + /// Response DTO for /api/v0/pin/add and /api/v0/pin/rm which both return a Pins array. + /// + internal record PinChangeResponseDto + { + public int? Progress { get; init; } + public List? Pins { get; init; } + } +} diff --git a/src/IpfsHttpClient.csproj b/src/IpfsHttpClient.csproj index 2826886..75d1e22 100644 --- a/src/IpfsHttpClient.csproj +++ b/src/IpfsHttpClient.csproj @@ -102,7 +102,7 @@ Added missing IFileSystemApi.ListAsync. Doesn't fully replace the removed IFileS - + diff --git a/test/AsyncEnumerableTestHelpers.cs b/test/AsyncEnumerableTestHelpers.cs new file mode 100644 index 0000000..ac981bc --- /dev/null +++ b/test/AsyncEnumerableTestHelpers.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Ipfs.Http +{ + internal static class AsyncEnumerableTestHelpers + { + public static IEnumerable ToEnumerable(this IAsyncEnumerable source) + { + return source.ToArrayAsync().GetAwaiter().GetResult(); + } + + public static async Task ToArrayAsync(this IAsyncEnumerable source) + { + var list = new List(); + await foreach (var item in source) + { + list.Add(item); + } + return list.ToArray(); + } + } +} diff --git a/test/CoreApi/BlockApiTest.cs b/test/CoreApi/BlockApiTest.cs index 9527d78..b1b1f9d 100644 --- a/test/CoreApi/BlockApiTest.cs +++ b/test/CoreApi/BlockApiTest.cs @@ -59,13 +59,13 @@ public void Put_Bytes_Pinned() { var data1 = new byte[] { 23, 24, 127 }; var cid1 = ipfs.Block.PutAsync(data1, pin: true).Result; - var pins = ipfs.Pin.ListAsync().Result; - Assert.IsTrue(pins.Any(pin => pin == cid1.Id)); + var pins = ipfs.Pin.ListAsync().ToEnumerable(); + Assert.IsTrue(pins.Any(pin => pin.Cid == cid1.Id)); var data2 = new byte[] { 123, 124, 27 }; var cid2 = ipfs.Block.PutAsync(data2, pin: false).Result; - pins = ipfs.Pin.ListAsync().Result; - Assert.IsFalse(pins.Any(pin => pin == cid2.Id)); + pins = ipfs.Pin.ListAsync().ToEnumerable(); + Assert.IsFalse(pins.Any(pin => pin.Cid == cid2.Id)); } [TestMethod] @@ -106,13 +106,13 @@ public void Put_Stream_Pinned() { var data1 = new MemoryStream(new byte[] { 23, 24, 127 }); var cid1 = ipfs.Block.PutAsync(data1, pin: true).Result; - var pins = ipfs.Pin.ListAsync().Result; - Assert.IsTrue(pins.Any(pin => pin == cid1.Id)); + var pins = ipfs.Pin.ListAsync().ToEnumerable(); + Assert.IsTrue(pins.Any(pin => pin.Cid == cid1.Id)); var data2 = new MemoryStream(new byte[] { 123, 124, 27 }); var cid2 = ipfs.Block.PutAsync(data2, pin: false).Result; - pins = ipfs.Pin.ListAsync().Result; - Assert.IsFalse(pins.Any(pin => pin == cid2.Id)); + pins = ipfs.Pin.ListAsync().ToEnumerable(); + Assert.IsFalse(pins.Any(pin => pin.Cid == cid2.Id)); } [TestMethod] diff --git a/test/CoreApi/DagApiTest.cs b/test/CoreApi/DagApiTest.cs index 817c217..bf46b80 100644 --- a/test/CoreApi/DagApiTest.cs +++ b/test/CoreApi/DagApiTest.cs @@ -1,5 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json.Linq; +using System.Linq; using System.Threading.Tasks; namespace Ipfs.Http @@ -49,6 +50,64 @@ public async Task PutAndGet_POCO() var value = (string)await ipfs.Dag.GetAsync(id.Encode() + "/Last"); Assert.AreEqual(expected.Last, value); } + + [TestMethod] + public async Task Import_Default_Pins_Roots() + { + var ipfs = TestFixture.Ipfs; + + var node = await ipfs.FileSystem.AddTextAsync("car import default pin"); + await using var car = await ipfs.Dag.ExportAsync(node.Id); + + // ensure unpinned first + await ipfs.Pin.RemoveAsync(node.Id); + + var result = await ipfs.Dag.ImportAsync(car, pinRoots: null, stats: false); + Assert.IsNotNull(result.Root); + + var pins = await ipfs.Pin.ListAsync().ToArrayAsync(); + Assert.IsTrue(pins.Any(p => p.Cid == node.Id)); + } + + [TestMethod] + public async Task Import_PinRoots_False_Does_Not_Pin() + { + var ipfs = TestFixture.Ipfs; + + var node = await ipfs.FileSystem.AddTextAsync("car import nopin"); + await using var car = await ipfs.Dag.ExportAsync(node.Id); + + // ensure unpinned first + await ipfs.Pin.RemoveAsync(node.Id); + + var result = await ipfs.Dag.ImportAsync(car, pinRoots: false, stats: false); + // Some Kubo versions emit no Root output when pin-roots=false; allow null. + + var pins = await ipfs.Pin.ListAsync().ToArrayAsync(); + Assert.IsFalse(pins.Any(p => p.Cid == node.Id)); + } + + [TestMethod] + public async Task Export_Then_Import_Roundtrip_Preserves_Root() + { + var ipfs = TestFixture.Ipfs; + + var node = await ipfs.FileSystem.AddTextAsync("car export roundtrip"); + + // ensure unpinned first so import with pinRoots=true creates a new pin + try { await ipfs.Pin.RemoveAsync(node.Id); } catch { } + + await using var car = await ipfs.Dag.ExportAsync(node.Id); + Assert.IsNotNull(car); + + var result = await ipfs.Dag.ImportAsync(car, pinRoots: true, stats: false); + Assert.IsNotNull(result.Root); + Assert.AreEqual(node.Id.ToString(), result.Root!.Cid.ToString()); + + // Verify it is pinned now + var pins = await ipfs.Pin.ListAsync().ToArrayAsync(); + Assert.IsTrue(pins.Any(p => p.Cid == node.Id)); + } } } diff --git a/test/CoreApi/FileSystemApiTest.cs b/test/CoreApi/FileSystemApiTest.cs index 7c2e905..5597575 100644 --- a/test/CoreApi/FileSystemApiTest.cs +++ b/test/CoreApi/FileSystemApiTest.cs @@ -95,8 +95,8 @@ public void Add_NoPin() var data = new MemoryStream(new byte[] { 11, 22, 33 }); var options = new AddFileOptions { Pin = false }; var node = ipfs.FileSystem.AddAsync(data, "", options).Result; - var pins = ipfs.Pin.ListAsync().Result; - Assert.IsFalse(pins.Any(pin => pin == node.Id)); + var pins = ipfs.Pin.ListAsync().ToEnumerable(); + Assert.IsFalse(pins.Any(pin => pin.Cid == node.Id)); } [TestMethod] @@ -124,6 +124,22 @@ public async Task Add_Wrap() } } + [TestMethod] + public async Task Add_WithPinName_PinsNamed() + { + var ipfs = TestFixture.Ipfs; + var name = $"add-pin-{Guid.NewGuid()}"; + var node = await ipfs.FileSystem.AddTextAsync("named pin via add", new AddFileOptions { Pin = true, PinName = name }); + + var items = await ipfs.Pin.ListAsync(new PinListOptions { Names = true }).ToArrayAsync(); + var match = items.FirstOrDefault(i => i.Cid == node.Id); + Assert.IsNotNull(match, "Expected CID to be pinned"); + Assert.AreEqual(name, match!.Name, "Expected pin name to match"); + + // cleanup + await ipfs.Pin.RemoveAsync(node.Id); + } + [TestMethod] public async Task GetTar_EmptyDirectory() { diff --git a/test/CoreApi/PinApiTest.cs b/test/CoreApi/PinApiTest.cs index 9d9aee5..7a77362 100644 --- a/test/CoreApi/PinApiTest.cs +++ b/test/CoreApi/PinApiTest.cs @@ -1,4 +1,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using Ipfs.CoreApi; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -11,9 +14,18 @@ public class PinApiTest public void List() { var ipfs = TestFixture.Ipfs; - var pins = ipfs.Pin.ListAsync().Result; + var pins = ipfs.Pin.ListAsync().ToEnumerable().ToArray(); Assert.IsNotNull(pins); - Assert.IsTrue(pins.Count() > 0); + Assert.IsTrue(pins.Length > 0); + } + + [TestMethod] + public async Task List_WithType_All() + { + var ipfs = TestFixture.Ipfs; + var pins = await ipfs.Pin.ListAsync(PinType.All).ToArrayAsync(); + Assert.IsNotNull(pins); + Assert.IsTrue(pins.Length > 0); } [TestMethod] @@ -23,15 +35,106 @@ public async Task Add_Remove() var result = await ipfs.FileSystem.AddTextAsync("I am pinned"); var id = result.Id; - var pins = await ipfs.Pin.AddAsync(id); + var pins = await ipfs.Pin.AddAsync(id, new PinAddOptions { Recursive = true }); Assert.IsTrue(pins.Any(pin => pin == id)); - var all = await ipfs.Pin.ListAsync(); - Assert.IsTrue(all.Any(pin => pin == id)); + var all = await ipfs.Pin.ListAsync().ToArrayAsync(); + Assert.IsTrue(all.Any(pin => pin.Cid == id)); + + var removed = await ipfs.Pin.RemoveAsync(id); + Assert.IsTrue(removed.Any(pin => pin == id)); + all = await ipfs.Pin.ListAsync().ToArrayAsync(); + Assert.IsFalse(all.Any(pin => pin.Cid == id)); + } + + [TestMethod] + public async Task Add_WithName() + { + var ipfs = TestFixture.Ipfs; + var result = await ipfs.FileSystem.AddTextAsync("I am pinned with a name"); + var id = result.Id; + var name = $"tdd-{System.Guid.NewGuid()}"; + + var pins = await ipfs.Pin.AddAsync(id, new PinAddOptions { Name = name, Recursive = true }); + Assert.IsTrue(pins.Any(pin => pin == id)); + + var all = await ipfs.Pin.ListAsync().ToArrayAsync(); + Assert.IsTrue(all.Any(pin => pin.Cid == id)); + + // cleanup + await ipfs.Pin.RemoveAsync(id); + } + + [TestMethod] + public async Task Add_WithName_NonRecursive() + { + var ipfs = TestFixture.Ipfs; + var result = await ipfs.FileSystem.AddTextAsync("I am pinned non-recursive with a name", new Ipfs.CoreApi.AddFileOptions { Pin = false }); + var id = result.Id; + var name = $"tdd-nr-{System.Guid.NewGuid()}"; - pins = await ipfs.Pin.RemoveAsync(id); + var pins = await ipfs.Pin.AddAsync(id, new PinAddOptions { Name = name, Recursive = false }); Assert.IsTrue(pins.Any(pin => pin == id)); - all = await ipfs.Pin.ListAsync(); - Assert.IsFalse(all.Any(pin => pin == id)); + + // cleanup + await ipfs.Pin.RemoveAsync(id, recursive: false); + } + + [TestMethod] + public async Task Add_WithProgress_Reports_And_Pins() + { + var ipfs = TestFixture.Ipfs; + // Create a larger object (>256 KiB) to ensure multiple blocks + var data = new byte[300_000]; + var node = await ipfs.FileSystem.AddAsync(new System.IO.MemoryStream(data, writable: false), "big.bin", new Ipfs.CoreApi.AddFileOptions { Pin = false }); + var id = node.Id; + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p.BlocksPinned)); + + var pins = await ipfs.Pin.AddAsync(id, new PinAddOptions { Recursive = true }, progress); + + Assert.IsTrue(pins.Any(pin => pin == id), "Expected returned pins to contain the root CID"); + Assert.IsTrue(progressValues.Count >= 1, "Expected at least one progress report"); + Assert.IsTrue(progressValues[progressValues.Count - 1] >= 1, "Expected final progress to be >= 1"); + // Monotonic non-decreasing + for (int i = 1; i < progressValues.Count; i++) + { + Assert.IsTrue(progressValues[i] >= progressValues[i - 1], "Progress should be non-decreasing"); + } + + // cleanup + await ipfs.Pin.RemoveAsync(id); + } + + [TestMethod] + public async Task List_NonStreaming_Default() + { + var ipfs = TestFixture.Ipfs; + var items = await ipfs.Pin.ListAsync(new PinListOptions { Stream = false }).ToArrayAsync(); + Assert.IsTrue(items.Length > 0); + Assert.IsTrue(items.All(i => i.Cid != null)); + } + + [TestMethod] + public async Task List_Streaming_WithNames() + { + var ipfs = TestFixture.Ipfs; + // Ensure at least one named pin exists + var n = await ipfs.FileSystem.AddTextAsync("named pin", new Ipfs.CoreApi.AddFileOptions { Pin = false }); + var id = n.Id; + var name = $"tdd-name-{Guid.NewGuid()}"; + await ipfs.Pin.AddAsync(id, new PinAddOptions { Name = name }); + + var items = new List(); + await foreach (var item in ipfs.Pin.ListAsync(new PinListOptions { Stream = true, Names = true })) + { + items.Add(item); + } + Assert.IsTrue(items.Count > 0); + Assert.IsTrue(items.Any(i => i.Cid == id)); + + // cleanup + await ipfs.Pin.RemoveAsync(id); } } diff --git a/test/IpfsHttpClientTests.csproj b/test/IpfsHttpClientTests.csproj index 0306882..1b6218e 100644 --- a/test/IpfsHttpClientTests.csproj +++ b/test/IpfsHttpClientTests.csproj @@ -13,6 +13,7 @@ + diff --git a/test/TestFixture.cs b/test/TestFixture.cs index 2f786d6..a65a51b 100644 --- a/test/TestFixture.cs +++ b/test/TestFixture.cs @@ -1,14 +1,108 @@ -namespace Ipfs.Http +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Kubo; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OwlCore.Storage.System.IO; +using OwlCore.Storage; +using System.Diagnostics; + +namespace Ipfs.Http { - public static class TestFixture + [TestClass] + public class TestFixture { - /// - /// Fiddler cannot see localhost traffic because .Net bypasses the network stack for localhost/127.0.0.1. - /// By using "127.0.0.1." (note trailing dot) fiddler will receive the traffic and if its not running - /// the localhost will get it! - /// - //IpfsClient.DefaultApiUri = new Uri("http://127.0.0.1.:5001"); - - public static IpfsClient Ipfs = new IpfsClient(); + // Publicly accessible client and API URI for tests. + public static IpfsClient Ipfs { get; private set; } = null!; + public static Uri? ApiUri { get; private set; } + public static KuboBootstrapper? Node; + + [AssemblyInitialize] + public static void AssemblyInit(TestContext context) + { + try + { + OwlCore.Diagnostics.Logger.MessageReceived += (sender, args) => context.WriteLine(args.Message); + + // Prefer the deployment directory when provided; otherwise fall back to a temp folder. + var deploymentDir = context.DeploymentDirectory ?? Path.Combine(Path.GetTempPath(), "IpfsHttpClientTests", "Work"); + Directory.CreateDirectory(deploymentDir); + + // Create a working folder and start a fresh Kubo node with default bootstrap peers. + var workingFolder = SafeCreateWorkingFolder(new SystemFolder(deploymentDir), typeof(TestFixture).Namespace ?? "test").GetAwaiter().GetResult(); + + // Use non-default ports to avoid conflicts with any locally running node. + int apiPort = 11501; + int gatewayPort = 18080; + + Node = CreateNodeAsync(workingFolder, "kubo-node", apiPort, gatewayPort).GetAwaiter().GetResult(); + ApiUri = Node.ApiUri; + Ipfs = new IpfsClient(ApiUri.ToString()); + + context?.WriteLine($"Connected to existing Kubo node: {ApiUri}"); + } + catch (Exception ex) + { + context?.WriteLine($"Kubo bootstrapper failed to start: {ex}"); + throw; + } + } + + public static async Task CreateNodeAsync(SystemFolder workingDirectory, string nodeRepoName, int apiPort, int gatewayPort) + { + var nodeRepo = (SystemFolder)await workingDirectory.CreateFolderAsync(nodeRepoName, overwrite: true); + + // Use a temp folder for the Kubo binary cache to avoid limited space on the deployment drive. + var binCachePath = Path.Combine(Path.GetTempPath(), "IpfsHttpClientTests", "KuboBin"); + Directory.CreateDirectory(binCachePath); + var binCacheFolder = new SystemFolder(binCachePath); + + var node = new KuboBootstrapper(nodeRepo.Path) + { + ApiUri = new Uri($"http://127.0.0.1:{apiPort}"), + GatewayUri = new Uri($"http://127.0.0.1:{gatewayPort}"), + RoutingMode = DhtRoutingMode.AutoClient, + LaunchConflictMode = BootstrapLaunchConflictMode.Relaunch, + BinaryWorkingFolder = binCacheFolder, + EnableFilestore = true, + }; + + OwlCore.Diagnostics.Logger.LogInformation($"Starting node {nodeRepoName}\n"); + + await node.StartAsync().ConfigureAwait(false); + await node.Client.IdAsync().ConfigureAwait(false); + + Debug.Assert(node.Process != null); + return node; + } + + public static async Task SafeCreateWorkingFolder(SystemFolder rootFolder, string name) + { + var testTempRoot = (SystemFolder)await rootFolder.CreateFolderAsync(name, overwrite: false); + await SetAllFileAttributesRecursive(testTempRoot, attributes => attributes & ~FileAttributes.ReadOnly).ConfigureAwait(false); + + // Delete and recreate the folder. + return (SystemFolder)await rootFolder.CreateFolderAsync(name, overwrite: true).ConfigureAwait(false); + } + + public static async Task SetAllFileAttributesRecursive(SystemFolder rootFolder, Func transform) + { + await foreach (SystemFile file in rootFolder.GetFilesAsync()) + file.Info.Attributes = transform(file.Info.Attributes); + + await foreach (SystemFolder folder in rootFolder.GetFoldersAsync()) + await SetAllFileAttributesRecursive(folder, transform).ConfigureAwait(false); + } + + [AssemblyCleanup] + public static void AssemblyCleanup() + { + try + { + Node?.Dispose(); + } + catch { } + } } } diff --git a/test/TlsReproTest.cs b/test/TlsReproTest.cs new file mode 100644 index 0000000..01820c5 --- /dev/null +++ b/test/TlsReproTest.cs @@ -0,0 +1,31 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Linq; + +namespace Ipfs.Http +{ + [TestClass] + public class TlsReproTest + { + [TestMethod] + public void Real_Tls_MultiAddress_Should_Fail() + { + // Real multiaddr from long-running node that contains TLS + var realTlsAddr = "/dns4/45-86-153-40.k51qzi5uqu5dhssh49wibkxi3yw56ihw9uo7cxctyiqbfxpodudqo09swsxp1s.libp2p.direct/tcp/4001/tls/ws/p2p/12D3KooWEBaJd7msGiDjA5ATyMpVSEgja4xc6FXkxddaSM9DtHCT/p2p-circuit/p2p/12D3KooWEPx5LSWNGRtQFweBG9KMsfNjdogoeRisF9cU2s5RcrsJ"; + + var addr = new MultiAddress(realTlsAddr); + OwlCore.Diagnostics.Logger.LogInformation($"Successfully parsed TLS multiaddr: {addr}"); + + // Check if it contains the protocols we expect + var protocols = addr.Protocols.ToList(); + OwlCore.Diagnostics.Logger.LogInformation($"Protocol count: {protocols.Count}"); + + foreach(var protocol in protocols) + { + OwlCore.Diagnostics.Logger.LogInformation($"Protocol: {protocol.Name} (code: {protocol.Code})"); + } + + Assert.IsNotNull(addr); + } + } +}