From 0f6cd7307b19aa352926e87461b36c782d160972 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Fri, 12 Dec 2025 10:40:45 +0800 Subject: [PATCH 01/13] temp Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 169 +++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/OrasProject.Oras/Content/File/Store.cs diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs new file mode 100644 index 00000000..aaf4e22e --- /dev/null +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -0,0 +1,169 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Threading; + +namespace OrasProject.Oras.Content.File; + +/// +/// Provides implementation of a content store based on file system. +/// +internal static class StoreConstants +{ + internal const long DefaultFallbackPushSizeLimit = 1 << 22; // 4 MiB +} + +/// +/// Store represents a file system based store, which implements . +/// +/// +/// +/// In the file store, the contents described by names are location-addressed +/// by file paths. Meanwhile, the file paths are mapped to a virtual CAS +/// where all metadata are stored in the memory. +/// +/// +/// The contents that are not described by names are stored in a fallback storage, +/// which is a limited memory CAS by default. +/// As all the metadata are stored in the memory, the file store +/// cannot be restored from the file system. +/// +/// +/// After use, the file store needs to be closed by calling the method. +/// The file store cannot be used after being closed. +/// +/// +public class Store : IDisposable +{ + private readonly string _workingDir; // the working directory of the file store + private int _closed; // if the store is closed - 0: false, 1: true. + private readonly ConcurrentDictionary _tmpFiles = new(); + + private readonly IStorage _fallbackStorage; + private readonly ITagStore _resolver; + private readonly MemoryGraph _graph; + + /// + /// Initializes a new instance of the class, using a default limited memory CAS + /// as the fallback storage for contents without names. + /// + /// The working directory of the file store. + /// + /// When pushing content without names, the size of content being pushed + /// cannot exceed the default size limit: 4 MiB. + /// + public Store(string workingDir) + : this(workingDir, StoreConstants.DefaultFallbackPushSizeLimit) + { + } + + /// + /// Initializes a new instance of the class, using a default + /// limited memory CAS as the fallback storage for contents without names. + /// + /// The working directory of the file store. + /// The maximum size (in bytes) for pushed content without names. + /// + /// When pushing content without names, the size of content being pushed + /// cannot exceed the size limit specified by the parameter. + /// + public Store(string workingDir, long limit) + : this(workingDir, new LimitedStorage(new MemoryStorage(), limit)) + { + } + + /// + /// Initializes a new instance of the class, + /// using the provided fallback storage for contents without names. + /// + /// The working directory of the file store. + /// The fallback storage for contents without names. + /// Thrown when cannot be resolved to an absolute path. + public Store(string workingDir, IStorage fallbackStorage) + { + var workingDirAbs = Path.GetFullPath(workingDir); + + _workingDir = workingDirAbs; + _fallbackStorage = fallbackStorage; + _resolver = new MemoryTagStore(); + _graph = new MemoryGraph(); + } + + /// + /// Close closes the file store and cleans up all the temporary files used by it. + /// The store cannot be used after being closed. + /// This function is not thread-safe. + /// + /// Thrown when one or more temporary files cannot be deleted. + public void Close() + { + if (IsClosedSet()) + { + return; + } + SetClosed(); + + var errors = new List(); + foreach (var kvp in _tmpFiles) + { + try + { + System.IO.File.Delete(kvp.Key); + } + catch (Exception ex) + { + errors.Add(ex); + } + } + + if (errors.Count > 0) + { + throw new AggregateException(errors); + } + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + Close(); + GC.SuppressFinalize(this); + } + + // private static IStorage CreateLimitedStorage(long limit) + // { + // var memory = new MemoryStorage(); + // return new LimitedStorage(memory, limit); + // } + + /// + /// Returns true if the `closed` flag is set, otherwise returns false. + /// + private bool IsClosedSet() + { + return Interlocked.CompareExchange(ref _closed, 0, 0) == 1; + } + + /// + /// Sets the `closed` flag. + /// + private void SetClosed() + { + Interlocked.Exchange(ref _closed, 1); + } +} From b414187c3ffeb610d343fe6f3ef0eda84047fab3 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 15 Dec 2025 13:36:49 +0800 Subject: [PATCH 02/13] temp and added resolve Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index aaf4e22e..fef49b76 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -16,6 +16,8 @@ using System.Collections.Generic; using System.IO; using System.Threading; +using System.Threading.Tasks; +using OrasProject.Oras.Oci; namespace OrasProject.Oras.Content.File; @@ -166,4 +168,27 @@ private void SetClosed() { Interlocked.Exchange(ref _closed, 1); } + + /// + /// Resolves a reference to a descriptor. + /// + /// The reference string to resolve. + /// A token to cancel the operation. + /// The descriptor for the reference. + /// Thrown when the store is closed. + /// Thrown when the reference is null or empty. + public async Task ResolveAsync(string reference, CancellationToken cancellationToken = default) + { + if (IsClosedSet()) + { + throw new InvalidOperationException("Store is closed"); + } + + if (string.IsNullOrEmpty(reference)) + { + throw new ArgumentException("missing reference", nameof(reference)); + } + + return await _resolver.ResolveAsync(reference, cancellationToken).ConfigureAwait(false); + } } From 4640fa1e7f51be5104e5d135dcd2ec2ef5b7bda1 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 15 Dec 2025 14:56:59 +0800 Subject: [PATCH 03/13] feat: file store constructors and close Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 35 +------------ .../Content/File/StoreTest.cs | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 33 deletions(-) create mode 100644 tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index fef49b76..42b5b4fc 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -16,8 +16,6 @@ using System.Collections.Generic; using System.IO; using System.Threading; -using System.Threading.Tasks; -using OrasProject.Oras.Oci; namespace OrasProject.Oras.Content.File; @@ -147,18 +145,12 @@ public void Dispose() GC.SuppressFinalize(this); } - // private static IStorage CreateLimitedStorage(long limit) - // { - // var memory = new MemoryStorage(); - // return new LimitedStorage(memory, limit); - // } - /// /// Returns true if the `closed` flag is set, otherwise returns false. /// private bool IsClosedSet() { - return Interlocked.CompareExchange(ref _closed, 0, 0) == 1; + return Volatile.Read(ref _closed) == 1; } /// @@ -166,29 +158,6 @@ private bool IsClosedSet() /// private void SetClosed() { - Interlocked.Exchange(ref _closed, 1); - } - - /// - /// Resolves a reference to a descriptor. - /// - /// The reference string to resolve. - /// A token to cancel the operation. - /// The descriptor for the reference. - /// Thrown when the store is closed. - /// Thrown when the reference is null or empty. - public async Task ResolveAsync(string reference, CancellationToken cancellationToken = default) - { - if (IsClosedSet()) - { - throw new InvalidOperationException("Store is closed"); - } - - if (string.IsNullOrEmpty(reference)) - { - throw new ArgumentException("missing reference", nameof(reference)); - } - - return await _resolver.ResolveAsync(reference, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref _closed, 1); } } diff --git a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs new file mode 100644 index 00000000..4eaf6e8e --- /dev/null +++ b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs @@ -0,0 +1,51 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using OrasProject.Oras.Content.File; +using Xunit; + +namespace OrasProject.Oras.Tests.Content.File; + +public class StoreTest +{ + /// + /// Tests that a Store can be created with a working directory and closed successfully, + /// either by calling Close() directly or via Dispose(). + /// + [Fact] + public void CanCreateAndCloseStore() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + // Test explicit Close() + var store = new Store(tempDir); + store.Close(); + + // Test Dispose() via using statement + using (var store2 = new Store(tempDir)) + { + // Store will be disposed automatically + } + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } +} From 206fb22b1db1bf6ff78fc1079c30ba68b6a66518 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Sun, 4 Jan 2026 16:42:25 +0800 Subject: [PATCH 04/13] adding exists, tag and resolve Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 139 ++++++++++++++++-- .../Exceptions/MissingReferenceException.cs | 34 +++++ .../Exceptions/StoreClosedException.cs | 34 +++++ src/OrasProject.Oras/Oci/Descriptor.cs | 6 + 4 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 src/OrasProject.Oras/Exceptions/MissingReferenceException.cs create mode 100644 src/OrasProject.Oras/Exceptions/StoreClosedException.cs diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index 42b5b4fc..e2061875 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -16,17 +16,12 @@ using System.Collections.Generic; using System.IO; using System.Threading; +using System.Threading.Tasks; +using OrasProject.Oras.Exceptions; +using OrasProject.Oras.Oci; namespace OrasProject.Oras.Content.File; -/// -/// Provides implementation of a content store based on file system. -/// -internal static class StoreConstants -{ - internal const long DefaultFallbackPushSizeLimit = 1 << 22; // 4 MiB -} - /// /// Store represents a file system based store, which implements . /// @@ -49,9 +44,23 @@ internal static class StoreConstants /// public class Store : IDisposable { + // _defaultFallbackPushSizeLimit specifies the default size limit for pushing no-name contents. + const long _defaultFallbackPushSizeLimit = 1 << 22; // 4 MiB + + /// + /// NameStatus contains a flag indicating if a name exists, and a ReaderWriterLockSlim protecting it. + /// + private class NameStatus + { + public ReaderWriterLockSlim Lock { get; } = new(); + public bool Exists { get; set; } + } + private readonly string _workingDir; // the working directory of the file store private int _closed; // if the store is closed - 0: false, 1: true. private readonly ConcurrentDictionary _tmpFiles = new(); + private readonly ConcurrentDictionary _digestToPath = new(); + private readonly ConcurrentDictionary _nameToStatus = new(); private readonly IStorage _fallbackStorage; private readonly ITagStore _resolver; @@ -67,7 +76,7 @@ public class Store : IDisposable /// cannot exceed the default size limit: 4 MiB. /// public Store(string workingDir) - : this(workingDir, StoreConstants.DefaultFallbackPushSizeLimit) + : this(workingDir, _defaultFallbackPushSizeLimit) { } @@ -76,7 +85,7 @@ public Store(string workingDir) /// limited memory CAS as the fallback storage for contents without names. /// /// The working directory of the file store. - /// The maximum size (in bytes) for pushed content without names. + /// The maximum size (in bytes) for pushed content. /// /// When pushing content without names, the size of content being pushed /// cannot exceed the size limit specified by the parameter. @@ -92,7 +101,6 @@ public Store(string workingDir, long limit) /// /// The working directory of the file store. /// The fallback storage for contents without names. - /// Thrown when cannot be resolved to an absolute path. public Store(string workingDir, IStorage fallbackStorage) { var workingDirAbs = Path.GetFullPath(workingDir); @@ -160,4 +168,113 @@ private void SetClosed() { Volatile.Write(ref _closed, 1); } + + /// + /// Exists returns true if the described content exists. + /// + /// The descriptor of the content to check. + /// A cancellation token. + /// A task that represents the asynchronous operation. The task result contains true if the content exists; otherwise, false. + /// Thrown when the store has been closed. + public async Task ExistsAsync(Descriptor target, CancellationToken cancellationToken = default) + { + if (IsClosedSet()) + { + throw new StoreClosedException(); + } + + // if the target has name, check if the name exists. + if (target.Annotations != null && target.Annotations.TryGetValue(Descriptor.AnnotationTitle, out var name)) + { + if (!string.IsNullOrEmpty(name) && !NameExists(name)) + { + return false; + } + } + + // check if the content exists in the store + if (_digestToPath.ContainsKey(target.Digest)) + { + return true; + } + + // if the content does not exist in the store, + // then fall back to the fallback storage. + return await _fallbackStorage.ExistsAsync(target, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves a reference to a descriptor. + /// + /// The reference string to resolve. + /// A cancellation token. + /// A task that represents the asynchronous operation. The task result contains the resolved descriptor. + /// Thrown when the store has been closed. + /// Thrown when the reference is empty or null. + public async Task ResolveAsync(string reference, CancellationToken cancellationToken = default) + { + if (IsClosedSet()) + { + throw new StoreClosedException(); + } + + if (string.IsNullOrEmpty(reference)) + { + throw new MissingReferenceException(); + } + + return await _resolver.ResolveAsync(reference, cancellationToken).ConfigureAwait(false); + } + + /// + /// Tags a descriptor with a reference string. + /// + /// The descriptor of the manifest to tag. + /// The reference tag string. + /// A cancellation token. + /// A task that represents the asynchronous operation. + /// Thrown when the store has been closed. + /// Thrown when the reference is empty or null. + /// Thrown when the manifest does not exist in the store. + public async Task TagAsync(Descriptor descriptor, string reference, CancellationToken cancellationToken = default) + { + if (IsClosedSet()) + { + throw new StoreClosedException(); + } + + if (string.IsNullOrEmpty(reference)) + { + throw new MissingReferenceException(); + } + + var exists = await ExistsAsync(descriptor, cancellationToken).ConfigureAwait(false); + if (!exists) + { + throw new NotFoundException($"{descriptor.Digest}: {descriptor.MediaType}"); + } + + await _resolver.TagAsync(descriptor, reference, cancellationToken).ConfigureAwait(false); + } + + /// + /// Returns true if the given name exists in the file store. + /// + private bool NameExists(string name) + { + if (!_nameToStatus.TryGetValue(name, out var status)) + { + return false; + } + + status.Lock.EnterReadLock(); + try + { + return status.Exists; + } + finally + { + status.Lock.ExitReadLock(); + } + } } diff --git a/src/OrasProject.Oras/Exceptions/MissingReferenceException.cs b/src/OrasProject.Oras/Exceptions/MissingReferenceException.cs new file mode 100644 index 00000000..f83a9da8 --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/MissingReferenceException.cs @@ -0,0 +1,34 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// Exception thrown when a required reference is missing. +/// +public class MissingReferenceException : Exception +{ + public MissingReferenceException() : base("missing reference") + { + } + + public MissingReferenceException(string message) : base(message) + { + } + + public MissingReferenceException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/OrasProject.Oras/Exceptions/StoreClosedException.cs b/src/OrasProject.Oras/Exceptions/StoreClosedException.cs new file mode 100644 index 00000000..6eb0fc42 --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/StoreClosedException.cs @@ -0,0 +1,34 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// Exception thrown when an operation is attempted on a closed store. +/// +public class StoreClosedException : Exception +{ + public StoreClosedException() : base("store already closed") + { + } + + public StoreClosedException(string message) : base(message) + { + } + + public StoreClosedException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/OrasProject.Oras/Oci/Descriptor.cs b/src/OrasProject.Oras/Oci/Descriptor.cs index 8d4147f3..7c5b1a84 100644 --- a/src/OrasProject.Oras/Oci/Descriptor.cs +++ b/src/OrasProject.Oras/Oci/Descriptor.cs @@ -52,6 +52,12 @@ public class Descriptor [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public string? ArtifactType { get; set; } + /// + /// AnnotationTitle is the annotation key for the human-readable title of the image. + /// Specification: https://github.com/opencontainers/image-spec/blob/v1.1.0/annotations.md#pre-defined-annotation-keys + /// + public const string AnnotationTitle = "org.opencontainers.image.title"; + public static Descriptor Create(Span data, string mediaType) { byte[] byteData = data.ToArray(); From 137b3468f0afba4a3d604d892bf5365639984f61 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 5 Jan 2026 13:21:15 +0800 Subject: [PATCH 05/13] added Add method Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 103 ++++++++++++++++++ .../Exceptions/DuplicateNameException.cs | 47 ++++++++ .../Exceptions/MissingNameException.cs | 47 ++++++++ 3 files changed, 197 insertions(+) create mode 100644 src/OrasProject.Oras/Exceptions/DuplicateNameException.cs create mode 100644 src/OrasProject.Oras/Exceptions/MissingNameException.cs diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index e2061875..120d0983 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -169,6 +169,70 @@ private void SetClosed() Volatile.Write(ref _closed, 1); } + /// + /// Adds a file into the file store. Directory is not yet supported. + /// + /// The name of the file to add to the store. + /// The media type for the content. + /// The file system path to the file to add. + /// A cancellation. + /// A task that represents the asynchronous operation and contains the descriptor for the added content. + /// Thrown when the store has been closed. + /// Thrown when the name is empty or null. + /// Thrown when the name already exists in the store. + /// Thrown when the specified path does not exist. + public async Task AddAsync(string name, string mediaType, string path, CancellationToken cancellationToken = default) + { + if (IsClosedSet()) + { + throw new StoreClosedException(); + } + if (string.IsNullOrEmpty(name)) + { + throw new MissingNameException(); + } + + // check the status of the name + var status = _nameToStatus.GetOrAdd(name, _ => new NameStatus()); + + status.Lock.EnterWriteLock(); + try + { + if (status.Exists) + { + throw new DuplicateNameException($"{name}: duplicate name"); + } + if (string.IsNullOrEmpty(path)) + { + path = name; + } + + path = GetAbsolutePath(path); + // directory path is not yet supported. + var fileInfo = new FileInfo(path); + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"failed to get the file: {path}", path); + } + + // generate descriptor + Descriptor descriptor; + descriptor = await GenerateDescriptorFromFileAsync(fileInfo, mediaType, path, cancellationToken).ConfigureAwait(false); + + // Add annotation + descriptor.Annotations ??= new Dictionary(); + descriptor.Annotations[Descriptor.AnnotationTitle] = name; + + // update the name status as existed + status.Exists = true; + return descriptor; + } + finally + { + status.Lock.ExitWriteLock(); + } + } + /// /// Exists returns true if the described content exists. /// @@ -277,4 +341,43 @@ private bool NameExists(string name) status.Lock.ExitReadLock(); } } + + /// + /// Returns the absolute path for the given path. + /// + private string GetAbsolutePath(string path) + { + if (Path.IsPathRooted(path)) + { + return path; + } + return Path.Combine(_workingDir, path); + } + + /// + /// Generates a descriptor from the given file. + /// + private async Task GenerateDescriptorFromFileAsync(FileInfo fileInfo, string mediaType, string path, CancellationToken cancellationToken) + { + using var stream = fileInfo.OpenRead(); + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); + var digest = $"sha256:{Convert.ToHexString(hashBytes).ToLowerInvariant()}"; + + // map digest to file path + _digestToPath.TryAdd(digest, path); + + // generate descriptor + if (string.IsNullOrEmpty(mediaType)) + { + mediaType = Oci.MediaType.ImageLayer; + } + + return new Descriptor + { + MediaType = mediaType, + Digest = digest, + Size = fileInfo.Length + }; + } } diff --git a/src/OrasProject.Oras/Exceptions/DuplicateNameException.cs b/src/OrasProject.Oras/Exceptions/DuplicateNameException.cs new file mode 100644 index 00000000..4413551c --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/DuplicateNameException.cs @@ -0,0 +1,47 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// Exception thrown when a duplicate name is encountered. +/// +public class DuplicateNameException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public DuplicateNameException() : base("duplicate name") + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public DuplicateNameException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public DuplicateNameException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/OrasProject.Oras/Exceptions/MissingNameException.cs b/src/OrasProject.Oras/Exceptions/MissingNameException.cs new file mode 100644 index 00000000..684d2c47 --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/MissingNameException.cs @@ -0,0 +1,47 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// Exception thrown when a required name is missing. +/// +public class MissingNameException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public MissingNameException() : base("missing name") + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public MissingNameException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public MissingNameException(string message, Exception innerException) : base(message, innerException) + { + } +} From 8df72a32a853cd53673c83e3c5c4bea5bf5da05a Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 5 Jan 2026 13:45:44 +0800 Subject: [PATCH 06/13] added unit test Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 48 ++++++++++++------- .../Content/File/StoreTest.cs | 39 +++++++++++++++ 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index 120d0983..91fc2e6f 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -195,42 +195,56 @@ public async Task AddAsync(string name, string mediaType, string pat // check the status of the name var status = _nameToStatus.GetOrAdd(name, _ => new NameStatus()); - status.Lock.EnterWriteLock(); + // First, ensure the name is not already taken. + status.Lock.EnterReadLock(); try { if (status.Exists) { throw new DuplicateNameException($"{name}: duplicate name"); } - if (string.IsNullOrEmpty(path)) - { - path = name; - } + } + finally + { + status.Lock.ExitReadLock(); + } + + if (string.IsNullOrEmpty(path)) + { + path = name; + } - path = GetAbsolutePath(path); - // directory path is not yet supported. - var fileInfo = new FileInfo(path); - if (!fileInfo.Exists) + path = GetAbsolutePath(path); + // directory path is not yet supported. + var fileInfo = new FileInfo(path); + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"failed to get the file: {path}", path); + } + + // generate descriptor outside of the lock to avoid holding a write lock across awaits + var descriptor = await GenerateDescriptorFromFileAsync(fileInfo, mediaType, path, cancellationToken).ConfigureAwait(false); + + // Commit the name and annotations under the write lock to prevent races. + status.Lock.EnterWriteLock(); + try + { + if (status.Exists) { - throw new FileNotFoundException($"failed to get the file: {path}", path); + throw new DuplicateNameException($"{name}: duplicate name"); } - // generate descriptor - Descriptor descriptor; - descriptor = await GenerateDescriptorFromFileAsync(fileInfo, mediaType, path, cancellationToken).ConfigureAwait(false); - - // Add annotation descriptor.Annotations ??= new Dictionary(); descriptor.Annotations[Descriptor.AnnotationTitle] = name; - // update the name status as existed status.Exists = true; - return descriptor; } finally { status.Lock.ExitWriteLock(); } + + return descriptor; } /// diff --git a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs index 4eaf6e8e..c2a6a159 100644 --- a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs +++ b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs @@ -48,4 +48,43 @@ public void CanCreateAndCloseStore() } } } + + /// + /// Tests that a file can be added to the store, tagged, resolved, and checked for existence. + /// + [Fact] + public async Task AddTagResolveAndExists_Works() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + var tempFilePath = Path.Combine(tempDir, "test.txt"); + await System.IO.File.WriteAllTextAsync(tempFilePath, "test content"); + + using var store = new Store(tempDir); + + var descriptor = await store.AddAsync("test-artifact", string.Empty, tempFilePath); + + await store.TagAsync(descriptor, "latest"); + + var resolved = await store.ResolveAsync("latest"); + + Assert.Equal(descriptor.Digest, resolved.Digest); + + var existsForOriginal = await store.ExistsAsync(descriptor); + var existsForResolved = await store.ExistsAsync(resolved); + + Assert.True(existsForOriginal); + Assert.True(existsForResolved); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } } From 0499d0bd484fe276bb973ac98d1b77c529c07d4c Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 5 Jan 2026 13:58:25 +0800 Subject: [PATCH 07/13] added test for exceptions Signed-off-by: Xiaoxuan Wang --- .../Exceptions/DuplicateNameException.cs | 4 +-- .../Exceptions/MissingNameException.cs | 4 +-- .../Exceptions/MissingReferenceException.cs | 4 +-- .../Exceptions/StoreClosedException.cs | 4 +-- .../Exceptions/ExceptionTest.cs | 32 +++++++++++++++++++ 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/OrasProject.Oras/Exceptions/DuplicateNameException.cs b/src/OrasProject.Oras/Exceptions/DuplicateNameException.cs index 4413551c..62e0ecc4 100644 --- a/src/OrasProject.Oras/Exceptions/DuplicateNameException.cs +++ b/src/OrasProject.Oras/Exceptions/DuplicateNameException.cs @@ -31,7 +31,7 @@ public DuplicateNameException() : base("duplicate name") /// Initializes a new instance of the class with a specified error message. /// /// The message that describes the error. - public DuplicateNameException(string message) : base(message) + public DuplicateNameException(string? message) : base(message) { } @@ -41,7 +41,7 @@ public DuplicateNameException(string message) : base(message) /// /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception. - public DuplicateNameException(string message, Exception innerException) : base(message, innerException) + public DuplicateNameException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/src/OrasProject.Oras/Exceptions/MissingNameException.cs b/src/OrasProject.Oras/Exceptions/MissingNameException.cs index 684d2c47..eba7598d 100644 --- a/src/OrasProject.Oras/Exceptions/MissingNameException.cs +++ b/src/OrasProject.Oras/Exceptions/MissingNameException.cs @@ -31,7 +31,7 @@ public MissingNameException() : base("missing name") /// Initializes a new instance of the class with a specified error message. /// /// The message that describes the error. - public MissingNameException(string message) : base(message) + public MissingNameException(string? message) : base(message) { } @@ -41,7 +41,7 @@ public MissingNameException(string message) : base(message) /// /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception. - public MissingNameException(string message, Exception innerException) : base(message, innerException) + public MissingNameException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/src/OrasProject.Oras/Exceptions/MissingReferenceException.cs b/src/OrasProject.Oras/Exceptions/MissingReferenceException.cs index f83a9da8..1aafb9b8 100644 --- a/src/OrasProject.Oras/Exceptions/MissingReferenceException.cs +++ b/src/OrasProject.Oras/Exceptions/MissingReferenceException.cs @@ -24,11 +24,11 @@ public MissingReferenceException() : base("missing reference") { } - public MissingReferenceException(string message) : base(message) + public MissingReferenceException(string? message) : base(message) { } - public MissingReferenceException(string message, Exception innerException) : base(message, innerException) + public MissingReferenceException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/src/OrasProject.Oras/Exceptions/StoreClosedException.cs b/src/OrasProject.Oras/Exceptions/StoreClosedException.cs index 6eb0fc42..3514aa5f 100644 --- a/src/OrasProject.Oras/Exceptions/StoreClosedException.cs +++ b/src/OrasProject.Oras/Exceptions/StoreClosedException.cs @@ -24,11 +24,11 @@ public StoreClosedException() : base("store already closed") { } - public StoreClosedException(string message) : base(message) + public StoreClosedException(string? message) : base(message) { } - public StoreClosedException(string message, Exception innerException) : base(message, innerException) + public StoreClosedException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs b/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs index b8e99f1b..aed15fec 100644 --- a/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs +++ b/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs @@ -65,4 +65,36 @@ public async Task InvalidDateTimeFormatException() await Assert.ThrowsAsync(() => throw new InvalidDateTimeFormatException("Invalid date time format")); await Assert.ThrowsAsync(() => throw new InvalidDateTimeFormatException("Invalid date time format", null)); } + + [Fact] + public async Task DuplicateNameException() + { + await Assert.ThrowsAsync(() => throw new DuplicateNameException()); + await Assert.ThrowsAsync(() => throw new DuplicateNameException("Duplicate name")); + await Assert.ThrowsAsync(() => throw new DuplicateNameException("Duplicate name", null)); + } + + [Fact] + public async Task MissingNameException() + { + await Assert.ThrowsAsync(() => throw new MissingNameException()); + await Assert.ThrowsAsync(() => throw new MissingNameException("Missing name")); + await Assert.ThrowsAsync(() => throw new MissingNameException("Missing name", null)); + } + + [Fact] + public async Task MissingReferenceException() + { + await Assert.ThrowsAsync(() => throw new MissingReferenceException()); + await Assert.ThrowsAsync(() => throw new MissingReferenceException("Missing reference")); + await Assert.ThrowsAsync(() => throw new MissingReferenceException("Missing reference", null)); + } + + [Fact] + public async Task StoreClosedException() + { + await Assert.ThrowsAsync(() => throw new StoreClosedException()); + await Assert.ThrowsAsync(() => throw new StoreClosedException("Store closed")); + await Assert.ThrowsAsync(() => throw new StoreClosedException("Store closed", null)); + } } From 6e8474a869aeeed7f3186e1f4a70003335fe3443 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 5 Jan 2026 15:27:30 +0800 Subject: [PATCH 08/13] added unit test Signed-off-by: Xiaoxuan Wang --- .../Content/File/StoreTest.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs index c2a6a159..4d9cbe78 100644 --- a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs +++ b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs @@ -12,6 +12,8 @@ // limitations under the License. using OrasProject.Oras.Content.File; +using OrasProject.Oras.Exceptions; +using OrasProject.Oras.Oci; using Xunit; namespace OrasProject.Oras.Tests.Content.File; @@ -87,4 +89,39 @@ public async Task AddTagResolveAndExists_Works() } } } + + /// + /// Tests that after closing the store, AddAsync, TagAsync, ResolveAsync, and ExistsAsync all throw StoreClosedException. + /// + [Fact] + public async Task ClosedStore_ThrowException() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + var tempFilePath = Path.Combine(tempDir, "closed.txt"); + await System.IO.File.WriteAllTextAsync(tempFilePath, "content"); + + using var store = new Store(tempDir); + store.Close(); + + // calling Close on a closed store should not throw + store.Close(); + + // other operations should thrown StoreClosedException + await Assert.ThrowsAsync(() => store.AddAsync("name", string.Empty, tempFilePath)); + await Assert.ThrowsAsync(() => store.TagAsync(Descriptor.Empty, "latest")); + await Assert.ThrowsAsync(() => store.ResolveAsync("latest")); + await Assert.ThrowsAsync(() => store.ExistsAsync(Descriptor.Empty)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } } From 622d54a1bfb95307dbb2e3e5a0a440cc0d5a0996 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 5 Jan 2026 15:39:22 +0800 Subject: [PATCH 09/13] added more tests Signed-off-by: Xiaoxuan Wang --- .../Content/File/StoreTest.cs | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs index 4d9cbe78..ab525617 100644 --- a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs +++ b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs @@ -124,4 +124,264 @@ public async Task ClosedStore_ThrowException() } } } + + /// + /// Tests that AddAsync throws MissingNameException when name is null or empty. + /// + [Fact] + public async Task AddAsync_MissingName_ThrowsException() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + using var store = new Store(tempDir); + + await Assert.ThrowsAsync(() => store.AddAsync(null!, string.Empty, "path")); + await Assert.ThrowsAsync(() => store.AddAsync(string.Empty, string.Empty, "path")); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that AddAsync throws FileNotFoundException when the file path doesn't exist. + /// + [Fact] + public async Task AddAsync_FileNotFound_ThrowsException() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + using var store = new Store(tempDir); + var nonExistentPath = Path.Combine(tempDir, "nonexistent.txt"); + + await Assert.ThrowsAsync(() => store.AddAsync("test", string.Empty, nonExistentPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that AddAsync throws DuplicateNameException when adding the same name twice. + /// + [Fact] + public async Task AddAsync_DuplicateName_ThrowsException() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + var tempFilePath = Path.Combine(tempDir, "file.txt"); + await System.IO.File.WriteAllTextAsync(tempFilePath, "content"); + + using var store = new Store(tempDir); + + await store.AddAsync("duplicate", string.Empty, tempFilePath); + await Assert.ThrowsAsync(() => store.AddAsync("duplicate", string.Empty, tempFilePath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that AddAsync uses name as path when path parameter is empty. + /// + [Fact] + public async Task AddAsync_EmptyPath_UsesNameAsPath() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + var fileName = "testfile.txt"; + var filePath = Path.Combine(tempDir, fileName); + await System.IO.File.WriteAllTextAsync(filePath, "content"); + + using var store = new Store(tempDir); + + var descriptor = await store.AddAsync(fileName, string.Empty, string.Empty); + + Assert.NotNull(descriptor); + Assert.NotEmpty(descriptor.Digest); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that ResolveAsync throws MissingReferenceException when reference is null or empty. + /// + [Fact] + public async Task ResolveAsync_MissingReference_ThrowsException() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + using var store = new Store(tempDir); + + await Assert.ThrowsAsync(() => store.ResolveAsync(null!)); + await Assert.ThrowsAsync(() => store.ResolveAsync(string.Empty)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that TagAsync throws MissingReferenceException when reference is null or empty. + /// + [Fact] + public async Task TagAsync_MissingReference_ThrowsException() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + using var store = new Store(tempDir); + + await Assert.ThrowsAsync(() => store.TagAsync(Descriptor.Empty, null!)); + await Assert.ThrowsAsync(() => store.TagAsync(Descriptor.Empty, string.Empty)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that TagAsync throws NotFoundException when descriptor doesn't exist in store. + /// + [Fact] + public async Task TagAsync_NonExistentDescriptor_ThrowsException() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + using var store = new Store(tempDir); + + var nonExistentDescriptor = new Descriptor + { + MediaType = "application/octet-stream", + Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000", + Size = 0 + }; + + await Assert.ThrowsAsync(() => store.TagAsync(nonExistentDescriptor, "tag")); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that ExistsAsync returns false for non-existent content. + /// + [Fact] + public async Task ExistsAsync_NonExistent_ReturnsFalse() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + using var store = new Store(tempDir); + + var nonExistentDescriptor = new Descriptor + { + MediaType = "application/octet-stream", + Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000", + Size = 0 + }; + + var exists = await store.ExistsAsync(nonExistentDescriptor); + + Assert.False(exists); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + /// + /// Tests that ExistsAsync returns false when descriptor has a name annotation but the name doesn't exist. + /// + [Fact] + public async Task ExistsAsync_WithNonExistentName_ReturnsFalse() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + + try + { + using var store = new Store(tempDir); + + var descriptorWithName = new Descriptor + { + MediaType = "application/octet-stream", + Digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111", + Size = 0, + Annotations = new Dictionary + { + [Descriptor.AnnotationTitle] = "nonexistent-name" + } + }; + + var exists = await store.ExistsAsync(descriptorWithName); + + Assert.False(exists); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } } From cd340aacf2c80dba2f769b103f9b1ff0e2c05306 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 5 Jan 2026 17:22:57 +0800 Subject: [PATCH 10/13] resolved comments Signed-off-by: Xiaoxuan Wang --- tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs index ab525617..6b817280 100644 --- a/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs +++ b/tests/OrasProject.Oras.Tests/Content/File/StoreTest.cs @@ -37,7 +37,7 @@ public void CanCreateAndCloseStore() store.Close(); // Test Dispose() via using statement - using (var store2 = new Store(tempDir)) + using (new Store(tempDir)) { // Store will be disposed automatically } From d4d4527084e2f826c62b209f8ce3932e98ccc59c Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Mon, 5 Jan 2026 17:31:07 +0800 Subject: [PATCH 11/13] resolve race condition Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 66 ++++++++-------------- 1 file changed, 24 insertions(+), 42 deletions(-) diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index 91fc2e6f..7fdc0e06 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -48,11 +48,11 @@ public class Store : IDisposable const long _defaultFallbackPushSizeLimit = 1 << 22; // 4 MiB /// - /// NameStatus contains a flag indicating if a name exists, and a ReaderWriterLockSlim protecting it. + /// NameStatus contains a flag indicating if a name exists, and a SemaphoreSlim to serialize access per name. /// private class NameStatus { - public ReaderWriterLockSlim Lock { get; } = new(); + public SemaphoreSlim Semaphore { get; } = new(1, 1); public bool Exists { get; set; } } @@ -195,56 +195,44 @@ public async Task AddAsync(string name, string mediaType, string pat // check the status of the name var status = _nameToStatus.GetOrAdd(name, _ => new NameStatus()); - // First, ensure the name is not already taken. - status.Lock.EnterReadLock(); + // Serialize access per name to prevent duplicate expensive I/O + await status.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { + // Check if name already exists if (status.Exists) { throw new DuplicateNameException($"{name}: duplicate name"); } - } - finally - { - status.Lock.ExitReadLock(); - } - - if (string.IsNullOrEmpty(path)) - { - path = name; - } - path = GetAbsolutePath(path); - // directory path is not yet supported. - var fileInfo = new FileInfo(path); - if (!fileInfo.Exists) - { - throw new FileNotFoundException($"failed to get the file: {path}", path); - } - - // generate descriptor outside of the lock to avoid holding a write lock across awaits - var descriptor = await GenerateDescriptorFromFileAsync(fileInfo, mediaType, path, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(path)) + { + path = name; + } - // Commit the name and annotations under the write lock to prevent races. - status.Lock.EnterWriteLock(); - try - { - if (status.Exists) + path = GetAbsolutePath(path); + // directory path is not yet supported. + var fileInfo = new FileInfo(path); + if (!fileInfo.Exists) { - throw new DuplicateNameException($"{name}: duplicate name"); + throw new FileNotFoundException($"failed to get the file: {path}", path); } + // Generate descriptor (file I/O happens here) + var descriptor = await GenerateDescriptorFromFileAsync(fileInfo, mediaType, path, cancellationToken).ConfigureAwait(false); + + // Commit the name and annotations descriptor.Annotations ??= new Dictionary(); descriptor.Annotations[Descriptor.AnnotationTitle] = name; status.Exists = true; + + return descriptor; } finally { - status.Lock.ExitWriteLock(); + status.Semaphore.Release(); } - - return descriptor; } /// @@ -345,15 +333,9 @@ private bool NameExists(string name) return false; } - status.Lock.EnterReadLock(); - try - { - return status.Exists; - } - finally - { - status.Lock.ExitReadLock(); - } + // For read-only check, we can safely read the volatile bool without the semaphore + // since Exists is only ever set from false to true (never reset) + return status.Exists; } /// From 5f39d9021520638153b087d0ca9870db4fa865f4 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Wed, 14 Jan 2026 11:59:12 +0800 Subject: [PATCH 12/13] dispose semaphoreSlims Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index 7fdc0e06..ff266fa4 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -50,10 +50,15 @@ public class Store : IDisposable /// /// NameStatus contains a flag indicating if a name exists, and a SemaphoreSlim to serialize access per name. /// - private class NameStatus + private class NameStatus : IDisposable { public SemaphoreSlim Semaphore { get; } = new(1, 1); public bool Exists { get; set; } + + public void Dispose() + { + Semaphore.Dispose(); + } } private readonly string _workingDir; // the working directory of the file store @@ -64,7 +69,6 @@ private class NameStatus private readonly IStorage _fallbackStorage; private readonly ITagStore _resolver; - private readonly MemoryGraph _graph; /// /// Initializes a new instance of the class, using a default limited memory CAS @@ -108,7 +112,6 @@ public Store(string workingDir, IStorage fallbackStorage) _workingDir = workingDirAbs; _fallbackStorage = fallbackStorage; _resolver = new MemoryTagStore(); - _graph = new MemoryGraph(); } /// @@ -138,6 +141,19 @@ public void Close() } } + // Dispose all semaphores + foreach (var kvp in _nameToStatus) + { + try + { + kvp.Value.Dispose(); + } + catch (Exception ex) + { + errors.Add(ex); + } + } + if (errors.Count > 0) { throw new AggregateException(errors); From 2e2fc05e084baf41c8f551212ecade1cc3ab55e6 Mon Sep 17 00:00:00 2001 From: Xiaoxuan Wang Date: Wed, 14 Jan 2026 14:03:55 +0800 Subject: [PATCH 13/13] added semaphore against close operation Signed-off-by: Xiaoxuan Wang --- src/OrasProject.Oras/Content/File/Store.cs | 248 +++++++++++++-------- 1 file changed, 153 insertions(+), 95 deletions(-) diff --git a/src/OrasProject.Oras/Content/File/Store.cs b/src/OrasProject.Oras/Content/File/Store.cs index ff266fa4..a26d9625 100644 --- a/src/OrasProject.Oras/Content/File/Store.cs +++ b/src/OrasProject.Oras/Content/File/Store.cs @@ -70,6 +70,9 @@ public void Dispose() private readonly IStorage _fallbackStorage; private readonly ITagStore _resolver; + private readonly SemaphoreSlim _operationSemaphore = new(1, 1); // Protects against concurrent Close() + private int _operationCount; // Tracks active operations + /// /// Initializes a new instance of the class, using a default limited memory CAS /// as the fallback storage for contents without names. @@ -117,46 +120,56 @@ public Store(string workingDir, IStorage fallbackStorage) /// /// Close closes the file store and cleans up all the temporary files used by it. /// The store cannot be used after being closed. - /// This function is not thread-safe. /// /// Thrown when one or more temporary files cannot be deleted. public void Close() { - if (IsClosedSet()) - { - return; - } - SetClosed(); - - var errors = new List(); - foreach (var kvp in _tmpFiles) + _operationSemaphore.Wait(); + try { - try + if (IsClosedSet()) { - System.IO.File.Delete(kvp.Key); + return; } - catch (Exception ex) + SetClosed(); + + // Wait for all active operations to complete + SpinWait.SpinUntil(() => Volatile.Read(ref _operationCount) == 0); + + var errors = new List(); + foreach (var kvp in _tmpFiles) { - errors.Add(ex); + try + { + System.IO.File.Delete(kvp.Key); + } + catch (Exception ex) + { + errors.Add(ex); + } } - } - // Dispose all semaphores - foreach (var kvp in _nameToStatus) - { - try + // Dispose all semaphores + foreach (var kvp in _nameToStatus) { - kvp.Value.Dispose(); + try + { + kvp.Value.Dispose(); + } + catch (Exception ex) + { + errors.Add(ex); + } } - catch (Exception ex) + + if (errors.Count > 0) { - errors.Add(ex); + throw new AggregateException(errors); } } - - if (errors.Count > 0) + finally { - throw new AggregateException(errors); + _operationSemaphore.Release(); } } @@ -166,6 +179,7 @@ public void Close() public void Dispose() { Close(); + _operationSemaphore.Dispose(); GC.SuppressFinalize(this); } @@ -199,55 +213,66 @@ private void SetClosed() /// Thrown when the specified path does not exist. public async Task AddAsync(string name, string mediaType, string path, CancellationToken cancellationToken = default) { - if (IsClosedSet()) - { - throw new StoreClosedException(); - } - if (string.IsNullOrEmpty(name)) - { - throw new MissingNameException(); - } - - // check the status of the name - var status = _nameToStatus.GetOrAdd(name, _ => new NameStatus()); + await _operationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _operationCount); + _operationSemaphore.Release(); - // Serialize access per name to prevent duplicate expensive I/O - await status.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - // Check if name already exists - if (status.Exists) + if (IsClosedSet()) { - throw new DuplicateNameException($"{name}: duplicate name"); + throw new StoreClosedException(); } - - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrEmpty(name)) { - path = name; + throw new MissingNameException(); } - path = GetAbsolutePath(path); - // directory path is not yet supported. - var fileInfo = new FileInfo(path); - if (!fileInfo.Exists) + // check the status of the name + var status = _nameToStatus.GetOrAdd(name, _ => new NameStatus()); + + // Serialize access per name to prevent duplicate expensive I/O + await status.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - throw new FileNotFoundException($"failed to get the file: {path}", path); + // Check if name already exists + if (status.Exists) + { + throw new DuplicateNameException($"{name}: duplicate name"); + } + + if (string.IsNullOrEmpty(path)) + { + path = name; + } + + path = GetAbsolutePath(path); + // directory path is not yet supported. + var fileInfo = new FileInfo(path); + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"failed to get the file: {path}", path); + } + + // Generate descriptor (file I/O happens here) + var descriptor = await GenerateDescriptorFromFileAsync(fileInfo, mediaType, path, cancellationToken).ConfigureAwait(false); + + // Commit the name and annotations + descriptor.Annotations ??= new Dictionary(); + descriptor.Annotations[Descriptor.AnnotationTitle] = name; + + status.Exists = true; + + return descriptor; + } + finally + { + status.Semaphore.Release(); } - - // Generate descriptor (file I/O happens here) - var descriptor = await GenerateDescriptorFromFileAsync(fileInfo, mediaType, path, cancellationToken).ConfigureAwait(false); - - // Commit the name and annotations - descriptor.Annotations ??= new Dictionary(); - descriptor.Annotations[Descriptor.AnnotationTitle] = name; - - status.Exists = true; - - return descriptor; } finally { - status.Semaphore.Release(); + Interlocked.Decrement(ref _operationCount); } } @@ -260,29 +285,40 @@ public async Task AddAsync(string name, string mediaType, string pat /// Thrown when the store has been closed. public async Task ExistsAsync(Descriptor target, CancellationToken cancellationToken = default) { - if (IsClosedSet()) - { - throw new StoreClosedException(); - } + await _operationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _operationCount); + _operationSemaphore.Release(); - // if the target has name, check if the name exists. - if (target.Annotations != null && target.Annotations.TryGetValue(Descriptor.AnnotationTitle, out var name)) + try { - if (!string.IsNullOrEmpty(name) && !NameExists(name)) + if (IsClosedSet()) { - return false; + throw new StoreClosedException(); } - } - // check if the content exists in the store - if (_digestToPath.ContainsKey(target.Digest)) + // if the target has name, check if the name exists. + if (target.Annotations != null && target.Annotations.TryGetValue(Descriptor.AnnotationTitle, out var name)) + { + if (!string.IsNullOrEmpty(name) && !NameExists(name)) + { + return false; + } + } + + // check if the content exists in the store + if (_digestToPath.ContainsKey(target.Digest)) + { + return true; + } + + // if the content does not exist in the store, + // then fall back to the fallback storage. + return await _fallbackStorage.ExistsAsync(target, cancellationToken).ConfigureAwait(false); + } + finally { - return true; + Interlocked.Decrement(ref _operationCount); } - - // if the content does not exist in the store, - // then fall back to the fallback storage. - return await _fallbackStorage.ExistsAsync(target, cancellationToken).ConfigureAwait(false); } /// @@ -295,17 +331,28 @@ public async Task ExistsAsync(Descriptor target, CancellationToken cancell /// Thrown when the reference is empty or null. public async Task ResolveAsync(string reference, CancellationToken cancellationToken = default) { - if (IsClosedSet()) + await _operationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _operationCount); + _operationSemaphore.Release(); + + try { - throw new StoreClosedException(); - } + if (IsClosedSet()) + { + throw new StoreClosedException(); + } + + if (string.IsNullOrEmpty(reference)) + { + throw new MissingReferenceException(); + } - if (string.IsNullOrEmpty(reference)) + return await _resolver.ResolveAsync(reference, cancellationToken).ConfigureAwait(false); + } + finally { - throw new MissingReferenceException(); + Interlocked.Decrement(ref _operationCount); } - - return await _resolver.ResolveAsync(reference, cancellationToken).ConfigureAwait(false); } /// @@ -320,23 +367,34 @@ public async Task ResolveAsync(string reference, CancellationToken c /// Thrown when the manifest does not exist in the store. public async Task TagAsync(Descriptor descriptor, string reference, CancellationToken cancellationToken = default) { - if (IsClosedSet()) - { - throw new StoreClosedException(); - } + await _operationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _operationCount); + _operationSemaphore.Release(); - if (string.IsNullOrEmpty(reference)) + try { - throw new MissingReferenceException(); - } + if (IsClosedSet()) + { + throw new StoreClosedException(); + } + + if (string.IsNullOrEmpty(reference)) + { + throw new MissingReferenceException(); + } + + var exists = await ExistsAsync(descriptor, cancellationToken).ConfigureAwait(false); + if (!exists) + { + throw new NotFoundException($"{descriptor.Digest}: {descriptor.MediaType}"); + } - var exists = await ExistsAsync(descriptor, cancellationToken).ConfigureAwait(false); - if (!exists) + await _resolver.TagAsync(descriptor, reference, cancellationToken).ConfigureAwait(false); + } + finally { - throw new NotFoundException($"{descriptor.Digest}: {descriptor.MediaType}"); + Interlocked.Decrement(ref _operationCount); } - - await _resolver.TagAsync(descriptor, reference, cancellationToken).ConfigureAwait(false); } ///