/**
 * @file   FOBJCLD.c
 * @brief  FOBJ SPI sample -- Object store backed by AWS S3 / S3-compatible
 *         storage (libcurl)
 * @author limes datentechnik gmbh
 *
 * DISCLAIMER: This file is a sample implementation provided by limes
 * datentechnik gmbh for educational and integration purposes.  It is
 * delivered as source code free of charge, including with the FL5 DEMO
 * license, and may be used, adapted, and redistributed without restriction.
 * This sample has NOT passed through the full limes datentechnik gmbh QA
 * process.  Users are responsible for verifying correctness, security, and
 * fitness for their specific environment before deploying it in production.
 * limes datentechnik gmbh provides NO WARRANTY of any kind for this sample
 * and is not liable for any damages arising from its use, including but not
 * limited to data loss, security vulnerabilities, or service interruptions.
 *
 * DESIGN: PARAMETER vs NAME separation
 *
 *   FOBJ SPI receives two distinct identifiers per open call:
 *
 *   pcParStr (PARAMETER=) -- static, deployment-specific configuration.
 *     Contains everything that describes WHERE and HOW to access the S3
 *     service: bucket name, AWS region, endpoint URL, credentials, and
 *     tuning parameters (timeout, prefix).  These values are the same for
 *     every archive stored in one S3 deployment and typically do not change
 *     between invocations.  Because they include credentials they must be
 *     treated as security-sensitive (see CREDENTIAL PROTECTION below).
 *
 *   pcNamStr (NAME=) -- dynamic, per-archive identifier.
 *     The logical name of the specific archive being opened, e.g.
 *     "backups/hr/payroll/2026".  This string becomes the namespace
 *     component in every S3 object key written for this archive (see OBJECT
 *     KEY FORMAT below).  It is not security-sensitive and changes with
 *     every new archive.
 *
 *   Separating these two concerns keeps credential management independent of
 *   the archive name: many different archives (pcNamStr values) can be stored
 *   in the same S3 bucket using a single shared PARAMETER file.
 *
 * CREDENTIAL PROTECTION:
 *
 *   The PARAMETER field is a CLEP DYNSTR, which means flcl accepts the
 *   special syntax PARAMETER=f'<path>' to read the parameter string at
 *   runtime from a file instead of passing it on the command line.  This
 *   is the recommended approach for production use:
 *
 *   1. Create a protected configuration directory:
 *        mkdir -m 0700 ~/.cloud
 *
 *   2. Create a per-deployment credential file, e.g. ~/.cloud/s3-prod.txt:
 *        bucket=my-bucket;region=us-east-1;
 *        access-key=AKIAIOSFODNN7EXAMPLE;
 *        secret-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
 *      Protect it:
 *        chmod 0600 ~/.cloud/s3-prod.txt
 *
 *   3. Reference it in the CLEP command:
 *        store.objuser(
 *            LIBRARY='libfobjcld',
 *            FUNC='FOBJUSR',
 *            PARAMETER=f'~/.cloud/s3-prod.txt',
 *            NAME='backups/hr/payroll/2026')
 *
 *   This pattern keeps credentials out of command lines, shell history,
 *   and audit logs.  It mirrors the ~/.ssh/ convention used for SSH keys.
 *   On z/OS use a SAF-protected dataset or HFS file in the home directory.
 *
 *   When FOBJ_FLAG_STRONG is set, the parameter string was explicitly
 *   approved in RACF (FOBJPAR resource class) and the audit trail is
 *   complete -- even then, prefer the f'filename' form so credentials do
 *   not appear in SMF records or operator logs verbatim.
 *
 * PLATFORM RESTRICTION:
 * This sample is designed for ASCII platforms (Linux, Windows, AIX, Solaris).
 * It must NOT be compiled or deployed on z/OS USS (__USS__) or z/OS MVS
 * (__ZOS__), because all string literals passed to libcurl (HTTP header names,
 * CURLOPT_AWS_SIGV4 service/region strings, credential strings) are EBCDIC on
 * those platforms, whereas libcurl always expects ASCII input.  z/OS users who
 * need cloud object store access should use the built-in store.s3() qualifier
 * (CSRC/SEGSTOR/OBJCLDS3.c), which handles EBCDIC/ASCII conversion internally.
 *
 * REQUIREMENTS FOR DYNAMIC LIBCURL LOADING:
 * - libcurl >= 7.75.0 (February 2021) for CURLOPT_AWS_SIGV4 support.
 * - libcurl is loaded at runtime via dlopen; no compile-time dependency.
 * REQUIREMENTS FOR STATIC LIBCURL LINK:
 * - libcurl is statically linked; no run-time dependency.
 *
 * CHANGELOG:
 * 1.0.1: Remove the compile-time curl header dependency; the curl types are forward-declared
 * 1.1.0: Adopt the FOBJ SPI NEW/FRE/EXS lifecycle (FOBJ_VERSION 0x0200)
 * 1.2.0: Fix DeleteObjects: canonical query string "?delete=" and <Quiet>true</Quiet>
 * 1.3.0: Bound the name length before snprintf into acPrefix in fobjcld_stateless_init
 * 1.4.0: Fix the pF leak in fobjcld_opn when an EXS call precedes OPN on a NEW handle
 * 1.5.0: link libcurl statically by default, to avoid dynamic-library incompatibilities
 * 1.6.0: Refuse at open when this libcurl has no CURLOPT_AWS_SIGV4
 * 1.7.0: Free the TsFobjCurlFuncs of a prior EXS outside the dynload guard; FRE closes the handle
 * 1.7.1: Decide dynamic loading on FOBJCLD_DYNLOAD_LIBCURL, not on ONLY_DYNLOAD_LIBCURL
 * 1.7.2: Name the libcurl entry point types and cast the fetched symbols to them
 * 1.7.3: Declare the setopt and getinfo call types variadic, as libcurl does
 * 1.7.4: Decide dynamic loading on the platform alone
 * 1.8.0: Configure the trust anchor: the ca-bundle parameter, FL_CA_BUNDLE, or the OS store
 * 1.9.0: Explain the curl errors a caller can act on: trust anchor, name, port, timeout
 * 1.10.0: ca-path parameter for a hashed anchor directory, as Solaris 11 has
 *
 * USAGE (CLEP):
 * @code
 *   store.objuser(
 *       LIBRARY='libfobjcld',
 *       FUNC='FOBJUSR',
 *       PARAMETER=f'~/.cloud/s3-prod.txt',
 *       NAME='backups/hr/payroll/2026')
 * @endcode
 *
 * PARAMETER string keys (static, per-deployment -- store in a protected file):
 *   bucket     (required) S3 bucket name
 *   region     (optional, default us-east-1) AWS region for SigV4 scope
 *   endpoint   (optional, default https://s3.amazonaws.com) base URL
 *   access-key (optional, falls back to AWS_ACCESS_KEY_ID env var)
 *   secret-key (optional, falls back to AWS_SECRET_ACCESS_KEY env var)
 *   prefix     (optional, default "") fixed key prefix prepended to namespace
 *   timeout    (optional, default 30) HTTP timeout in seconds
 *   ca-bundle  (optional, falls back to FL_CA_BUNDLE env var) PEM file with
 *              the trust anchors for the endpoint.  Needed on every platform
 *              whose libcurl was cross-built, which is all of them except
 *              native Linux: there is no default anchor there, so without
 *              this and without a usable operating-system store every https
 *              request fails verification.  On Windows the system store is
 *              consulted when this is unset
 *   ca-path    (optional) DIRECTORY of hashed trust anchors, for a system
 *              that ships no bundle file -- Solaris 11 has only
 *              /etc/openssl/certs.  Composes with ca-bundle rather than
 *              replacing it
 *   curllib    (optional) libcurl filename for dlopen; platform default used
 *              if omitted; honoured only where libcurl is loaded at runtime
 *              (linx32, winx32, z/OS, USS -- see FOBJCLD_DYNLOAD_LIBCURL)
 *
 * NAME string (pcNamStr, dynamic, per-archive -- passed directly in CLEP):
 *   The logical namespace for the archive, e.g. "backups/hr/payroll/2026".
 *   Becomes the {namespace} component in every S3 object key.  Multiple
 *   archives can coexist in the same bucket simply by using distinct names.
 *
 * OBJECT KEY FORMAT:
 *   {prefix}{namespace}/{version:020lld}/{opaque_key}
 *
 * Example (bucket=my-bucket, NAME=backups/hr/payroll/2026):
 *   my-bucket/backups/hr/payroll/2026/00000000000000000001/SEG0000042
 *
 ******************************************************************************/

#include <stdlib.h>
#include <string.h>
#include <stdio.h>

#include "FOBJ.h"

/* Where there is no static libcurl to link against, this sample loads it at
 * runtime, and that is z/OS and USS: FL5 itself reaches HTTP through the IBM
 * z/OS enabler and links FLHTTPHZ, but this sample talks to libcurl directly
 * and the host build gives libfobjcld only -lzz. The condition used to name
 * ONLY_DYNLOAD_LIBCURL as well; that define was set nowhere and went with
 * FLHTTPLC 5.2.10. Keeping an own macro rather than reusing FL5's still keeps
 * the two questions apart -- "how does FL5 do HTTP" and "can this sample link
 * libcurl statically". */
#if defined(__ZOS__) || defined(__USS__)
#  define FOBJCLD_DYNLOAD_LIBCURL
#endif

#ifdef FOBJCLD_DYNLOAD_LIBCURL
/* =========================================================================
 * Platform dynamic loading layer (no DYL.h dependency)
 * ========================================================================= */

#if defined(_WIN32) || defined(_WIN64)
#  include <windows.h>
   typedef HMODULE TDylHdl;
   static TDylHdl     dyl_open(const char* p)            { return LoadLibraryA(p); }
   static void*       dyl_sym(TDylHdl h, const char* n)  { return (void*)GetProcAddress(h, n); }
   static void        dyl_close(TDylHdl h)               { FreeLibrary(h); }
   static const char* dyl_err(void)                       { return "LoadLibrary failed"; }
#elif defined(__ZOS__)
#  include <dll.h>
   typedef dllhandle* TDylHdl;
   static TDylHdl     dyl_open(const char* p)            { return dllload(p); }
   static void*       dyl_sym(TDylHdl h, const char* n)  { return (void*)dllqueryfn(h, n); }
   static void        dyl_close(TDylHdl h)               { dllfree(h); }
   static const char* dyl_err(void)                       { return "dllload failed"; }
#else
#  include <dlfcn.h>
   typedef void*      TDylHdl;
   static TDylHdl     dyl_open(const char* p)            { return dlopen(p, RTLD_NOW | RTLD_LOCAL); }
   static void*       dyl_sym(TDylHdl h, const char* n)  { return dlsym(h, n); }
   static void        dyl_close(TDylHdl h)               { dlclose(h); }
   static const char* dyl_err(void)                       { return dlerror(); }
#endif

/* =========================================================================
 * Default libcurl library name per platform
 * ========================================================================= */

#if defined(_WIN32) || defined(_WIN64)
#  define FOBJCLD_DEFAULT_LIB  "libcurl.dll"
#elif defined(__ZOS__)
#  define FOBJCLD_DEFAULT_LIB  "libcurl.so"
#elif defined(__APPLE__)
#  define FOBJCLD_DEFAULT_LIB  "libcurl.dylib"
#else
#  define FOBJCLD_DEFAULT_LIB  "libcurl.so.4"
#endif
#else
#include "curl/curl.h"
#include "curl/easy.h"
#endif
/* =========================================================================
 * Minimal curl ABI constants and types (no <curl/curl.h> needed)
 *
 * All numeric values are from the stable libcurl ABI (unchanged since 7.1).
 * Pattern mirrors FLREST.c -- void* for handles, int for return/option codes.
 * ========================================================================= */

/* CURLoption values: LONG=0..9999, OBJECTPOINT=10000..19999,
 * FUNCTIONPOINT=20000..29999, OFF_T=30000..39999               */
#define FOBJCLD_OPT_WRITEDATA        10001
#define FOBJCLD_OPT_URL              10002
#define FOBJCLD_OPT_USERPWD          10005
#define FOBJCLD_OPT_READDATA         10009
#define FOBJCLD_OPT_TIMEOUT              13
#define FOBJCLD_OPT_WRITEFUNCTION    20011
#define FOBJCLD_OPT_READFUNCTION     20012
#define FOBJCLD_OPT_HTTPHEADER       10023
#define FOBJCLD_OPT_CUSTOMREQUEST    10036
#define FOBJCLD_OPT_NOBODY               44
#define FOBJCLD_OPT_UPLOAD               46
#define FOBJCLD_OPT_POST                 47
#define FOBJCLD_OPT_POSTFIELDSIZE        60
#define FOBJCLD_OPT_INFILESIZE_LARGE  30115
#define FOBJCLD_OPT_AWS_SIGV4        10305
/* CURLOPT_CAINFO: 10000 (CURLOPTTYPE_STRINGPOINT) + 65
 * CURLOPT_SSL_OPTIONS: 0 (CURLOPTTYPE_LONG) + 216
 * CURLSSLOPT_NATIVE_CA: 1 << 4
 * Spelled out because this sample deliberately does not include curl.h --
 * it is meant to be readable without the library's headers to hand. */
#define FOBJCLD_OPT_CAINFO           10065
/* CURLOPT_CAPATH: 10000 + 97. A DIRECTORY of hashed anchors, which is what
 * Solaris 11 has and no bundle file -- OpenSSL looks each certificate up as
 * <subject-hash>.0 there. */
#define FOBJCLD_OPT_CAPATH           10097
#define FOBJCLD_OPT_SSL_OPTIONS        216
#define FOBJCLD_SSLOPT_NATIVE_CA        16

/* CURLINFO values: LONG=0x200000, OFF_T=0x600000               */
#define FOBJCLD_INFO_RESPONSE_CODE     0x200002
#define FOBJCLD_INFO_CONTENT_LENGTH_T  0x60000F

#define FOBJCLD_CURLE_OK  0

/* The libcurl results this sample can say something useful about. Numeric for
 * the same reason as the FOBJCLD_OPT_* values above: a sample must build
 * without curl.h so that a customer can read it as one file. */
#define FOBJCLD_CURLE_RESOLVE      6   /* CURLE_COULDNT_RESOLVE_HOST       */
#define FOBJCLD_CURLE_CONNECT      7   /* CURLE_COULDNT_CONNECT            */
#define FOBJCLD_CURLE_TIMEOUT     28   /* CURLE_OPERATION_TIMEDOUT         */
#define FOBJCLD_CURLE_TLSCONNECT  35   /* CURLE_SSL_CONNECT_ERROR          */
#define FOBJCLD_CURLE_PEERVERIFY  60   /* CURLE_PEER_FAILED_VERIFICATION   */
#define FOBJCLD_CURLE_CACERTFILE  77   /* CURLE_SSL_CACERT_BADFILE         */

/* Minimal curl_slist -- same member layout as the real struct.
 * Used only as an opaque pointer; members are only here for correct
 * struct size in case any future code inspects them. */
/* cppcheck-suppress-begin unusedStructMember */
typedef struct TsFobjCurlSlist {
   char*                  data;
   struct TsFobjCurlSlist* next;
} TsFobjCurlSlist;
/* cppcheck-suppress-end unusedStructMember */

/* Call types for the variadic curl_easy_setopt / curl_easy_getinfo.
 *
 * The ellipsis has to stay. Declaring a fixed third parameter instead lets gcc
 * omit the ELFv2 parameter save area on Power little-endian, and va_start
 * inside libcurl then spills its register arguments into a frame that was
 * never allocated. See the same note in CSRC/FLHTTPLC.c. One type per entry
 * point is all there is to declare: with the ellipsis, the value type is not
 * part of the function type any more, so splitting these by argument width
 * would say nothing. The width is carried by the casts in the FSETx and FGETx
 * macros below, which is where it belongs.
 */
typedef int (*TfFobjSet)(void* h, int opt,  ...);
typedef int (*TfFobjGet)(void* h, int info, ...);

/* =========================================================================
 * Provider-local return codes
 * (FOBJ_RTC_NFD = 1 is reserved in FOBJ.h for "object not found" from GET)
 * ========================================================================= */

/** @brief HTTP / curl transport error. */
#define FOBJCLD_RTC_CON  2
/** @brief Memory allocation failure. */
#define FOBJCLD_RTC_ALC  3
/** @brief Bad or missing parameter in pcParStr. */
#define FOBJCLD_RTC_PAR  4

/* =========================================================================
 * curl function pointer table
 * ========================================================================= */

/** @brief Dynamically loaded libcurl entry points. */
/* The entry points get names of their own, so that a symbol fetched as void*
 * can be cast to the type it is assigned to. Assigning an object pointer to a
 * function pointer is not allowed in C, and XL C says so -- CCN3068, seven
 * times per build once FOBJCLD 1.7.1 compiled the dynamic-load path on z/OS
 * and USS. clang stays silent about it, which is why it took the host to show
 * it. Same construct as CSRC/FLAMCLE.c:1345 for the HTML document plug-in. */
typedef void*            (*TfFobjCurlInit)(void);
typedef void             (*TfFobjCurlCleanup)(void*);
typedef int              (*TfFobjCurlPerform)(void*);
typedef const char*      (*TfFobjCurlStrerror)(int);
typedef TsFobjCurlSlist* (*TfFobjCurlSlistAppend)(TsFobjCurlSlist*, const char*);
typedef void             (*TfFobjCurlSlistFree)(TsFobjCurlSlist*);
typedef void             (*TfFobjCurlReset)(void*);

typedef struct FobjCurlFuncs
{
#ifdef FOBJCLD_DYNLOAD_LIBCURL
   TDylHdl             hLib;
#endif
   TfFobjCurlInit         curl_easy_init;
   TfFobjCurlCleanup      curl_easy_cleanup;
   void*                  curl_easy_setopt;   /* cast via TfFobjSet            */
   TfFobjCurlPerform      curl_easy_perform;
   void*                  curl_easy_getinfo;  /* cast via TfFobjGet            */
   TfFobjCurlStrerror     curl_easy_strerror;
   TfFobjCurlSlistAppend  curl_slist_append;
   TfFobjCurlSlistFree    curl_slist_free_all;
   TfFobjCurlReset        curl_easy_reset;
} TsFobjCurlFuncs;

/* Convenience call macros -- pHdl is TsFobjS3Hdl* */
#define FSETL(h,opt,val) ((TfFobjSet)(h)->pF->curl_easy_setopt)((h)->pCurl,(opt),(long)(val))
#define FSETP(h,opt,ptr) ((TfFobjSet)(h)->pF->curl_easy_setopt)((h)->pCurl,(opt),(const void*)(ptr))
#define FSETF(h,opt,fn)  ((TfFobjSet)(h)->pF->curl_easy_setopt)((h)->pCurl,(opt),(void*)(fn))
#define FSETO(h,opt,val) ((TfFobjSet)(h)->pF->curl_easy_setopt)((h)->pCurl,(opt),(long long)(val))
#define FGETL(h,info,pv) ((TfFobjGet)(h)->pF->curl_easy_getinfo)((h)->pCurl,(info),(pv))
#define FGETO(h,info,pv) ((TfFobjGet)(h)->pF->curl_easy_getinfo)((h)->pCurl,(info),(pv))

#ifdef FOBJCLD_DYNLOAD_LIBCURL
/**
 * @brief Load libcurl dynamically and resolve all required entry points.
 *
 * @param pF       Pointer to function table to fill.
 * @param pcLib    Library filename (e.g. "libcurl.so.4").
 * @param pcMsg    Buffer for error message.
 * @param uiMsgLen Capacity of pcMsg in bytes.
 * @return 0 on success, FOBJCLD_RTC_CON on failure.
 */
static int fobjcld_load(TsFobjCurlFuncs* pF, const char* pcLib,
                        char* pcMsg, unsigned int uiMsgLen)
{
   pF->hLib = dyl_open(pcLib);
   if (pF->hLib == NULL) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen,
                  "FOBJCLD: dlopen('%s') failed: %s", pcLib, dyl_err());
      }
      return FOBJCLD_RTC_CON;
   }

#define FOBJCLD_LOAD_SYM(name, type)                              \
   do {                                                            \
      pF->name = (type)dyl_sym(pF->hLib, #name);                   \
      if (pF->name == NULL) {                                      \
         if (pcMsg != NULL) {                                      \
            snprintf(pcMsg, uiMsgLen,                              \
                     "FOBJCLD: Symbol '%s' not found in '%s'",     \
                     #name, pcLib);                                 \
         }                                                         \
         dyl_close(pF->hLib);                                      \
         pF->hLib = NULL;                                          \
         return FOBJCLD_RTC_CON;                                   \
      }                                                            \
   } while (0)

   FOBJCLD_LOAD_SYM(curl_easy_init, TfFobjCurlInit);
   FOBJCLD_LOAD_SYM(curl_easy_cleanup, TfFobjCurlCleanup);
   FOBJCLD_LOAD_SYM(curl_easy_setopt, void*);
   FOBJCLD_LOAD_SYM(curl_easy_perform, TfFobjCurlPerform);
   FOBJCLD_LOAD_SYM(curl_easy_getinfo, void*);
   FOBJCLD_LOAD_SYM(curl_easy_strerror, TfFobjCurlStrerror);
   FOBJCLD_LOAD_SYM(curl_slist_append, TfFobjCurlSlistAppend);
   FOBJCLD_LOAD_SYM(curl_slist_free_all, TfFobjCurlSlistFree);
   FOBJCLD_LOAD_SYM(curl_easy_reset, TfFobjCurlReset);

#undef FOBJCLD_LOAD_SYM

   return 0;
}
#else
static void fobjcld_finit(TsFobjCurlFuncs* pF)
{
   /* Cast through the named types here as well: the non-variadic entry points
    * differ from libcurl's prototypes in their handle and code types, so the
    * conversion is real and saying so is better than letting one compiler
    * shrug at it. setopt and getinfo carry libcurl's ellipsis and go through
    * void*, as dyl_sym() delivers them on the other branch. */
   pF->curl_easy_init       = (TfFobjCurlInit)&curl_easy_init;
   pF->curl_easy_cleanup    = (TfFobjCurlCleanup)&curl_easy_cleanup;
   pF->curl_easy_setopt     = (void*)&curl_easy_setopt;
   pF->curl_easy_perform    = (TfFobjCurlPerform)&curl_easy_perform;
   pF->curl_easy_getinfo    = (void*)&curl_easy_getinfo;
   pF->curl_easy_strerror   = (TfFobjCurlStrerror)&curl_easy_strerror;
   pF->curl_slist_append    = (TfFobjCurlSlistAppend)&curl_slist_append;
   pF->curl_slist_free_all  = (TfFobjCurlSlistFree)&curl_slist_free_all;
   pF->curl_easy_reset      = (TfFobjCurlReset)&curl_easy_reset;
}
#endif
/* =========================================================================
 * Provider handle structure
 * ========================================================================= */

/** @brief Internal handle maintained across OPN/GET/PUT/DEL/CHK/CLS calls. */
typedef struct FobjS3Hdl
{
   TsFobjCurlFuncs* pF;              /**< Loaded curl function pointers.     */
   void*            pCurl;           /**< curl easy handle (opaque).         */
   char             acBucket[256];   /**< S3 bucket name.                    */
   char             acRegion[64];    /**< AWS region (for SigV4 scope).      */
   char             acEndpoint[512]; /**< Base URL (no trailing slash).      */
   char             acAccessKey[128];/**< AWS access key ID.                 */
   char             acSecretKey[128];/**< AWS secret access key.             */
   /** PEM with the trust anchors; empty = ask the platform.  See
    *  fobjcld_setup_request() for why a sample needs this at all.         */
   char             acCaBundle[512];
   char             acCaPath[512];
   /** Full key prefix: "{user_prefix}{namespace}/{version:020lld}/"         */
   char             acPrefix[768];
   /** Canonical name for ppcName output (endpoint/bucket/prefix).           */
   char             acName[1600];    /**< endpoint+bucket+prefix combined. */
   char             acNam[1024];     /**< Namespace from FOBJ_FUNC_NEW.       */
   char             acPar[1024];     /**< Param string from FOBJ_FUNC_NEW.    */
   int              iWrite;          /**< 1 = write mode, 0 = read mode.     */
   int              iTimeout;        /**< HTTP timeout in seconds.           */
   /** Return code of the CURLOPT_AWS_SIGV4 setopt of the last request setup.
    *  Non-zero means this libcurl cannot sign, see fobjcld_setup_request(). */
   int              iSigV4Cc;
   unsigned char*   pcGetBuf;        /**< GET locate buffer (provider-owned).*/
   unsigned int     uiGetBufLen;     /**< Number of valid bytes in pcGetBuf. */
   long long        siObjPut;        /**< Objects written.                   */
   long long        siDatPut;        /**< Bytes written.                     */
   long long        siObjGet;        /**< Objects read.                      */
   long long        siDatGet;        /**< Bytes read.                        */
   long long        siObjDel;        /**< Objects deleted.                   */
} TsFobjS3Hdl;

/* =========================================================================
 * curl write / read callback helpers
 * ========================================================================= */

/** @brief Dynamic buffer filled by the curl write callback. */
typedef struct FobjBuf
{
   unsigned char* pcData; /**< Allocated data buffer.       */
   unsigned int   uiLen;  /**< Number of valid bytes.       */
   unsigned int   uiCap;  /**< Allocated capacity in bytes. */
} TsFobjBuf;

/** @brief Fixed read buffer consumed by the curl read callback. */
typedef struct FobjReadBuf
{
   const unsigned char* pcData; /**< Source data pointer.          */
   unsigned int         uiLen;  /**< Total length of source data.  */
   unsigned int         uiOff;  /**< Current read offset in bytes. */
} TsFobjReadBuf;

/**
 * @brief curl CURLOPT_WRITEFUNCTION callback: appends data to TsFobjBuf.
 */
/*
 * One message for a failed libcurl call -- with what it means and what to do.
 *
 * WHY A HELPER AND NOT SIX snprintf CALLS. "SSL peer certificate or SSH
 * remote key was not OK" names the symptom and leaves the reader with
 * nothing to do, and the decision it points at -- which trust anchor to use
 * -- is one this provider deliberately leaves to its caller. Measured
 * 2026-09-22: lpbe64, lple64, linz64 and sols64 all failed here, and the
 * report said "curl error" and nothing else. A message that names the
 * EXPLANATION and the SOLUTION turns a support call into a configuration
 * change.
 *
 * The labels are twelve characters wide before the colon, which is the FL5
 * convention for a diagnostic line, so the text lines up with the error
 * trace it is embedded in.
 */
static void fobjcld_curl_msg(char* pcMsg, unsigned int uiMsgLen,
                             const char* pcWhat, int iCc, const char* pcErr)
{
   const char* pcExp = NULL;
   const char* pcSol = NULL;

   if (pcMsg == NULL || uiMsgLen == 0U) return;

   switch (iCc) {
      case FOBJCLD_CURLE_PEERVERIFY:
         pcExp = "the server certificate could not be verified, and this"
                 " libcurl has no default trust anchor -- its configure finds"
                 " one only on a NATIVE build, so Windows, Solaris, Linux on"
                 " Power and Linux on Z have none at all";
         pcSol = "name a PEM bundle: the 'ca-bundle' parameter of this"
                 " provider, or the FL_CA_BUNDLE variable. Usual paths are"
                 " /etc/ssl/certs/ca-certificates.crt (Debian, SUSE) and"
                 " /etc/pki/tls/certs/ca-bundle.crt (Red Hat)";
         break;
      case FOBJCLD_CURLE_CACERTFILE:
         pcExp = "the CA bundle that was named cannot be read as one";
         pcSol = "check path, permission and format (PEM) of the 'ca-bundle'"
                 " parameter or of FL_CA_BUNDLE";
         break;
      case FOBJCLD_CURLE_RESOLVE:
         pcExp = "the host name of the endpoint does not resolve";
         pcSol = "check the 'endpoint' parameter and the name service of this"
                 " machine";
         break;
      case FOBJCLD_CURLE_CONNECT:
         pcExp = "nothing accepted the connection on that host and port";
         pcSol = "check host and port in the 'endpoint' parameter, and whether"
                 " a firewall or a proxy sits in between";
         break;
      case FOBJCLD_CURLE_TIMEOUT:
         pcExp = "the request did not finish within its timeout";
         pcSol = "check the path to the endpoint; a slow or loaded link needs"
                 " a longer timeout than the default";
         break;
      case FOBJCLD_CURLE_TLSCONNECT:
         pcExp = "the TLS handshake failed before any certificate was seen";
         pcSol = "check that the endpoint speaks TLS on that port, and that"
                 " protocol version and cipher suites have an overlap";
         break;
      default:
         break;
   }
   if (pcExp != NULL) {
      snprintf(pcMsg, uiMsgLen,
               "FOBJCLD: %s curl error: %s\n"
               "EXPLANATION : %s\n"
               "   SOLUTION : %s\n",
               pcWhat, pcErr, pcExp, pcSol);
   } else {
      snprintf(pcMsg, uiMsgLen, "FOBJCLD: %s curl error: %s", pcWhat, pcErr);
   }
}

static size_t fobjcld_write_cb(void* pvPtr, size_t szSz, size_t szNmemb,
                                void* pvUd)
{
   TsFobjBuf*    pBuf  = (TsFobjBuf*)pvUd;
   size_t        szLen = szSz * szNmemb;
   unsigned int  uiNew;
   unsigned char* pcTmp;

   if (szLen == 0) {
      return 0;
   }

   uiNew = pBuf->uiLen + (unsigned int)szLen;
   if (uiNew > pBuf->uiCap) {
      unsigned int uiCap2 = uiNew + 65536u;
      pcTmp = (unsigned char*)realloc(pBuf->pcData, uiCap2);
      if (pcTmp == NULL) {
         return 0; /* returning < szLen signals error to curl */
      }
      pBuf->pcData = pcTmp;
      pBuf->uiCap  = uiCap2;
   }
   memcpy(pBuf->pcData + pBuf->uiLen, pvPtr, szLen);
   pBuf->uiLen = uiNew;
   return szLen;
}

/**
 * @brief curl CURLOPT_READFUNCTION callback: supplies bytes from TsFobjReadBuf.
 */
static size_t fobjcld_read_cb(void* pvPtr, size_t szSz, size_t szNmemb,
                               void* pvUd)
{
   TsFobjReadBuf* pRd   = (TsFobjReadBuf*)pvUd;
   size_t         szCap = szSz * szNmemb;
   size_t         szRem = (size_t)(pRd->uiLen - pRd->uiOff);
   size_t         szCpy;

   if (szCap == 0 || szRem == 0) {
      return 0;
   }
   szCpy = (szCap < szRem) ? szCap : szRem;
   memcpy(pvPtr, pRd->pcData + pRd->uiOff, szCpy);
   pRd->uiOff += (unsigned int)szCpy;
   return szCpy;
}

/* =========================================================================
 * URL construction helper
 * ========================================================================= */

/**
 * @brief Build a full S3 object URL from endpoint, bucket, prefix and key.
 *
 * @param pHdl     Provider handle (supplies endpoint, bucket, prefix).
 * @param pcKey    Object key suffix (may be NULL or empty for bucket URL).
 * @param pcUrl    Output buffer.
 * @param uiUrlLen Capacity of pcUrl in bytes.
 */
static void fobjcld_build_url(const TsFobjS3Hdl* pHdl, const char* pcKey,
                               char* pcUrl, unsigned int uiUrlLen)
{
   /* This sample always uses path-style URLs: {endpoint}/{bucket}/{key}.
    * Virtual-hosted style ({bucket}.{hostname}/{key}) is not implemented.
    * Path-style is required by SeaweedFS and most S3-compatible services;
    * AWS S3 also supports it (though virtual-hosted is the AWS default).
    * The "path-style" parameter in the parameter string is accepted for
    * forward compatibility but has no effect since path-style is always used.
    *
    * EBCDIC portability note: the format strings below are ASCII-safe because
    * they consist of only '/', '%', 's' -- all of which map to the same byte
    * in both ASCII and EBCDIC.  No fix is needed here. */
   if (pcKey != NULL && pcKey[0] != '\0') {
      snprintf(pcUrl, uiUrlLen, "%s/%s/%s%s",
               pHdl->acEndpoint, pHdl->acBucket, pHdl->acPrefix, pcKey);
   } else {
      snprintf(pcUrl, uiUrlLen, "%s/%s",
               pHdl->acEndpoint, pHdl->acBucket);
   }
}

/* =========================================================================
 * Request setup helper
 * ========================================================================= */

/**
 * @brief Reset curl handle and configure URL, timeout, and AWS SigV4.
 *
 * Must be called before every HTTP operation.  Clears all options set by
 * a previous call so each operation starts from a clean state.
 *
 * @param pHdl     Provider handle.
 * @param pcUrl    Full request URL.
 * @param iTimeout Timeout in seconds (use pHdl->iTimeout normally).
 */
static void fobjcld_setup_request(TsFobjS3Hdl* pHdl, const char* pcUrl,
                                   int iTimeout)
{
   char acSigv4[128];
   char acCreds[260];

   pHdl->pF->curl_easy_reset(pHdl->pCurl);
   FSETP(pHdl, FOBJCLD_OPT_URL,     pcUrl);
   FSETL(pHdl, FOBJCLD_OPT_TIMEOUT, iTimeout);

   /* AWS Signature V4 -- requires libcurl >= 7.75.0 (February 2021).
    * Keep the return code: an older libcurl does not know option 10305 and
    * answers CURLE_UNKNOWN_OPTION.  Ignoring that sends every request
    * UNSIGNED, and the server answers 403 -- which reads like wrong
    * credentials and is not.  The caller of the first request evaluates
    * iSigV4Cc and says what is really wrong.                             */
   snprintf(acSigv4, sizeof(acSigv4), "aws:amz:%s:s3", pHdl->acRegion);
   pHdl->iSigV4Cc = FSETP(pHdl, FOBJCLD_OPT_AWS_SIGV4, acSigv4);

   /* credentials: "accessKey:secretKey" */
   snprintf(acCreds, sizeof(acCreds), "%s:%s",
            pHdl->acAccessKey, pHdl->acSecretKey);
   FSETP(pHdl, FOBJCLD_OPT_USERPWD, acCreds);

   /* --- where the trust anchor comes from ------------------------------
    * A sample provider has to configure this, and this one did not.  It
    * cost a real failure: on Windows every request came back
    * CURLE_PEER_FAILED_VERIFICATION ("SSL peer certificate ... was not
    * OK"), because libcurl is built --without-ca-bundle and configure
    * detects a CA path only on a native build -- so the Windows, Solaris
    * and Linux-on-Power builds have no default anchor at all, not even for
    * a publicly trusted certificate.  It passed on Linux for the one
    * reason that the native build there does have /etc/ssl/certs baked in.
    *
    * Two tiers, which is what a provider owns:
    *   1  the ca-bundle parameter (a FILE) or ca-path (a hashed DIRECTORY),
    *      else FL_CA_BUNDLE -- the caller decides
    *   2  the operating system store on Windows
    * FLREST has a third for the well-known UNIX paths; that list belongs in
    * a library and not in a sample, so a UNIX box without a default anchor
    * has to name the anchor here.
    *
    * BOTH FORMS EXIST BECAUSE BOTH OCCUR. Most systems ship a concatenated
    * PEM file, Solaris 11 ships only the hashed directory /etc/openssl/certs
    * and no bundle at all -- naming a file there is not possible. They are
    * not exclusive: libcurl consults CAINFO and CAPATH both.
    *
    * The options are set on every request because curl_easy_reset() above
    * clears them, like the credentials. */
   {
      const char* pcCa = (pHdl->acCaBundle[0] != '\0')
                         ? pHdl->acCaBundle : getenv("FL_CA_BUNDLE");
      if (pHdl->acCaPath[0] != '\0') {
         FSETP(pHdl, FOBJCLD_OPT_CAPATH, pHdl->acCaPath);
      }
      if (pcCa != NULL && pcCa[0] != '\0') {
         FSETP(pHdl, FOBJCLD_OPT_CAINFO, pcCa);
      } else if (pHdl->acCaPath[0] == '\0') {
#if defined(_WIN32) || defined(_WIN64)
         /* CURLSSLOPT_NATIVE_CA works with the OpenSSL backend too and not
          * only with Schannel: libcurl loads the ROOT and CA system stores
          * into OpenSSL's store.  It composes with CAINFO, so a private PEM
          * can be added to the system store rather than replacing it. */
         FSETL(pHdl, FOBJCLD_OPT_SSL_OPTIONS, FOBJCLD_SSLOPT_NATIVE_CA);
#endif
      }
   }
}

/* =========================================================================
 * Parameter string parser
 * ========================================================================= */

/**
 * @brief Parse a semicolon-separated "key=value" parameter string.
 *
 * Fills the fields of pHdl that correspond to configurable parameters.
 * The bucket key is required; all others have documented defaults.
 * Credentials fall back to environment variables when not given in the
 * parameter string.
 *
 * @param pcParStr  Null-terminated parameter string from CLEP.
 * @param pcCurlLib Output buffer (FOBJCLD_DEFAULT_LIB size) for curllib.
 * @param pHdl      Handle to fill.
 * @param pcMsg     Buffer for error message.
 * @param uiMsgLen  Capacity of pcMsg in bytes.
 * @return 0 on success, FOBJCLD_RTC_PAR if a required key is missing.
 */
static int fobjcld_parse_params(const char* pcParStr,
#ifdef FOBJCLD_DYNLOAD_LIBCURL
   char* pcCurlLib,
#endif
   TsFobjS3Hdl* pHdl,
   char* pcMsg,
   unsigned int uiMsgLen)
{
   char        acCopy[4096];
   char*       pcTok;
   char*       pcSave;
   char*       pcEq;
   const char* pcVal;
   const char* pcEnv;

   /* Defaults */
   strncpy(pHdl->acRegion,   "us-east-1",            sizeof(pHdl->acRegion)   - 1);
   strncpy(pHdl->acEndpoint, "https://s3.amazonaws.com", sizeof(pHdl->acEndpoint) - 1);
   pHdl->acRegion[sizeof(pHdl->acRegion)     - 1] = '\0';
   pHdl->acEndpoint[sizeof(pHdl->acEndpoint) - 1] = '\0';
   pHdl->acAccessKey[0] = '\0';
   pHdl->acSecretKey[0] = '\0';
   pHdl->acBucket[0]    = '\0';
   pHdl->iTimeout       = 30;
#ifdef FOBJCLD_DYNLOAD_LIBCURL
   strncpy(pcCurlLib, FOBJCLD_DEFAULT_LIB, 255);
   pcCurlLib[255] = '\0';
#endif
   if (pcParStr == NULL || pcParStr[0] == '\0') {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen,
                  "FOBJCLD: Parameter string is empty; 'bucket' is required");
      }
      return FOBJCLD_RTC_PAR;
   }

   strncpy(acCopy, pcParStr, sizeof(acCopy) - 1);
   acCopy[sizeof(acCopy) - 1] = '\0';

   pcTok = acCopy;
   while (pcTok != NULL && *pcTok != '\0') {
      /* find next ';' delimiter */
      pcSave = strchr(pcTok, ';');
      if (pcSave != NULL) {
         *pcSave = '\0';
         pcSave++;
      }

      /* find '=' within token */
      pcEq = strchr(pcTok, '=');
      if (pcEq != NULL) {
         *pcEq = '\0';
         pcVal = pcEq + 1;

         if (strcmp(pcTok, "bucket") == 0) {
            strncpy(pHdl->acBucket, pcVal, sizeof(pHdl->acBucket) - 1);
            pHdl->acBucket[sizeof(pHdl->acBucket) - 1] = '\0';
         } else if (strcmp(pcTok, "region") == 0) {
            strncpy(pHdl->acRegion, pcVal, sizeof(pHdl->acRegion) - 1);
            pHdl->acRegion[sizeof(pHdl->acRegion) - 1] = '\0';
         } else if (strcmp(pcTok, "endpoint") == 0) {
            strncpy(pHdl->acEndpoint, pcVal, sizeof(pHdl->acEndpoint) - 1);
            pHdl->acEndpoint[sizeof(pHdl->acEndpoint) - 1] = '\0';
            /* strip trailing slash */
            {
               size_t szLen = strlen(pHdl->acEndpoint);
               if (szLen > 0 && pHdl->acEndpoint[szLen - 1] == '/') {
                  pHdl->acEndpoint[szLen - 1] = '\0';
               }
            }
         } else if (strcmp(pcTok, "access-key") == 0) {
            strncpy(pHdl->acAccessKey, pcVal, sizeof(pHdl->acAccessKey) - 1);
            pHdl->acAccessKey[sizeof(pHdl->acAccessKey) - 1] = '\0';
         } else if (strcmp(pcTok, "secret-key") == 0) {
            strncpy(pHdl->acSecretKey, pcVal, sizeof(pHdl->acSecretKey) - 1);
            pHdl->acSecretKey[sizeof(pHdl->acSecretKey) - 1] = '\0';
         } else if (strcmp(pcTok, "ca-bundle") == 0) {
            strncpy(pHdl->acCaBundle, pcVal, sizeof(pHdl->acCaBundle) - 1);
            pHdl->acCaBundle[sizeof(pHdl->acCaBundle) - 1] = '\0';
         } else if (strcmp(pcTok, "ca-path") == 0) {
            strncpy(pHdl->acCaPath, pcVal, sizeof(pHdl->acCaPath) - 1);
            pHdl->acCaPath[sizeof(pHdl->acCaPath) - 1] = '\0';
         } else if (strcmp(pcTok, "prefix") == 0) {
            /* stored temporarily in acPrefix; OPN prepends it to namespace */
            strncpy(pHdl->acPrefix, pcVal, sizeof(pHdl->acPrefix) - 1);
            pHdl->acPrefix[sizeof(pHdl->acPrefix) - 1] = '\0';
         } else if (strcmp(pcTok, "timeout") == 0) {
            char*  pcEnd2 = NULL;
            long   lTmo   = strtol(pcVal, &pcEnd2, 10);
            pHdl->iTimeout = (pcEnd2 != pcVal && lTmo > 0 && lTmo <= 3600)
                             ? (int)lTmo : 30;
         } else if (strcmp(pcTok, "path-style") == 0) {
            /* path-style=1 (default): URL = endpoint/bucket/key
             * path-style=0: virtual-hosted style (bucket.hostname/key) --
             *   not implemented in this sample; only path-style URLs are
             *   generated.  Set this to 1 explicitly when using SeaweedFS
             *   or other S3-compatible services that require path-style.
             * Env var override: FL_S3_PATH_STYLE (1 or 0). */
            (void)pcVal; /* accepted and ignored; path-style is always used */
#ifdef FOBJCLD_DYNLOAD_LIBCURL
         } else if (strcmp(pcTok, "curllib") == 0) {
            strncpy(pcCurlLib, pcVal, 255);
            pcCurlLib[255] = '\0';
#endif
         }
         /* unknown keys are silently ignored (forward compatibility) */
      }
      pcTok = pcSave;
   }

   /* env var fallback for credentials */
   if (pHdl->acAccessKey[0] == '\0') {
      pcEnv = getenv("AWS_ACCESS_KEY_ID");
      if (pcEnv != NULL) {
         strncpy(pHdl->acAccessKey, pcEnv, sizeof(pHdl->acAccessKey) - 1);
         pHdl->acAccessKey[sizeof(pHdl->acAccessKey) - 1] = '\0';
      }
   }
   if (pHdl->acSecretKey[0] == '\0') {
      pcEnv = getenv("AWS_SECRET_ACCESS_KEY");
      if (pcEnv != NULL) {
         strncpy(pHdl->acSecretKey, pcEnv, sizeof(pHdl->acSecretKey) - 1);
         pHdl->acSecretKey[sizeof(pHdl->acSecretKey) - 1] = '\0';
      }
   }

   if (pHdl->acBucket[0] == '\0') {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen,
                  "FOBJCLD: Required parameter 'bucket' is missing");
      }
      return FOBJCLD_RTC_PAR;
   }

   return 0;
}

/* =========================================================================
 * DST helper: list objects with a given prefix (paginated ListObjectsV2)
 * =========================================================================
 *
 * XML key extraction uses simple strstr scanning.  This is adequate for the
 * sample because S3 object keys consist of printable ASCII characters only
 * and the XML tags <Key> / </Key> do not appear inside key values.  For
 * production use a proper XML parser is recommended.
 *
 * EBCDIC portability note: the string literals "<Key>", "</Key>",
 * "<NextContinuationToken>", "</NextContinuationToken>", "<Size>", "</Size>"
 * are compiled as EBCDIC bytes on z/OS.  The S3 response body, however, is
 * ASCII.  On z/OS USS, replace these literals with ASCII byte-array constants
 * (e.g. static const char sc_acKey[] = {0x3C,0x4B,0x65,0x79,0x3E,0x00};)
 * before the strstr calls will work correctly.  The same applies to the
 * "<Delete>", "<Object>", "<Key>", "</Key>", "</Object>", "</Delete>" strings
 * used when building the DeleteObjects body in fobjcld_delete_batch.
 */

/** @brief Maximum number of keys collected per listing page. */
#define FOBJCLD_LIST_PAGE  1000

/**
 * @brief Extract all <Key>...</Key> values from a ListObjectsV2 XML response.
 *
 * Caller must free each string in *papcKeys and then free *papcKeys itself.
 *
 * @param pcXml    Null-terminated XML response body.
 * @param papcKeys Output: allocated array of null-terminated key strings.
 * @param puiCnt   Output: number of entries in *papcKeys.
 * @param pcMsg    Buffer for error message.
 * @param uiMsgLen Capacity of pcMsg.
 * @return 0 on success, FOBJCLD_RTC_ALC on allocation failure.
 */
static int fobjcld_extract_keys(const char* pcXml, char*** papcKeys,
                                 unsigned int* puiCnt,
                                 char* pcMsg, unsigned int uiMsgLen)
{
   const char*   pcPos    = pcXml;
   const char*   pcStart;
   const char*   pcEnd;
   unsigned int  uiAlloc  = 64;
   unsigned int  uiCnt    = 0;
   char**        apcKeys;
   char**        apcTmp;
   char*         pcKey;
   size_t        szKeyLen;

   apcKeys = (char**)malloc(uiAlloc * sizeof(char*));
   if (apcKeys == NULL) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen, "FOBJCLD: malloc failed in extract_keys");
      }
      return FOBJCLD_RTC_ALC;
   }

   while ((pcStart = strstr(pcPos, "<Key>")) != NULL) {
      pcStart += 5; /* skip "<Key>" */
      pcEnd = strstr(pcStart, "</Key>");
      if (pcEnd == NULL) {
         break;
      }
      szKeyLen = (size_t)(pcEnd - pcStart);
      pcKey = (char*)malloc(szKeyLen + 1);
      if (pcKey == NULL) {
         unsigned int i;
         for (i = 0; i < uiCnt; i++) {
            free(apcKeys[i]);
         }
         free(apcKeys);
         if (pcMsg != NULL) {
            snprintf(pcMsg, uiMsgLen,
                     "FOBJCLD: malloc failed in extract_keys (key copy)");
         }
         return FOBJCLD_RTC_ALC;
      }
      memcpy(pcKey, pcStart, szKeyLen);
      pcKey[szKeyLen] = '\0';

      if (uiCnt >= uiAlloc) {
         uiAlloc *= 2;
         apcTmp = (char**)realloc(apcKeys, uiAlloc * sizeof(char*));
         if (apcTmp == NULL) {
            unsigned int i;
            free(pcKey);
            for (i = 0; i < uiCnt; i++) {
               free(apcKeys[i]);
            }
            free(apcKeys);
            if (pcMsg != NULL) {
               snprintf(pcMsg, uiMsgLen,
                        "FOBJCLD: realloc failed in extract_keys");
            }
            return FOBJCLD_RTC_ALC;
         }
         apcKeys = apcTmp;
      }
      apcKeys[uiCnt++] = pcKey;
      pcPos = pcEnd + 6; /* skip "</Key>" */
   }

   *papcKeys = apcKeys;
   *puiCnt   = uiCnt;
   return 0;
}

/**
 * @brief Extract the continuation token from a ListObjectsV2 XML response.
 *
 * Scans for <NextContinuationToken>...</NextContinuationToken>.  Writes up
 * to (uiTokLen-1) characters of the token into pcTok and null-terminates.
 * Sets pcTok[0] = '\0' when no continuation token is present (last page).
 *
 * @param pcXml    Null-terminated XML response body.
 * @param pcTok    Output buffer for the continuation token.
 * @param uiTokLen Capacity of pcTok in bytes.
 */
static void fobjcld_extract_next_token(const char* pcXml, char* pcTok,
                                        unsigned int uiTokLen)
{
   const char* pcStart;
   const char* pcEnd;
   size_t      szLen;
   size_t      szCopy;

   pcTok[0] = '\0';
   pcStart = strstr(pcXml, "<NextContinuationToken>");
   if (pcStart == NULL) {
      return;
   }
   pcStart += 23; /* skip "<NextContinuationToken>" */
   pcEnd = strstr(pcStart, "</NextContinuationToken>");
   if (pcEnd == NULL) {
      return;
   }
   szLen  = (size_t)(pcEnd - pcStart);
   szCopy = (szLen < (size_t)(uiTokLen - 1)) ? szLen : (size_t)(uiTokLen - 1);
   memcpy(pcTok, pcStart, szCopy);
   pcTok[szCopy] = '\0';
}

/**
 * @brief Extract the sum of all <Size>...</Size> values in a listing page.
 *
 * @param pcXml  Null-terminated XML response body.
 * @return Total bytes for all objects on this page.
 */
static long long fobjcld_extract_sizes(const char* pcXml)
{
   const char* pcPos = pcXml;
   const char* pcStart;
   const char* pcEnd;
   char        acNum[32];
   size_t      szLen;
   long long   siTotal = 0;

   while ((pcStart = strstr(pcPos, "<Size>")) != NULL) {
      pcStart += 6;
      pcEnd = strstr(pcStart, "</Size>");
      if (pcEnd == NULL) {
         break;
      }
      szLen = (size_t)(pcEnd - pcStart);
      if (szLen < sizeof(acNum)) {
         memcpy(acNum, pcStart, szLen);
         acNum[szLen] = '\0';
         siTotal += (long long)atoll(acNum);
      }
      pcPos = pcEnd + 7;
   }
   return siTotal;
}

/**
 * @brief Perform S3 ListObjectsV2 for one page and collect keys + sizes.
 *
 * @param pHdl          Provider handle.
 * @param pcPrefix      S3 key prefix to list (URL-encoded safe ASCII).
 * @param pcContToken   Continuation token ("" for first page).
 * @param papcKeys      Output: allocated array of key strings (caller frees).
 * @param puiCnt        Output: number of entries.
 * @param psiPageDat    Output: total data bytes for this page.
 * @param pcNextTok     Output buffer (512 bytes) for next continuation token.
 * @param pcMsg         Buffer for error message.
 * @param uiMsgLen      Capacity of pcMsg.
 * @return 0 on success, FOBJCLD_RTC_CON / FOBJCLD_RTC_ALC on error.
 */
static int fobjcld_list_page(TsFobjS3Hdl* pHdl, const char* pcPrefix,
                              const char* pcContToken,
                              char*** papcKeys, unsigned int* puiCnt,
                              long long* psiPageDat,
                              char* pcNextTok,
                              char* pcMsg, unsigned int uiMsgLen)
{
   char       acUrl[4096];
   TsFobjBuf  stBuf;
   int        iCc;
   long       siCode = 0;
   int        iRtc;

   memset(&stBuf, 0, sizeof(stBuf));
   *puiCnt    = 0;
   *papcKeys  = NULL;
   *psiPageDat = 0;
   pcNextTok[0] = '\0';

   if (pcContToken != NULL && pcContToken[0] != '\0') {
      snprintf(acUrl, sizeof(acUrl),
               "%s/%s?list-type=2&prefix=%s&continuation-token=%s",
               pHdl->acEndpoint, pHdl->acBucket, pcPrefix, pcContToken);
   } else {
      snprintf(acUrl, sizeof(acUrl),
               "%s/%s?list-type=2&prefix=%s",
               pHdl->acEndpoint, pHdl->acBucket, pcPrefix);
   }

   fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);
   FSETF(pHdl, FOBJCLD_OPT_WRITEFUNCTION, fobjcld_write_cb);
   FSETP(pHdl, FOBJCLD_OPT_WRITEDATA,     &stBuf);

   iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
   if (iCc != FOBJCLD_CURLE_OK) {
      if (pcMsg != NULL) {
         fobjcld_curl_msg(pcMsg, uiMsgLen, "list", iCc,
                          pHdl->pF->curl_easy_strerror(iCc));
      }
      free(stBuf.pcData);
      return FOBJCLD_RTC_CON;
   }

   FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);
   if (siCode != 200) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen,
                  "FOBJCLD: ListObjectsV2 HTTP %ld", siCode);
      }
      free(stBuf.pcData);
      return FOBJCLD_RTC_CON;
   }

   /* null-terminate response for strstr scanning */
   {
      unsigned char* pcTmp = (unsigned char*)realloc(stBuf.pcData,
                                                      stBuf.uiLen + 1);
      if (pcTmp == NULL) {
         free(stBuf.pcData);
         if (pcMsg != NULL) {
            snprintf(pcMsg, uiMsgLen, "FOBJCLD: realloc failed in list_page");
         }
         return FOBJCLD_RTC_ALC;
      }
      stBuf.pcData = pcTmp;
      stBuf.pcData[stBuf.uiLen] = '\0';
   }

   iRtc = fobjcld_extract_keys((char*)stBuf.pcData, papcKeys, puiCnt,
                                pcMsg, uiMsgLen);
   if (iRtc == 0) {
      *psiPageDat = fobjcld_extract_sizes((char*)stBuf.pcData);
      fobjcld_extract_next_token((char*)stBuf.pcData, pcNextTok, 512);
   }

   free(stBuf.pcData);
   return iRtc;
}

/**
 * @brief Send a multi-object delete request for up to FOBJCLD_LIST_PAGE keys.
 *
 * Builds the DeleteObjects XML body and POSTs to /{bucket}?delete.
 *
 * @param pHdl      Provider handle.
 * @param apcKeys   Array of full S3 object keys to delete.
 * @param uiCnt     Number of keys in apcKeys.
 * @param pcMsg     Buffer for error message.
 * @param uiMsgLen  Capacity of pcMsg.
 * @return 0 on success, FOBJCLD_RTC_CON / FOBJCLD_RTC_ALC on error.
 */
static int fobjcld_delete_batch(TsFobjS3Hdl* pHdl, char** apcKeys,
                                 unsigned int uiCnt,
                                 char* pcMsg, unsigned int uiMsgLen)
{
   /* Each entry: <Object><Key>...</Key></Object> -- key max ~1024 chars */
   unsigned int   uiBufCap = 256 + uiCnt * 1100;
   char*          pcBody;
   unsigned int   uiOff = 0;
   unsigned int   i;
   char           acUrl[1024];
   TsFobjReadBuf  stRd;
   TsFobjBuf      stResp;
   int                iCc;
   long               siCode = 0;
   TsFobjCurlSlist*   pHdr = NULL;

   if (uiCnt == 0) {
      return 0;
   }

   pcBody = (char*)malloc(uiBufCap);
   if (pcBody == NULL) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen, "FOBJCLD: malloc failed in delete_batch");
      }
      return FOBJCLD_RTC_ALC;
   }

   /* <Quiet>true</Quiet> suppresses per-key success entries in the response
    * body so only errors are returned; reduces response parsing complexity.
    * NOTE: AWS S3 also requires a Content-MD5: base64(MD5(body)) header for
    * DeleteObjects.  This sample omits it for simplicity; strict S3-
    * compatible servers may reject the request.  Compute it as:
    *   unsigned char acMD5[16]; MD5(pcBody, uiOff, acMD5);
    *   base64_encode(acMD5, 16, acMD5B64);
    *   curl_slist_append(pHdr, "Content-MD5: " acMD5B64);             */
   uiOff += (unsigned int)snprintf(pcBody + uiOff, uiBufCap - uiOff,
                                    "<Delete><Quiet>true</Quiet>");
   for (i = 0; i < uiCnt && uiOff < uiBufCap - 1; i++) {
      uiOff += (unsigned int)snprintf(pcBody + uiOff, uiBufCap - uiOff,
                                       "<Object><Key>%s</Key></Object>",
                                       apcKeys[i]);
   }
   if (uiOff < uiBufCap - 1) {
      uiOff += (unsigned int)snprintf(pcBody + uiOff, uiBufCap - uiOff,
                                       "</Delete>");
   }

   /* "?delete=" (name=value form) is required by the SigV4 canonical query
    * string spec.  OBJCLDS3 v1.6.3 fixed this; "?delete" without "=" causes
    * signature mismatches on strict S3-compatible servers.               */
   snprintf(acUrl, sizeof(acUrl), "%s/%s?delete=",
            pHdl->acEndpoint, pHdl->acBucket);

   memset(&stResp, 0, sizeof(stResp));
   stRd.pcData = (const unsigned char*)pcBody;
   stRd.uiLen  = uiOff;
   stRd.uiOff  = 0;

   fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);
   FSETL(pHdl, FOBJCLD_OPT_POST,          1L);
   FSETL(pHdl, FOBJCLD_OPT_POSTFIELDSIZE, (long)uiOff);
   FSETF(pHdl, FOBJCLD_OPT_READFUNCTION,  fobjcld_read_cb);
   FSETP(pHdl, FOBJCLD_OPT_READDATA,      &stRd);
   FSETF(pHdl, FOBJCLD_OPT_WRITEFUNCTION, fobjcld_write_cb);
   FSETP(pHdl, FOBJCLD_OPT_WRITEDATA,     &stResp);

   pHdr = pHdl->pF->curl_slist_append(pHdr,
                                       "Content-Type: application/xml");
   if (pHdr != NULL) {
      FSETP(pHdl, FOBJCLD_OPT_HTTPHEADER, pHdr);
   }

   iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
   if (pHdr != NULL) {
      pHdl->pF->curl_slist_free_all(pHdr);
   }
   free(stResp.pcData);
   free(pcBody);

   if (iCc != FOBJCLD_CURLE_OK) {
      if (pcMsg != NULL) {
         fobjcld_curl_msg(pcMsg, uiMsgLen, "delete batch", iCc,
                          pHdl->pF->curl_easy_strerror(iCc));
      }
      return FOBJCLD_RTC_CON;
   }

   FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);
   if (siCode != 200) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen,
                  "FOBJCLD: DeleteObjects HTTP %ld", siCode);
      }
      return FOBJCLD_RTC_CON;
   }

   return 0;
}

/* =========================================================================
 * DST: delete entire version namespace
 * ========================================================================= */

/**
 * @brief Delete all S3 objects whose key starts with pHdl->acPrefix.
 *
 * Uses ListObjectsV2 (paginated, 1000 keys per page) and DeleteObjects
 * (batch delete).  Updates pHdl->siObjDel.
 *
 * @param pHdl     Provider handle.
 * @param pcMsg    Buffer for error message.
 * @param uiMsgLen Capacity of pcMsg.
 * @return 0 on success, non-zero on error.
 */
static int fobjcld_dst_internal(TsFobjS3Hdl* pHdl,
                                 char* pcMsg, unsigned int uiMsgLen)
{
   char         acContToken[512];
   char         acNextToken[512];
   char**       apcKeys   = NULL;
   unsigned int uiCnt     = 0;
   long long    siPageDat = 0; /* page byte total (informational, not returned) */
   unsigned int i;
   int          iRtc;

   acContToken[0] = '\0';

   do {
      iRtc = fobjcld_list_page(pHdl, pHdl->acPrefix, acContToken,
                                &apcKeys, &uiCnt, &siPageDat,
                                acNextToken, pcMsg, uiMsgLen);
      if (iRtc != 0) {
         return iRtc;
      }

      /* fobjcld_extract_keys always allocates apcKeys (even for 0 keys).
       * Free it unconditionally to avoid a leak when uiCnt == 0. */
      if (uiCnt > 0) {
         iRtc = fobjcld_delete_batch(pHdl, apcKeys, uiCnt, pcMsg, uiMsgLen);
      }
      for (i = 0; i < uiCnt; i++) {
         free(apcKeys[i]);
      }
      free(apcKeys);
      apcKeys = NULL;
      if (iRtc != 0) {
         return iRtc;
      }
      pHdl->siObjDel += (long long)uiCnt;

      /* Stop as soon as a page returns no objects.  A non-empty
       * NextContinuationToken with 0 keys indicates a server-side
       * quirk (e.g. SeaweedFS returning IsTruncated=true after all
       * objects were just deleted).  Continuing would loop forever. */
      if (uiCnt == 0) {
         break;
      }

      strncpy(acContToken, acNextToken, sizeof(acContToken) - 1);
      acContToken[sizeof(acContToken) - 1] = '\0';

   } while (acContToken[0] != '\0');

   return 0;
}

/* =========================================================================
 * SPI function implementations
 * ========================================================================= */

/**
 * @brief FOBJ_FUNC_NEW: allocate handle, store name and parameter strings.
 */
static int fobjcld_new(void** ppHdl, TuFobjPar* puPara,
                        char* pcMsg, unsigned int uiMsgLen)
{
   TsFobjS3Hdl* pHdl = (TsFobjS3Hdl*)malloc(sizeof(TsFobjS3Hdl));
   if (pHdl == NULL) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen, "FOBJCLD: malloc failed for handle (NEW)");
      }
      return FOBJCLD_RTC_ALC;
   }
   memset(pHdl, 0, sizeof(TsFobjS3Hdl));
   if (puPara->stNew.pcNamStr != NULL) {
      strncpy(pHdl->acNam, puPara->stNew.pcNamStr, sizeof(pHdl->acNam) - 1);
   }
   if (puPara->stNew.pcParStr != NULL) {
      strncpy(pHdl->acPar, puPara->stNew.pcParStr, sizeof(pHdl->acPar) - 1);
   }
   *ppHdl = pHdl;
   return 0;
}

/**
 * @brief FOBJ_FUNC_FRE: scrub credentials and free handle.
 */
static int fobjcld_fre(void** ppHdl, char* pcMsg, unsigned int uiMsgLen)
{
   TsFobjS3Hdl* pHdl = (TsFobjS3Hdl*)*ppHdl;
   (void)pcMsg; (void)uiMsgLen;
   if (pHdl == NULL) { return 0; }
   /* CLS is supposed to have closed pCurl, but nothing enforces it and one
    * path does leave it open: the early return in fobjcld_exs_new when
    * fobjcld_stateless_init reports "cannot connect" skips the cleanup at the
    * end of that function. What is lost then is not visible as a block of
    * ours -- the storage sits inside libcurl behind the easy handle, which is
    * how it appeared in memcheck: a small block under three frames of this
    * library with no symbols. Closing it here has to happen while pF is still
    * around, because curl_easy_cleanup is reached through it. */
   if (pHdl->pCurl != NULL && pHdl->pF != NULL) {
      pHdl->pF->curl_easy_cleanup(pHdl->pCurl);
      pHdl->pCurl = NULL;
   }
   /* pF may still be loaded -- free it */
   if (pHdl->pF != NULL) {
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      if (pHdl->pF->hLib != NULL) {
         dyl_close(pHdl->pF->hLib);
         pHdl->pF->hLib = NULL;
      }
#endif
      free(pHdl->pF);
      pHdl->pF = NULL;
   }
   if (pHdl->pcGetBuf != NULL) {
      free(pHdl->pcGetBuf);
      pHdl->pcGetBuf = NULL;
   }
   memset(pHdl, 0, sizeof(*pHdl));  /* scrub credentials */
   free(pHdl);
   *ppHdl = NULL;
   return 0;
}

/**
 * @brief Initialize connection from stored params for stateless ops (EXS/DST).
 *
 * Parses acPar, loads curl library (if not already loaded), initializes
 * pCurl, and builds the namespace-level prefix into pHdl->acPrefix.
 * On success the caller must call fobjcld_stateless_cleanup().
 * @return 0 on success, non-zero on error.
 */
static int fobjcld_stateless_init(TsFobjS3Hdl* pHdl,
                                   char* pcMsg, unsigned int uiMsgLen)
{
   char acPfxUser[512];
   int  iRtc;
   int  bNewPF = 0;
   /* Parse connection params from stored acPar */
#ifdef FOBJCLD_DYNLOAD_LIBCURL
   char acCurlLib[256];
   acCurlLib[0] = '\0';
   iRtc = fobjcld_parse_params(pHdl->acPar, acCurlLib, pHdl, pcMsg, uiMsgLen);
#else
   iRtc = fobjcld_parse_params(pHdl->acPar, pHdl, pcMsg, uiMsgLen);
#endif
   if (iRtc != 0) { return iRtc; }

   acPfxUser[0] = '\0';
   /* Parser stores user prefix temporarily in acPrefix; save it */
   strncpy(acPfxUser, pHdl->acPrefix, sizeof(acPfxUser) - 1);
   acPfxUser[sizeof(acPfxUser) - 1] = '\0';

   /* Build namespace-level prefix: {user_prefix}{namespace}/ (all versions).
    * Use explicit length limit so the combined result fits acPrefix[768].   */
   {
      int iUsr = (int)strlen(acPfxUser);
      int iMax = (int)sizeof(pHdl->acPrefix) - iUsr - 2; /* -1 '/' -1 NUL */
      if (iMax < 0) iMax = 0;
      snprintf(pHdl->acPrefix, sizeof(pHdl->acPrefix), "%s%.*s/",
               acPfxUser, iMax, pHdl->acNam);
   }

   /* Load curl library if not already loaded from prior OPN */
   if (pHdl->pF == NULL) {
      pHdl->pF = (TsFobjCurlFuncs*)malloc(sizeof(TsFobjCurlFuncs));
      if (pHdl->pF == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, uiMsgLen, "FOBJCLD: malloc failed (curl funcs)");
         }
         pHdl->acPrefix[0] = '\0';
         return FOBJCLD_RTC_ALC;
      }
      memset(pHdl->pF, 0, sizeof(TsFobjCurlFuncs));
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      iRtc = fobjcld_load(pHdl->pF, acCurlLib, pcMsg, uiMsgLen);
      if (iRtc != 0) {
         free(pHdl->pF);
         pHdl->pF = NULL;
         pHdl->acPrefix[0] = '\0';
         return iRtc;
      }
#else
      fobjcld_finit(pHdl->pF);
#endif
      bNewPF = 1;
   }
   (void)bNewPF;  /* pF ownership stays with handle; freed in FRE */

   /* Init curl easy handle */
   if (pHdl->pCurl != NULL) {
      /* Already open (OPEN state) -- caller should not call stateless_init */
      pHdl->acPrefix[0] = '\0';
      return FOBJCLD_RTC_PAR;
   }
   pHdl->pCurl = pHdl->pF->curl_easy_init();
   if (pHdl->pCurl == NULL) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen, "FOBJCLD: curl_easy_init() failed");
      }
      pHdl->acPrefix[0] = '\0';
      return FOBJCLD_RTC_CON;
   }
   return 0;
}

/** @brief Cleanup after fobjcld_stateless_init(); close pCurl only. */
static void fobjcld_stateless_cleanup(TsFobjS3Hdl* pHdl)
{
   /* pF is tested as well: the cleanup routine is reached through it, and it
    * is now also called from the failure exit of fobjcld_exs_new, where init
    * did not necessarily get that far. Today every state with pCurl set also
    * has pF set, so this guard costs nothing and removes the dependency on
    * that staying true. */
   if (pHdl->pCurl != NULL && pHdl->pF != NULL) {
      pHdl->pF->curl_easy_cleanup(pHdl->pCurl);
      pHdl->pCurl = NULL;
   }
}

/**
 * @brief FOBJ_FUNC_EXS: check if object/archive exists (NEW state).
 *
 * In pure NEW state (before OPN): uses ListObjectsV2 under the namespace
 * prefix to determine if any archive data exists.  In post-CLS state:
 * HTTP HEAD on the specific object key.
 */
static int fobjcld_exs_new(TsFobjS3Hdl* pHdl, TuFobjPar* puPara,
                             char* pcMsg, unsigned int uiMsgLen)
{
   int          iRtc;
   int          bPureNew = (pHdl->acEndpoint[0] == '\0');
   char**       apcKeys   = NULL;
   unsigned int uiCnt     = 0;
   long long    siPageDat = 0;
   char         acNextTok[512];

   puPara->stExs.isExists = 0U;

   if (bPureNew) {
      /* Pure NEW state: connect using acPar; acPrefix is set to namespace/ */
      iRtc = fobjcld_stateless_init(pHdl, pcMsg, uiMsgLen);
      if (iRtc != 0) {
         return 0;   /* cannot connect -- conservatively: not found */
      }
      /* List one object under the namespace prefix to check existence */
      iRtc = fobjcld_list_page(pHdl, pHdl->acPrefix, "",
                                &apcKeys, &uiCnt, &siPageDat,
                                acNextTok, pcMsg, uiMsgLen);
      if (iRtc == 0) {
         puPara->stExs.isExists = (uiCnt > 0) ? 1U : 0U;
         if (apcKeys != NULL) {
            unsigned int i;
            for (i = 0; i < uiCnt; i++) { free(apcKeys[i]); }
            free(apcKeys);
         }
      }
   } else {
      /* Post-CLS state: pF set from prior OPN; HTTP HEAD on specific key */
      if (pHdl->pF == NULL) { return 0; }
      pHdl->pCurl = pHdl->pF->curl_easy_init();
      if (pHdl->pCurl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, uiMsgLen, "FOBJCLD-EXS: curl_easy_init() failed");
         }
         return FOBJCLD_RTC_CON;
      }
      {
         char acUrl[4096];
         long siCode = 0;
         int  iCc;
         snprintf(acUrl, sizeof(acUrl), "%s/%s/%s%s",
                  pHdl->acEndpoint, pHdl->acBucket, pHdl->acPrefix,
                  puPara->stExs.pcKeyStr ? puPara->stExs.pcKeyStr : "");
         fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);
         FSETL(pHdl, FOBJCLD_OPT_NOBODY, 1L);
         iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
         if (iCc == FOBJCLD_CURLE_OK) {
            FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);
            puPara->stExs.isExists = (siCode == 200) ? 1U : 0U;
         }
      }
   }

   fobjcld_stateless_cleanup(pHdl);
   return 0;
}

/**
 * @brief FOBJ_FUNC_DST in NEW state: delete entire version namespace.
 */
static int fobjcld_dst_new(TsFobjS3Hdl* pHdl,
                            char* pcMsg, unsigned int uiMsgLen)
{
   int iRtc;
   int bPureNew = (pHdl->acEndpoint[0] == '\0');

   if (bPureNew) {
      /* Pure NEW state: parse params and connect internally */
      iRtc = fobjcld_stateless_init(pHdl, pcMsg, uiMsgLen);
      if (iRtc != 0) {
         return iRtc;
      }
      /* acPrefix is now set to namespace-level prefix by stateless_init */
   } else {
      /* Post-CLS state: acPrefix already set from prior OPN */
      if (pHdl->acPrefix[0] == '\0') {
         return 0;   /* prefix empty -- nothing to delete */
      }
      if (pHdl->pF == NULL) { return 0; }
      pHdl->pCurl = pHdl->pF->curl_easy_init();
      if (pHdl->pCurl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, uiMsgLen, "FOBJCLD-DST: curl_easy_init() failed");
         }
         return FOBJCLD_RTC_CON;
      }
   }

   iRtc = fobjcld_dst_internal(pHdl, pcMsg, uiMsgLen);
   fobjcld_stateless_cleanup(pHdl);
   return iRtc;
}

/**
 * @brief FOBJ_FUNC_OPN: parse parameters, init curl.
 *        Supports pre-allocated handle from FOBJ_FUNC_NEW.
 */
static int fobjcld_opn(void** ppHdl, TuFobjPar* puPara,
                        char* pcMsg, unsigned int uiMsgLen)
{
   TsFobjS3Hdl*    pHdl;
   TsFobjCurlFuncs* pF;
#ifdef FOBJCLD_DYNLOAD_LIBCURL
   char             acCurlLib[256];
#endif
   char             acPfxUser[512]; /* user prefix= value (temporary) */
   char             acUrl[1024];
   int              iCc;
   long             siCode = 0;
   int              iRtc;
   int              iPreAlloc; /* 1 if handle was pre-allocated by NEW */

   iPreAlloc = (*ppHdl != NULL) ? 1 : 0;
   if (iPreAlloc) {
      pHdl = (TsFobjS3Hdl*)*ppHdl;
      /* Use stored param string from NEW; re-init connection fields */
      pHdl->acEndpoint[0] = '\0';
      pHdl->acBucket[0]   = '\0';
      pHdl->acRegion[0]   = '\0';
      pHdl->acPrefix[0]   = '\0';
      pHdl->iWrite        = 0;
   } else {
      pHdl = (TsFobjS3Hdl*)malloc(sizeof(TsFobjS3Hdl));
      if (pHdl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, uiMsgLen, "FOBJCLD: malloc failed for handle");
         }
         return FOBJCLD_RTC_ALC;
      }
      memset(pHdl, 0, sizeof(TsFobjS3Hdl));
   }

   /* acPrefix used temporarily by parser for the user prefix= value */
   acPfxUser[0] = '\0';

   /* Use stored acPar (from NEW) if available; fall back to OPN pcParStr */
   {
      const char* pcPar = (iPreAlloc && pHdl->acPar[0] != '\0')
                          ? pHdl->acPar : puPara->stOpn.pcParStr;
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      iRtc = fobjcld_parse_params(pcPar, acCurlLib, pHdl, pcMsg, uiMsgLen);
#else
      iRtc = fobjcld_parse_params(pcPar, pHdl, pcMsg, uiMsgLen);
#endif
   }
   if (iRtc != 0) {
      if (!iPreAlloc) {
         memset(pHdl, 0, sizeof(*pHdl));
         free(pHdl);
      }
      return iRtc;
   }

   /* save user prefix before it gets overwritten */
   strncpy(acPfxUser, pHdl->acPrefix, sizeof(acPfxUser) - 1);
   acPfxUser[sizeof(acPfxUser) - 1] = '\0';

   /* build full key prefix: "{user_prefix}{namespace}/{version:020lld}/" */
   snprintf(pHdl->acPrefix, sizeof(pHdl->acPrefix), "%s%s/%020lld/",
            acPfxUser,
            (puPara->stOpn.pcNamStr != NULL) ? puPara->stOpn.pcNamStr : "",
            puPara->stOpn.siVerId);

   /* write canonical name for ppcName output */
   snprintf(pHdl->acName, sizeof(pHdl->acName), "%s/%s/%s",
            pHdl->acEndpoint, pHdl->acBucket, pHdl->acPrefix);

   pHdl->iWrite = (puPara->stOpn.uiFlg & FOBJ_FLAG_WRITE) ? 1 : 0;

   /* Free TsFobjCurlFuncs left by a prior EXS (fobjcld_stateless_init) call.
    * EXS on a NEW handle sets pHdl->pF; OPN must not overwrite it silently.
    * Outside the FOBJCLD_DYNLOAD_LIBCURL guard on purpose -- the assignment that
    * overwrites pF a few lines below is unconditional too. With libcurl linked
    * statically, the default since 1.5.0, the 1.4.0 fix was compiled out and
    * the block was lost: memcheck reported 72 bytes definitely lost from
    * fobjcld_stateless_init through fobjcld_exs_new, which is exactly this
    * EXS-then-OPN sequence. Only closing the library handle is dynload-only,
    * because hLib exists only there. */
   if (iPreAlloc && pHdl->pF != NULL) {
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      if (pHdl->pF->hLib != NULL) { dyl_close(pHdl->pF->hLib); }
#endif
      free(pHdl->pF);
      pHdl->pF = NULL;
   }

   /* load libcurl */
   pF = (TsFobjCurlFuncs*)malloc(sizeof(TsFobjCurlFuncs));
   if (pF == NULL) {
      if (!iPreAlloc) { memset(pHdl, 0, sizeof(*pHdl)); free(pHdl); }
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen, "FOBJCLD: malloc failed for curl funcs");
      }
      return FOBJCLD_RTC_ALC;
   }
   memset(pF, 0, sizeof(TsFobjCurlFuncs));
   pHdl->pF = pF;

#ifdef FOBJCLD_DYNLOAD_LIBCURL
   iRtc = fobjcld_load(pHdl->pF, acCurlLib, pcMsg, uiMsgLen);
   if (iRtc != 0) {
      free(pF); pHdl->pF = NULL;
      if (!iPreAlloc) { memset(pHdl, 0, sizeof(*pHdl)); free(pHdl); }
      return iRtc;
   }
#else
   fobjcld_finit(pHdl->pF);
#endif
   /* initialize curl easy handle */
   pHdl->pCurl = pHdl->pF->curl_easy_init();
   if (pHdl->pCurl == NULL) {
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      dyl_close(pHdl->pF->hLib); free(pF); pHdl->pF = NULL;
#endif
      if (!iPreAlloc) { memset(pHdl, 0, sizeof(*pHdl)); free(pHdl); }
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen, "FOBJCLD: curl_easy_init() failed");
      }
      return FOBJCLD_RTC_CON;
   }

   /* verify connectivity: HEAD {endpoint}/{bucket} */
   snprintf(acUrl, sizeof(acUrl), "%s/%s",
            pHdl->acEndpoint, pHdl->acBucket);
   fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);
   FSETL(pHdl, FOBJCLD_OPT_NOBODY, 1L);

   /* Refuse here rather than sending unsigned requests.  This is the first
    * request of the session, so a libcurl without CURLOPT_AWS_SIGV4 is caught
    * at open with a message that names the cause.  Without this check the
    * bucket HEAD still succeeded wherever anonymous access is allowed and only
    * the first GET failed with "HTTP 403", which looks like a credential
    * problem on a machine whose libcurl is simply too old.               */
   if (pHdl->iSigV4Cc != FOBJCLD_CURLE_OK) {
      pHdl->pF->curl_easy_cleanup(pHdl->pCurl); pHdl->pCurl = NULL;
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      dyl_close(pHdl->pF->hLib); free(pF); pHdl->pF = NULL;
#endif
      if (!iPreAlloc) { memset(pHdl, 0, sizeof(*pHdl)); free(pHdl); }
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen,
                  "FOBJCLD: This libcurl does not support CURLOPT_AWS_SIGV4 "
                  "(needs 7.75.0 or newer); requests cannot be signed");
      }
      return FOBJCLD_RTC_CON;
   }

   iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
   if (iCc != FOBJCLD_CURLE_OK) {
      const char* pcCurlErr = pHdl->pF->curl_easy_strerror(iCc);
      char        acErrCopy[256];
      strncpy(acErrCopy, pcCurlErr ? pcCurlErr : "unknown", sizeof(acErrCopy) - 1);
      acErrCopy[sizeof(acErrCopy) - 1] = '\0';
      pHdl->pF->curl_easy_cleanup(pHdl->pCurl); pHdl->pCurl = NULL;
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      dyl_close(pHdl->pF->hLib); free(pF); pHdl->pF = NULL;
#endif
      if (!iPreAlloc) { memset(pHdl, 0, sizeof(*pHdl)); free(pHdl); }
      if (pcMsg != NULL) {
         fobjcld_curl_msg(pcMsg, uiMsgLen, "bucket HEAD", iCc, acErrCopy);
      }
      return FOBJCLD_RTC_CON;
   }

   FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);
   if (siCode != 200 && siCode != 403) {
      char acBktCopy[256];
      strncpy(acBktCopy, pHdl->acBucket, sizeof(acBktCopy) - 1);
      acBktCopy[sizeof(acBktCopy) - 1] = '\0';
      pHdl->pF->curl_easy_cleanup(pHdl->pCurl); pHdl->pCurl = NULL;
#ifdef FOBJCLD_DYNLOAD_LIBCURL
      dyl_close(pHdl->pF->hLib); free(pF); pHdl->pF = NULL;
#endif
      if (!iPreAlloc) { memset(pHdl, 0, sizeof(*pHdl)); free(pHdl); }
      if (pcMsg != NULL) {
         snprintf(pcMsg, uiMsgLen,
                  "FOBJCLD: Bucket HEAD returned HTTP %ld (bucket '%s')",
                  siCode, acBktCopy);
      }
      return FOBJCLD_RTC_CON;
   }

   if (puPara->stOpn.ppcName != NULL) {
      *puPara->stOpn.ppcName = pHdl->acName;
   }
   if (!iPreAlloc) {
      *ppHdl = pHdl;
   }
   return 0;
}

/**
 * @brief FOBJ_FUNC_CLS: rollback or commit; close curl.
 *        Handle is NOT freed; call FOBJ_FUNC_FRE to release.
 */
static int fobjcld_cls(void** ppHdl, TuFobjPar* puPara,
                        char* pcMsg, unsigned int uiMsgLen)
{
   TsFobjS3Hdl* pHdl = (TsFobjS3Hdl*)*ppHdl;
   int          iRtc  = 0;

   if (pHdl == NULL) {
      return 0;
   }

   /* rollback: delete all objects written under this version prefix */
   if (puPara->stCls.isCommit == 0 && pHdl->iWrite) {
      iRtc = fobjcld_dst_internal(pHdl, pcMsg, uiMsgLen);
   }

   if (pHdl->pcGetBuf != NULL) {
      free(pHdl->pcGetBuf);
      pHdl->pcGetBuf    = NULL;
      pHdl->uiGetBufLen = 0;
   }

   if (pHdl->pCurl != NULL) {
      pHdl->pF->curl_easy_cleanup(pHdl->pCurl);
      pHdl->pCurl = NULL;
   }

   /* pF is kept alive for use by EXS/DST in NEW state.
    * It is freed in FOBJ_FUNC_FRE (fobjcld_fre). */
   pHdl->iWrite = 0;
   /* *ppHdl stays non-NULL; handle is in NEW state after CLS */
   return iRtc;
}

/**
 * @brief FOBJ_FUNC_GET: HTTP GET; fill locate buffer; return pointer.
 */
static int fobjcld_get(TsFobjS3Hdl* pHdl, TuFobjPar* puPara,
                        char* pcMsg, unsigned int uiMsgLen)
{
   char       acUrl[4096];
   TsFobjBuf  stBuf;
   int        iCc;
   long       siCode = 0;

   memset(&stBuf, 0, sizeof(stBuf));

   fobjcld_build_url(pHdl, puPara->stGet.pcKeyStr, acUrl, sizeof(acUrl));
   fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);
   FSETF(pHdl, FOBJCLD_OPT_WRITEFUNCTION, fobjcld_write_cb);
   FSETP(pHdl, FOBJCLD_OPT_WRITEDATA,     &stBuf);

   iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
   if (iCc != FOBJCLD_CURLE_OK) {
      free(stBuf.pcData);
      if (pcMsg != NULL) {
         fobjcld_curl_msg(pcMsg, uiMsgLen, "GET", iCc,
                          pHdl->pF->curl_easy_strerror(iCc));
      }
      return FOBJCLD_RTC_CON;
   }

   FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);

   if (siCode == 200) {
      /* replace previous GET buffer */
      if (pHdl->pcGetBuf != NULL) {
         free(pHdl->pcGetBuf);
      }
      pHdl->pcGetBuf         = stBuf.pcData;
      pHdl->uiGetBufLen      = stBuf.uiLen;
      puPara->stGet.ppDatPtr = pHdl->pcGetBuf;
      puPara->stGet.uiDatLen = pHdl->uiGetBufLen;
      pHdl->siObjGet++;
      pHdl->siDatGet += (long long)stBuf.uiLen;
      return 0;
   }

   free(stBuf.pcData);

   if (siCode == 404) {
      puPara->stGet.ppDatPtr = NULL;
      puPara->stGet.uiDatLen = 0;
      return FOBJ_RTC_NFD;
   }

   if (pcMsg != NULL) {
      snprintf(pcMsg, uiMsgLen, "FOBJCLD: GET HTTP %ld for key '%s'",
               siCode, puPara->stGet.pcKeyStr);
   }
   return FOBJCLD_RTC_CON;
}

/**
 * @brief FOBJ_FUNC_PUT: HTTP PUT with read callback.
 */
static int fobjcld_put(TsFobjS3Hdl* pHdl, TuFobjPar* puPara,
                        char* pcMsg, unsigned int uiMsgLen)
{
   char          acUrl[4096];
   TsFobjReadBuf stRd;
   int           iCc;
   long          siCode = 0;

   fobjcld_build_url(pHdl, puPara->stPut.pcKeyStr, acUrl, sizeof(acUrl));
   fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);

   stRd.pcData = puPara->stPut.pcDatPtr;
   stRd.uiLen  = puPara->stPut.uiDatLen;
   stRd.uiOff  = 0;

   FSETL(pHdl, FOBJCLD_OPT_UPLOAD,        1L);
   FSETF(pHdl, FOBJCLD_OPT_READFUNCTION,  fobjcld_read_cb);
   FSETP(pHdl, FOBJCLD_OPT_READDATA,      &stRd);
   FSETO(pHdl, FOBJCLD_OPT_INFILESIZE_LARGE, puPara->stPut.uiDatLen);

   iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
   if (iCc != FOBJCLD_CURLE_OK) {
      if (pcMsg != NULL) {
         fobjcld_curl_msg(pcMsg, uiMsgLen, "PUT", iCc,
                          pHdl->pF->curl_easy_strerror(iCc));
      }
      return FOBJCLD_RTC_CON;
   }

   FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);
   if (siCode == 200 || siCode == 201) {
      pHdl->siObjPut++;
      pHdl->siDatPut += (long long)puPara->stPut.uiDatLen;
      return 0;
   }

   if (pcMsg != NULL) {
      snprintf(pcMsg, uiMsgLen, "FOBJCLD: PUT HTTP %ld for key '%s'",
               siCode, puPara->stPut.pcKeyStr);
   }
   return FOBJCLD_RTC_CON;
}

/**
 * @brief FOBJ_FUNC_DEL: HTTP DELETE (idempotent -- 404 treated as success).
 */
static int fobjcld_del(TsFobjS3Hdl* pHdl, TuFobjPar* puPara,
                        char* pcMsg, unsigned int uiMsgLen)
{
   char  acUrl[4096];
   int   iCc;
   long  siCode = 0;

   fobjcld_build_url(pHdl, puPara->stDel.pcKeyStr, acUrl, sizeof(acUrl));
   fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);
   FSETP(pHdl, FOBJCLD_OPT_CUSTOMREQUEST, "DELETE");

   iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
   if (iCc != FOBJCLD_CURLE_OK) {
      if (pcMsg != NULL) {
         fobjcld_curl_msg(pcMsg, uiMsgLen, "DEL", iCc,
                          pHdl->pF->curl_easy_strerror(iCc));
      }
      return FOBJCLD_RTC_CON;
   }

   FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);
   /* 204 = deleted, 404 = already gone (idempotent) */
   if (siCode == 204 || siCode == 404) {
      if (siCode == 204) {
         pHdl->siObjDel++;
      }
      return 0;
   }

   if (pcMsg != NULL) {
      snprintf(pcMsg, uiMsgLen, "FOBJCLD: DEL HTTP %ld for key '%s'",
               siCode, puPara->stDel.pcKeyStr);
   }
   return FOBJCLD_RTC_CON;
}

/**
 * @brief FOBJ_FUNC_CHK: HTTP HEAD; set isExists based on existence and size.
 */
static int fobjcld_chk(TsFobjS3Hdl* pHdl, TuFobjPar* puPara,
                        char* pcMsg, unsigned int uiMsgLen)
{
   char      acUrl[4096];
   int       iCc;
   long      siCode    = 0;
   long long siContLen = -1;

   (void)pcMsg;
   (void)uiMsgLen;

   puPara->stChk.isExists   = 0;
   puPara->stChk.isTooShort = 0;

   fobjcld_build_url(pHdl, puPara->stChk.pcKeyStr, acUrl, sizeof(acUrl));
   fobjcld_setup_request(pHdl, acUrl, pHdl->iTimeout);
   FSETL(pHdl, FOBJCLD_OPT_NOBODY, 1L);

   iCc = pHdl->pF->curl_easy_perform(pHdl->pCurl);
   if (iCc != FOBJCLD_CURLE_OK) {
      /* CHK always returns 0; connectivity errors treated as "not found" */
      return 0;
   }

   FGETL(pHdl, FOBJCLD_INFO_RESPONSE_CODE, &siCode);
   if (siCode == 200) {
      FGETO(pHdl, FOBJCLD_INFO_CONTENT_LENGTH_T, &siContLen);
      if (siContLen < 0) {
         /* content length not known -- assume it meets the minimum */
         puPara->stChk.isExists = 1;
      } else if ((unsigned int)siContLen >= puPara->stChk.uiMinLen) {
         puPara->stChk.isExists = 1;
      } else {
         /* There, but shorter than asked for -- a different answer from
          * "not there", and the SPI has a flag for it. */
         puPara->stChk.isTooShort = 1;
      }
   }
   /* 404 or any other code: isExists remains 0 */

   return 0;
}

/* =========================================================================
 * FOBJUSR -- provider entry point
 * ========================================================================= */

/**
 * @brief FOBJ SPI entry point for the S3 cloud object store provider.
 *
 * This is the only exported symbol.  Dispatch table: OPN allocates the
 * handle; all other codes require a valid handle in *ppHdl.
 *
 * @param pvGbl     FLAM global handle (ignored by this provider).
 * @param ppHdl     Provider handle (NULL on first OPN; set by provider).
 * @param piRetco   Return code output (0 = success).
 * @param piVersion Interface version pointer passed by OBJSSE.
 * @param piFunc    Function code (FOBJ_FUNC_*).
 * @param puPara    Parameter union; active member determined by *piFunc.
 * @param piMsgSiz  Capacity of pcMsg in bytes.
 * @param pcMsg     Error / info message buffer (null-terminated on output).
 */
void FOBJUSR(void*                pvGbl,
             void**               ppHdl,
             int*                 piRetco,
             const unsigned int*  piVersion,
             const unsigned int*  piFunc,
             TuFobjPar*           puPara,
             const unsigned int*  piMsgSiz,
             char*                pcMsg)
{
   (void)pvGbl;

   if (pcMsg != NULL && *piMsgSiz > 0) {
      pcMsg[0] = '\0';
   }

   /* version check: major version must match */
   if (FOBJ_VER_MAJOR(*piVersion) != FOBJ_VER_MAJOR(FOBJ_VERSION)) {
      if (pcMsg != NULL) {
         snprintf(pcMsg, *piMsgSiz,
                  "FOBJCLD: Interface version mismatch: caller=0x%04X "
                  "provider=0x%04X",
                  *piVersion, FOBJ_VERSION);
      }
      *piRetco = FOBJCLD_RTC_PAR;
      return;
   }

   *piRetco = 0;

   switch (*piFunc) {
   /* Lifecycle order: NEW first, FRE last (after all operational codes). */
   case FOBJ_FUNC_NEW:
      *piRetco = fobjcld_new(ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_OPN:
      *piRetco = fobjcld_opn(ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_CLS:
      *piRetco = fobjcld_cls(ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_EXS:
      /* EXS is called in NEW state -- uses stored connection details */
      if (*ppHdl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, *piMsgSiz, "FOBJCLD: EXS called without handle");
         }
         *piRetco = FOBJCLD_RTC_PAR;
         break;
      }
      *piRetco = fobjcld_exs_new((TsFobjS3Hdl*)*ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_DST:
      /* DST is called in NEW state -- uses stored prefix from prior OPN */
      if (*ppHdl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, *piMsgSiz, "FOBJCLD: DST called without handle");
         }
         *piRetco = FOBJCLD_RTC_PAR;
         break;
      }
      *piRetco = fobjcld_dst_new((TsFobjS3Hdl*)*ppHdl, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_GET:
      if (*ppHdl == NULL || ((TsFobjS3Hdl*)*ppHdl)->pCurl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, *piMsgSiz, "FOBJCLD: GET called in non-OPEN state");
         }
         *piRetco = FOBJCLD_RTC_PAR;
         break;
      }
      *piRetco = fobjcld_get((TsFobjS3Hdl*)*ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_PUT:
      if (*ppHdl == NULL || ((TsFobjS3Hdl*)*ppHdl)->pCurl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, *piMsgSiz, "FOBJCLD: PUT called in non-OPEN state");
         }
         *piRetco = FOBJCLD_RTC_PAR;
         break;
      }
      *piRetco = fobjcld_put((TsFobjS3Hdl*)*ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_DEL:
      if (*ppHdl == NULL || ((TsFobjS3Hdl*)*ppHdl)->pCurl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, *piMsgSiz, "FOBJCLD: DEL called in non-OPEN state");
         }
         *piRetco = FOBJCLD_RTC_PAR;
         break;
      }
      *piRetco = fobjcld_del((TsFobjS3Hdl*)*ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   case FOBJ_FUNC_CHK:
      if (*ppHdl == NULL || ((TsFobjS3Hdl*)*ppHdl)->pCurl == NULL) {
         if (pcMsg != NULL) {
            snprintf(pcMsg, *piMsgSiz, "FOBJCLD: CHK called in non-OPEN state");
         }
         *piRetco = FOBJCLD_RTC_PAR;
         break;
      }
      *piRetco = fobjcld_chk((TsFobjS3Hdl*)*ppHdl, puPara, pcMsg, *piMsgSiz);
      break;
   /* FRE last: releases handle allocated by NEW. */
   case FOBJ_FUNC_FRE:
      *piRetco = fobjcld_fre(ppHdl, pcMsg, *piMsgSiz);
      break;
   default:
      if (pcMsg != NULL) {
         snprintf(pcMsg, *piMsgSiz,
                  "FOBJCLD: Unknown function code %u", *piFunc);
      }
      *piRetco = FOBJCLD_RTC_PAR;
      break;
   }
}

