/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Di.Server.SoftwareUpdate;
using Opc.Ua.Server;
namespace Opc.Ua.Di.Server.Builders
{
///
/// Implements the OPC 10000-5 §11.4 TemporaryFileTransferType
/// server protocol for the DI software-update facet's
/// PackageLoadingType.FileTransfer slot.
///
///
///
/// The flow per spec:
///
///
/// -
/// Client calls GenerateFileForWrite(generateOptions).
/// The server allocates a fresh handle, creates a transient
/// child of the FileTransfer object
/// backed by an in-memory buffer, registers it with the node
/// manager, and returns its NodeId + handle.
///
/// -
/// Client opens the returned FileState in
/// Write|EraseExisting mode, streams chunks via the
/// standard Write method, and closes it.
///
/// -
/// Client calls CloseAndCommit(handle). The server
/// resolves the buffered bytes, constructs a
/// , hands the payload to
/// , and removes
/// the transient FileState from the address space.
///
///
///
/// Each handle is owned by the session that allocated it; cross-
/// session access is rejected with
/// . The per-instance
/// handle count is capped at
/// to bound memory usage.
///
///
internal sealed class SoftwareUpdateFileTransferManager : IDisposable
{
///
/// Maximum concurrent upload handles per FileTransfer instance.
///
internal const int MaxConcurrentHandles = 8;
///
/// Maximum buffered upload size in bytes (64 MiB).
///
internal const long MaxUploadSizeBytes = 64L * 1024 * 1024;
/// OPC 10000-5 §11.3.3 — Open mode Write|EraseExisting.
private const byte OpenModeWriteEraseExisting = 6;
private readonly TemporaryFileTransferState m_fileTransfer;
private readonly DiNodeManager m_manager;
private readonly ISoftwarePackageStore m_packageStore;
private readonly ILogger m_logger;
private readonly object m_gate = new();
private readonly Dictionary m_slots = [];
private uint m_nextHandle;
private bool m_disposed;
public SoftwareUpdateFileTransferManager(
TemporaryFileTransferState fileTransfer,
DiNodeManager manager,
ISoftwarePackageStore packageStore,
ILogger logger)
{
m_fileTransfer = fileTransfer ?? throw new ArgumentNullException(nameof(fileTransfer));
m_manager = manager ?? throw new ArgumentNullException(nameof(manager));
m_packageStore = packageStore ?? throw new ArgumentNullException(nameof(packageStore));
m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (m_fileTransfer.GenerateFileForWrite != null)
{
m_fileTransfer.GenerateFileForWrite.OnCall = OnGenerateFileForWrite;
}
if (m_fileTransfer.CloseAndCommit != null)
{
m_fileTransfer.CloseAndCommit.OnCall = OnCloseAndCommit;
}
}
public void Dispose()
{
lock (m_gate)
{
if (m_disposed)
{
return;
}
m_disposed = true;
foreach (UploadSlot slot in m_slots.Values)
{
slot.Dispose();
}
m_slots.Clear();
}
}
private ServiceResult OnGenerateFileForWrite(
ISystemContext context,
MethodState method,
NodeId objectId,
Variant generateOptions,
ref NodeId fileNodeId,
ref uint fileHandle)
{
_ = method;
_ = objectId;
NodeId? sessionId = SessionIdOf(context);
UploadSlot slot;
lock (m_gate)
{
if (m_disposed)
{
fileNodeId = NodeId.Null;
fileHandle = 0;
return StatusCodes.BadInvalidState;
}
if (m_slots.Count >= MaxConcurrentHandles)
{
fileNodeId = NodeId.Null;
fileHandle = 0;
return ServiceResult.Create(
StatusCodes.BadTooManyOperations,
"Concurrent upload handle limit reached.");
}
fileHandle = ++m_nextHandle;
slot = new UploadSlot(
fileHandle, sessionId, generateOptions, m_logger);
try
{
slot.AttachFileState(
m_manager,
m_fileTransfer,
OpenSlot,
WriteSlot,
ReadSlot,
CloseSlot,
GetPositionSlot,
SetPositionSlot);
}
catch (Exception ex)
{
m_logger.LogError(
ex,
"Failed to create transient FileState for upload handle {Handle}.",
fileHandle);
slot.Dispose();
fileNodeId = NodeId.Null;
fileHandle = 0;
return ServiceResult.Create(
ex, StatusCodes.BadInternalError,
"Failed to create transient upload FileState.");
}
m_slots[fileHandle] = slot;
fileNodeId = slot.FileNodeId;
}
return ServiceResult.Good;
}
private ServiceResult OnCloseAndCommit(
ISystemContext context,
MethodState method,
NodeId objectId,
uint fileHandle,
ref NodeId completionStateMachine)
{
_ = method;
_ = objectId;
UploadSlot slot;
byte[] payload;
lock (m_gate)
{
if (m_disposed)
{
completionStateMachine = NodeId.Null;
return StatusCodes.BadInvalidState;
}
if (!TryGetOwnedSlotLocked(context, fileHandle, out slot, out ServiceResult err))
{
completionStateMachine = NodeId.Null;
return err;
}
payload = slot.SnapshotPayload();
m_slots.Remove(fileHandle);
}
try
{
SoftwarePackage metadata = BuildPackageMetadata(slot, payload);
using var payloadStream = new MemoryStream(payload, writable: false);
_ = m_packageStore.AddAsync(metadata, payloadStream, CancellationToken.None)
.AsTask()
.GetAwaiter()
.GetResult();
m_logger.LogInformation(
"Committed upload handle {Handle} ({Bytes} bytes) as package {PackageId}.",
fileHandle, payload.Length, metadata.Id);
}
catch (Exception ex)
{
m_logger.LogError(
ex,
"Failed to commit upload handle {Handle} to the package store.",
fileHandle);
slot.Dispose();
completionStateMachine = NodeId.Null;
return ServiceResult.Create(
ex, StatusCodes.BadUserAccessDenied,
"Failed to persist the uploaded package.");
}
finally
{
slot.DetachFromAddressSpace(m_manager);
slot.Dispose();
}
completionStateMachine = NodeId.Null;
return ServiceResult.Good;
}
private ServiceResult OpenSlot(
ISystemContext context,
MethodState method,
NodeId objectId,
byte mode,
ref uint fileHandle)
{
_ = method;
_ = objectId;
if (mode != OpenModeWriteEraseExisting)
{
fileHandle = 0;
return ServiceResult.Create(
StatusCodes.BadNotSupported,
"Transient upload files only support Write|EraseExisting (mode 6).");
}
lock (m_gate)
{
UploadSlot? slot = FindSlotByFileObject(method.Parent);
if (slot is null)
{
fileHandle = 0;
return StatusCodes.BadNotFound;
}
NodeId? sessionId = SessionIdOf(context);
if (slot.OwnerSessionId != null && sessionId != null
&& slot.OwnerSessionId != sessionId)
{
fileHandle = 0;
return ServiceResult.Create(
StatusCodes.BadUserAccessDenied,
"File handle is owned by another session.");
}
if (slot.IsOpen)
{
fileHandle = 0;
return ServiceResult.Create(
StatusCodes.BadInvalidState,
"Transient upload file is already open.");
}
slot.OpenForWrite();
fileHandle = slot.Handle;
}
return ServiceResult.Good;
}
private ServiceResult WriteSlot(
ISystemContext context,
MethodState method,
NodeId objectId,
uint fileHandle,
ByteString data)
{
_ = method;
_ = objectId;
lock (m_gate)
{
if (!TryGetOwnedSlotLocked(context, fileHandle, out UploadSlot slot, out ServiceResult err))
{
return err;
}
if (!slot.IsOpen)
{
return ServiceResult.Create(
StatusCodes.BadInvalidState,
"Transient upload file is not open.");
}
if (data.IsNull || data.Span.Length == 0)
{
return ServiceResult.Good;
}
if (slot.BufferedBytes + data.Span.Length > MaxUploadSizeBytes)
{
return ServiceResult.Create(
StatusCodes.BadOutOfMemory,
"Upload payload exceeds the configured maximum size.");
}
slot.AppendWrite(data.Span);
}
return ServiceResult.Good;
}
private ServiceResult ReadSlot(
ISystemContext context,
MethodState method,
NodeId objectId,
uint fileHandle,
int length,
ref ByteString data)
{
_ = context;
_ = method;
_ = objectId;
_ = fileHandle;
_ = length;
// Write-only transient files don't support read.
data = default;
return ServiceResult.Create(
StatusCodes.BadNotSupported,
"Transient upload files are write-only.");
}
private ServiceResult CloseSlot(
ISystemContext context,
MethodState method,
NodeId objectId,
uint fileHandle)
{
_ = method;
_ = objectId;
lock (m_gate)
{
if (!TryGetOwnedSlotLocked(context, fileHandle, out UploadSlot slot, out ServiceResult err))
{
return err;
}
if (!slot.IsOpen)
{
return ServiceResult.Create(
StatusCodes.BadInvalidState,
"Transient upload file is already closed.");
}
slot.Close();
}
return ServiceResult.Good;
}
private ServiceResult GetPositionSlot(
ISystemContext context,
MethodState method,
NodeId objectId,
uint fileHandle,
ref ulong position)
{
_ = method;
_ = objectId;
lock (m_gate)
{
if (!TryGetOwnedSlotLocked(context, fileHandle, out UploadSlot slot, out ServiceResult err))
{
position = 0;
return err;
}
position = (ulong)slot.BufferedBytes;
}
return ServiceResult.Good;
}
private ServiceResult SetPositionSlot(
ISystemContext context,
MethodState method,
NodeId objectId,
uint fileHandle,
ulong position)
{
_ = method;
_ = objectId;
lock (m_gate)
{
if (!TryGetOwnedSlotLocked(context, fileHandle, out UploadSlot slot, out ServiceResult err))
{
return err;
}
if (position > (ulong)slot.BufferedBytes)
{
return ServiceResult.Create(
StatusCodes.BadInvalidArgument,
"Requested position exceeds file length.");
}
slot.Seek((long)position);
}
return ServiceResult.Good;
}
private static NodeId? SessionIdOf(ISystemContext context)
{
return (context as ISessionSystemContext)?.SessionId;
}
private bool TryGetOwnedSlotLocked(
ISystemContext context,
uint fileHandle,
out UploadSlot slot,
out ServiceResult error)
{
if (!m_slots.TryGetValue(fileHandle, out UploadSlot? located))
{
slot = null!;
error = ServiceResult.Create(
StatusCodes.BadInvalidArgument, "Unknown file handle.");
return false;
}
NodeId? expected = SessionIdOf(context);
if (located.OwnerSessionId != null && expected != null
&& located.OwnerSessionId != expected)
{
slot = null!;
error = ServiceResult.Create(
StatusCodes.BadUserAccessDenied,
"File handle is owned by another session.");
return false;
}
slot = located;
error = ServiceResult.Good;
return true;
}
private UploadSlot? FindSlotByFileObject(NodeState? parent)
{
if (parent is null)
{
return null;
}
foreach (UploadSlot slot in m_slots.Values)
{
if (object.ReferenceEquals(slot.FileObject, parent))
{
return slot;
}
}
return null;
}
private static SoftwarePackage BuildPackageMetadata(
UploadSlot slot, byte[] payload)
{
string suggestedId = slot.ExtractSuggestedId();
return new SoftwarePackage(
Id: string.IsNullOrEmpty(suggestedId)
? $"upload-{slot.Handle}-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}"
: suggestedId,
Version: string.Empty,
Vendor: string.Empty,
Description: "Uploaded via FileTransfer",
SizeBytes: payload.LongLength,
CreatedAt: DateTimeOffset.UtcNow,
Hash: string.Empty);
}
private sealed class UploadSlot : IDisposable
{
private readonly Variant m_generateOptions;
private readonly ILogger m_logger;
private readonly MemoryStream m_buffer = new();
private bool m_disposed;
public UploadSlot(
uint handle,
NodeId? ownerSessionId,
Variant generateOptions,
ILogger logger)
{
Handle = handle;
OwnerSessionId = ownerSessionId;
m_generateOptions = generateOptions;
m_logger = logger;
}
public uint Handle { get; }
public NodeId? OwnerSessionId { get; }
public FileState? FileObject { get; private set; }
public NodeId FileNodeId =>
FileObject?.NodeId ?? NodeId.Null;
public bool IsOpen { get; private set; }
public long BufferedBytes => m_buffer.Length;
public void AttachFileState(
DiNodeManager manager,
TemporaryFileTransferState parent,
OpenMethodStateMethodCallHandler openHandler,
WriteMethodStateMethodCallHandler writeHandler,
ReadMethodStateMethodCallHandler readHandler,
CloseMethodStateMethodCallHandler closeHandler,
GetPositionMethodStateMethodCallHandler getPositionHandler,
SetPositionMethodStateMethodCallHandler setPositionHandler)
{
ServerSystemContext ctx = manager.SystemContext;
var browseName = new QualifiedName(
$"UploadFile_{Handle}", manager.DiNamespaceIndex);
FileState file = ctx.CreateInstanceOfFileType(parent, browseName);
file.SymbolicName = browseName.Name ?? string.Empty;
file.BrowseName = browseName;
file.DisplayName = new LocalizedText(browseName.Name);
file.NodeId = ctx.NodeIdFactory.New(ctx, file);
file.ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent;
file.ModellingRuleId = NodeId.Null;
if (file.Writable != null)
{
file.Writable.Value = true;
}
if (file.UserWritable != null)
{
file.UserWritable.Value = true;
}
if (file.Size != null)
{
file.Size.Value = 0;
}
if (file.OpenCount != null)
{
file.OpenCount.Value = 0;
}
if (file.MimeType != null)
{
file.MimeType.Value = "application/octet-stream";
}
if (file.Open != null)
{
file.Open.OnCall = openHandler;
}
if (file.Write != null)
{
file.Write.OnCall = writeHandler;
}
if (file.Read != null)
{
file.Read.OnCall = readHandler;
}
if (file.Close != null)
{
file.Close.OnCall = closeHandler;
}
if (file.GetPosition != null)
{
file.GetPosition.OnCall = getPositionHandler;
}
if (file.SetPosition != null)
{
file.SetPosition.OnCall = setPositionHandler;
}
parent.AddChild(file);
manager.AddPredefinedNodeAsync(file, CancellationToken.None)
.AsTask()
.GetAwaiter()
.GetResult();
FileObject = file;
}
public void DetachFromAddressSpace(DiNodeManager manager)
{
FileState? file = FileObject;
if (file is null)
{
return;
}
try
{
manager.DeleteNodeAsync(manager.SystemContext, file.NodeId)
.AsTask()
.GetAwaiter()
.GetResult();
}
catch (Exception ex)
{
m_logger.LogDebug(
ex,
"Failed to delete transient upload FileState {NodeId}; will rely on dispose to release the buffer.",
file.NodeId);
}
FileObject = null;
}
public void OpenForWrite()
{
m_buffer.Position = 0;
m_buffer.SetLength(0);
IsOpen = true;
if (FileObject?.OpenCount != null)
{
FileObject.OpenCount.Value = 1;
}
}
public void AppendWrite(ReadOnlySpan chunk)
{
byte[] copy = chunk.ToArray();
m_buffer.Write(copy, 0, copy.Length);
if (FileObject?.Size != null)
{
FileObject.Size.Value = (ulong)m_buffer.Length;
}
}
public void Seek(long position)
{
m_buffer.Position = position;
}
public void Close()
{
IsOpen = false;
if (FileObject?.OpenCount != null)
{
FileObject.OpenCount.Value = 0;
}
}
public byte[] SnapshotPayload()
{
return m_buffer.ToArray();
}
public string ExtractSuggestedId()
{
// The OPC 10000-100 spec lets vendors pass a vendor-defined
// generateOptions structure; we don't parse it for v1, but
// expose a string passthrough so a calling application can
// bake their package id directly into the request.
if (m_generateOptions.TryGetValue(out string s) && !string.IsNullOrEmpty(s))
{
return s;
}
return string.Empty;
}
public void Dispose()
{
if (m_disposed)
{
return;
}
m_disposed = true;
IsOpen = false;
m_buffer.Dispose();
}
}
}
}