From 68bed449d9f42504b01e3ae6bc1d6f81cf20710a Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Wed, 2 Sep 2026 15:16:35 +0200 Subject: [PATCH] package/clamav: add hardening patch for TOCTOU issue in quarantine actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Titouan Christophe Signed-off-by: Raphaël Mélotte --- ...rantine-actions-against-toctou-races.patch | 8117 +++++++++++++++++ 1 file changed, 8117 insertions(+) create mode 100644 package/clamav/0009-harden-quarantine-actions-against-toctou-races.patch diff --git a/package/clamav/0009-harden-quarantine-actions-against-toctou-races.patch b/package/clamav/0009-harden-quarantine-actions-against-toctou-races.patch new file mode 100644 index 0000000000..f698bd081d --- /dev/null +++ b/package/clamav/0009-harden-quarantine-actions-against-toctou-races.patch @@ -0,0 +1,8117 @@ +From: "Val S." +Date: Tue, 30 Jun 2026 21:45:13 -0400 +Subject: Harden quarantine actions against TOCTOU races + +Quarantine actions previously validated a source path or destination +directory, scanned through that path, and then reopened pathnames later to +copy, move, or remove infected files. A local attacker who could replace a +source path, symlink, or quarantine directory between those steps could cause +ClamAV to copy, move, or delete an object different from the one that produced +the detection, or redirect the quarantined output. + +This change makes quarantine actions operate on objects that were opened and +validated before the scan. The shared action_source_t records the user-facing +display path, the action path, the scan descriptor, stable file metadata, and +Windows handle state. clamscan, clamdscan, clamonacc, clamd, and scanmem pass +that source object from scan setup through the quarantine callback so action +code does not have to trust mutable pathnames after a detection. + +On POSIX, destination setup opens and retains the validated quarantine +directory, then creates the per-run lock file and destination files relative to +that directory. Source opens use no-follow traversal for resolved action paths. +Copy reads from the opened scan descriptor. Move is copy plus a verified +unlink, and remove captures the final path component into a private directory +before deleting it. Device and inode metadata from the scanned file are checked +before unlinking. Copy and move revalidate that the configured quarantine path +still names the retained destination directory before reporting success. +Mismatched private captures are restored only when the platform supports +no-replace rename. That conservative behavior avoids hiding a replacement +object when a writable directory races the action path. + +On Windows, destination setup retains an opened directory handle and actions +operate through normalized long-path names or retained handles. Move and remove +try to keep DELETE access on the scanned source handle; if that access is not +available, scanning still proceeds and the action reopens the path at action +time, validating the reopened object against the scanned file before deleting. +Windows copy preserves alternate data streams, basic file times and attributes, +and security metadata where available. + +File metadata preservation now follows the platform capabilities. Linux copies +mode, ownership when permitted, timestamps, and extended attributes from the +scanned source. macOS uses fcopyfile() with COPYFILE_ALL. Windows copies named +streams, basic metadata, and security descriptors. Metadata failures that are +not required for a safe detection do not suppress the detection. + +The client paths now keep reporting stable for users while separating display +paths from action paths. clamd and clamdscan still print the submitted path, +but clamd resolves callback paths before opening them so a symlinked parent +component cannot change the object scanned after cli_ftw() checks. VirusEvent +and quarantine actions receive the resolved scan target. clamdscan and +clamonacc use client-opened FILDES or INSTREAM scans when quarantine actions +are enabled so clamd does not need to reopen mutable source names. For local +quarantine-action directory walks, clamdscan applies clamd CrossFilesystems=no +policy to avoid crossing mount points that daemon-side scans would skip. +clamdscan --memory applies clamd ExcludePath to each module before direct or +packed-module scans. When quarantine actions are enabled, scanmem resolves +memory module action sources with the same sharing policy used by action +source opens, filters both the submitted module path and resolved path, and +uses the resolved path for direct module scanning and quarantine action setup. +scanmem avoids caching infected module results while actions are enabled so +move or remove can be retried after unload or action failure. + +The regression coverage exercises destination replacement, source symlink and +replacement races, relative source paths, clamdscan scan modes, quarantine lock +creation through the validated directory, Windows move and delete behavior, and +metadata preservation paths. + +Reported-by: Hiroki Imai from Ricerca Security, Inc. + +CLAM-2959 + +--- +Upstream: https://github.com/Cisco-Talos/clamav/commit/2b7eb60f93d584465e0f3a9b2eb19da308cc7ff1 +[Titouan: fix merge conflict with clamav-1.4.3] +Signed-off-by: Titouan Christophe +--- + clamd/scanner.c | 60 +- + clamd/server-th.c | 2 + + clamd/session.c | 15 +- + clamd/session.h | 1 + + clamdscan/client.c | 40 +- + clamdscan/proto.c | 297 +- + clamonacc/client/client.c | 92 +- + clamonacc/client/protocol.c | 138 +- + clamonacc/client/protocol.h | 5 +- + clamonacc/client/socket.c | 7 +- + clamonacc/client/socket.h | 3 +- + clamscan/manager.c | 202 +- + common/actions.c | 3876 +++++++++++++++-- + common/actions.h | 74 +- + common/clamdcom.c | 153 +- + common/clamdcom.h | 9 +- + common/scanmem.c | 277 +- + common/scanmem.h | 6 +- + docs/man/clamd.8.in | 24 +- + docs/man/clamdscan.1.in | 47 +- + libclamav/libclamav.map | 1 + + libclamav/others_common.c | 40 +- + unit_tests/check_clamav.c | 161 + + unit_tests/clamd_test.py | 209 + + unit_tests/clamscan/quarantine_toctou_test.py | 824 ++++ + 25 files changed, 5908 insertions(+), 655 deletions(-) + create mode 100644 unit_tests/clamscan/quarantine_toctou_test.py + +diff --git a/clamd/scanner.c b/clamd/scanner.c +index a6718569e..59fa2bcb2 100644 +--- a/clamd/scanner.c ++++ b/clamd/scanner.c +@@ -140,21 +140,11 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + { + struct scan_cb_data *scandata = data->data; + const char *virname = NULL; +- int ret; ++ cl_error_t ret = CL_SUCCESS; + int type = scandata->type; + struct cb_context context; +- char *real_filename = NULL; +- +- if (NULL != filename) { +- if (CL_SUCCESS != cli_realpath((const char *)filename, &real_filename)) { +- conn_reply_errno(scandata->conn, msg, "File path check failure:"); +- logg(LOGG_WARNING, "File path check failure for: %s\n", filename); +- logg(LOGG_DEBUG, "Quarantine of the file may fail if file path contains symlinks.\n"); +- } else { +- free(filename); +- filename = real_filename; +- } +- } ++ char *scan_filename = NULL; ++ const char *scan_path; + + /* detect disconnected socket, + * this should NOT detect half-shutdown sockets (SHUT_WR) */ +@@ -225,12 +215,35 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + return CL_SUCCESS; + } + ++ scan_path = (NULL != msg) ? msg : filename; ++ /* ++ * Resolve the path used for the actual scan open. Keep filename as the ++ * client-visible name, but do not let a symlinked parent component change ++ * the object opened after cli_ftw() has performed its checks. ++ */ ++ if (NULL != scan_path) { ++ ret = cli_realpath(scan_path, &scan_filename); ++ if (CL_SUCCESS != ret) { ++ conn_reply_errno(scandata->conn, filename, "File path check failure:"); ++ logg(LOGG_WARNING, "File path check failure for: %s\n", filename); ++ scandata->errors++; ++ free(filename); ++ return (CL_EMEM == ret) ? ret : CL_SUCCESS; ++ } ++ scan_path = scan_filename; ++ } ++ + if (type == TYPE_MULTISCAN) { + client_conn_t *client_conn = (client_conn_t *)calloc(1, sizeof(struct client_conn_tag)); + if (client_conn) { + client_conn->scanfd = -1; + client_conn->sd = scandata->odesc; +- client_conn->filename = filename; ++ client_conn->filename = (NULL != scan_filename) ? scan_filename : filename; ++ if (NULL != scan_filename) { ++ client_conn->display_filename = filename; ++ scan_filename = NULL; ++ } ++ filename = NULL; + client_conn->cmdtype = COMMAND_MULTISCANFILE; + client_conn->term = scandata->conn->term; + client_conn->options = scandata->options; +@@ -238,7 +251,8 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + client_conn->group = scandata->group; + if (cl_engine_addref(scandata->engine)) { + logg(LOGG_ERROR, "cl_engine_addref() failed\n"); +- free(filename); ++ free(client_conn->filename); ++ free(client_conn->display_filename); + free(client_conn); + return CL_EMEM; + } else { +@@ -249,7 +263,8 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + if (!thrmgr_group_dispatch(scandata->thr_pool, scandata->group, client_conn, 1)) { + logg(LOGG_ERROR, "thread dispatch failed\n"); + cl_engine_free(scandata->engine); +- free(filename); ++ free(client_conn->filename); ++ free(client_conn->display_filename); + free(client_conn); + return CL_EMEM; + } +@@ -257,6 +272,7 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + } else { + logg(LOGG_ERROR, "Can't allocate memory for client_conn\n"); + scandata->errors++; ++ free(scan_filename); + free(filename); + return CL_EMEM; + } +@@ -267,10 +283,11 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + context.filename = filename; + context.virsize = 0; + context.scandata = scandata; +- ret = cl_scanfile_callback(filename, &virname, &scandata->scanned, scandata->engine, scandata->options, &context); ++ ret = cl_scanfile_callback(scan_path, &virname, &scandata->scanned, scandata->engine, scandata->options, &context); + thrmgr_setactivetask(NULL, NULL); + + if (thrmgr_group_need_terminate(scandata->conn->group)) { ++ free(scan_filename); + free(filename); + logg(LOGG_DEBUG, "Client disconnected while scanjob was active\n"); + return ret == CL_ETIMEOUT ? ret : CL_BREAK; +@@ -283,11 +300,13 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + + if (ret == CL_EACCES) { + if (conn_reply(scandata->conn, filename, "Access denied.", "ERROR") == -1) { ++ free(scan_filename); + free(filename); + return CL_ETIMEOUT; + } + logg(LOGG_DEBUG, "Access denied: %s\n", filename); + scandata->errors++; ++ free(scan_filename); + free(filename); + return CL_SUCCESS; + } +@@ -298,11 +317,12 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + if (optget(scandata->opts, "PreludeEnable")->enabled) { + prelude_logging(filename, virname, context.virhash, context.virsize); + } +- virusaction(filename, virname, scandata->opts); ++ virusaction(scan_path, virname, scandata->opts); + } else { + scandata->infected++; +- virusaction(filename, virname, scandata->opts); ++ virusaction(scan_path, virname, scandata->opts); + if (conn_reply_virus(scandata->conn, filename, virname) == -1) { ++ free(scan_filename); + free(filename); + return CL_ETIMEOUT; + } +@@ -318,6 +338,7 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + } else if (ret != CL_CLEAN) { + scandata->errors++; + if (conn_reply(scandata->conn, filename, cl_strerror(ret), "ERROR") == -1) { ++ free(scan_filename); + free(filename); + return CL_ETIMEOUT; + } +@@ -326,6 +347,7 @@ cl_error_t scan_callback(STATBUF *sb, char *filename, const char *msg, enum cli_ + logg(LOGG_INFO, "%s: OK\n", filename); + } + ++ free(scan_filename); + free(filename); + + if (ret == CL_EMEM) /* stop scanning */ +diff --git a/clamd/server-th.c b/clamd/server-th.c +index 7dd30d5fb..8af257df9 100644 +--- a/clamd/server-th.c ++++ b/clamd/server-th.c +@@ -136,6 +136,8 @@ static void scanner_thread(void *arg) + + if (conn->filename) + free(conn->filename); ++ if (conn->display_filename) ++ free(conn->display_filename); + logg(LOGG_DEBUG_NV, "Finished scanthread\n"); + enum thrmgr_exit exit_code; + if (virus != 0) { +diff --git a/clamd/session.c b/clamd/session.c +index 3bdd26bba..bffe9ff89 100644 +--- a/clamd/session.c ++++ b/clamd/session.c +@@ -278,19 +278,28 @@ int command(client_conn_t *conn, int *virus) + break; + } + case COMMAND_MULTISCANFILE: ++ { ++ char *scan_filename = conn->filename; ++ char *display_filename = (NULL != conn->display_filename) ? conn->display_filename : conn->filename; ++ + thrmgr_setactivetask(NULL, "MULTISCANFILE"); + scandata.group = NULL; + scandata.type = TYPE_SCAN; + scandata.thr_pool = NULL; + /* TODO: check ret value */ +- ret = scan_callback(NULL, conn->filename, conn->filename, visit_file, &data); /* callback freed it */ +- conn->filename = NULL; +- *virus = scandata.infected; ++ ret = scan_callback(NULL, display_filename, scan_filename, visit_file, &data); /* callback freed display_filename */ ++ if (scan_filename != display_filename) { ++ free(scan_filename); ++ } ++ conn->filename = NULL; ++ conn->display_filename = NULL; ++ *virus = scandata.infected; + if (ret == CL_BREAK) { + thrmgr_group_terminate(conn->group); + return 1; + } + return scandata.errors > 0 ? scandata.errors : 0; ++ } + case COMMAND_FILDES: + thrmgr_setactivetask(NULL, "FILDES"); + #ifdef HAVE_FD_PASSING +diff --git a/clamd/session.h b/clamd/session.h +index 60c6778a3..3ef7f4c12 100644 +--- a/clamd/session.h ++++ b/clamd/session.h +@@ -88,6 +88,7 @@ enum commands { + typedef struct client_conn_tag { + enum commands cmdtype; + char *filename; ++ char *display_filename; + int scanfd; + int sd; + struct cl_scan_options *options; +diff --git a/clamdscan/client.c b/clamdscan/client.c +index 57570a1bd..c516eac41 100644 +--- a/clamdscan/client.c ++++ b/clamdscan/client.c +@@ -310,22 +310,11 @@ static char *makeabs(const char *basepath) + static int client_scan(const char *file, int scantype, int *infected, int *err, int maxlevel, int session, int flags) + { + int ret; +- char *real_path = NULL; + char *fullpath = NULL; + +- /* Convert relative path to fullpath */ ++ /* Convert relative path to fullpath without resolving symlinks. */ + fullpath = makeabs(file); + +- /* Convert fullpath to the real path (evaluating symlinks and . and ..). +- Doing this early on will ensure that the scan results will appear consistent +- across regular scans, --fdpass scans, and --stream scans. */ +- if (CL_SUCCESS != cli_realpath(fullpath, &real_path)) { +- logg(LOGG_DEBUG, "client_scan: Failed to determine real filename of %s.\n", fullpath); +- } else { +- free(fullpath); +- fullpath = real_path; +- } +- + if (!fullpath) + return 0; + if (!session) +@@ -392,6 +381,7 @@ int reload_clamd_database(const struct optstruct *opts) + int client(const struct optstruct *opts, int *infected, int *err) + { + int remote, scantype, session = 0, errors = 0, scandash = 0, maxrec, flags = 0; ++ int action_requested, client_side_multiscan = 0; + const char *fname; + + if (optget(opts, "wait")->enabled) { +@@ -407,14 +397,31 @@ int client(const struct optstruct *opts, int *infected, int *err) + } + + scandash = (opts->filename && opts->filename[0] && !strcmp(opts->filename[0], "-") && !optget(opts, "file-list")->enabled && !opts->filename[1]); +- remote = isremote(opts) | optget(opts, "stream")->enabled; ++ remote = isremote(opts) | optget(opts, "stream")->enabled; ++ action_requested = (NULL != action) || ++ optget(opts, "move")->enabled || ++ optget(opts, "copy")->enabled || ++ optget(opts, "remove")->enabled; ++ ++ if (action_requested && optget(opts, "allmatch")->enabled) { ++ logg(LOGG_WARNING, "--allmatch is ignored when quarantine actions are enabled.\n"); ++ } ++ ++ /* ++ * Quarantine actions must scan a client-opened file object so the action ++ * can run against the same object after the verdict. When multiscan is ++ * requested, use IDSESSION for client-side parallelism instead of sending ++ * whole directories to clamd. ++ */ ++ client_side_multiscan = action_requested && optget(opts, "multiscan")->enabled; + #ifdef HAVE_FD_PASSING +- if (!remote && optget(clamdopts, "LocalSocket")->enabled && (optget(opts, "fdpass")->enabled || scandash)) { ++ if (!remote && optget(clamdopts, "LocalSocket")->enabled && ++ (action_requested || optget(opts, "fdpass")->enabled || scandash || client_side_multiscan)) { + scantype = FILDES; + session = optget(opts, "multiscan")->enabled; + } else + #endif +- if (remote || scandash) { ++ if (action_requested || remote || scandash || client_side_multiscan) { + scantype = STREAM; + session = optget(opts, "multiscan")->enabled; + } else if (optget(opts, "multiscan")->enabled) +@@ -443,7 +450,8 @@ int client(const struct optstruct *opts, int *infected, int *err) + return 2; + } + if ((sb.st_mode & S_IFMT) != S_IFREG) scantype = STREAM; +- if ((sockd = dconnect(clamdopts)) >= 0 && (ret = dsresult(sockd, scantype, NULL, &ret, NULL, clamdopts)) >= 0) ++ if ((sockd = dconnect(clamdopts)) >= 0 && ++ (ret = dsresult(sockd, scantype, NULL, NULL, false, &ret, NULL, clamdopts)) >= 0) + *infected = ret; + else + errors = 1; +diff --git a/clamdscan/proto.c b/clamdscan/proto.c +index 4c26e1695..0eba9a8b1 100644 +--- a/clamdscan/proto.c ++++ b/clamdscan/proto.c +@@ -45,6 +45,9 @@ + #include + #endif + #ifndef _WIN32 ++#include ++#endif ++#ifndef _WIN32 + #include + #include + #include +@@ -68,44 +71,151 @@ extern unsigned long int maxstream; + int printinfected; + extern struct optstruct *clamdopts; + ++#define DEFAULT_MAX_ACTION_SOURCES 64 ++ ++static unsigned int get_max_action_sources(void) ++{ ++#ifndef _WIN32 ++#ifdef RLIMIT_NOFILE ++ struct rlimit rlim; ++ ++ if ((0 == getrlimit(RLIMIT_NOFILE, &rlim)) && (RLIM_INFINITY != rlim.rlim_cur)) { ++ rlim_t limit = rlim.rlim_cur; ++ ++ if (limit <= 16) { ++ return 1; ++ } ++ ++ limit = (limit - 16) / 2; ++ if (limit < 1) { ++ return 1; ++ } ++ if (limit < DEFAULT_MAX_ACTION_SOURCES) { ++ return (unsigned int)limit; ++ } ++ } ++#endif ++#endif ++ ++ return DEFAULT_MAX_ACTION_SOURCES; ++} ++ ++struct client_walk_policy { ++ bool check_cross_filesystems; ++ bool have_dev; ++ dev_t dev; ++#ifdef C_LINUX ++ bool check_proc; ++ dev_t proc_dev; ++#endif ++}; ++ ++/** ++ * Initialize client-side traversal policy for local quarantine actions. ++ * ++ * When clamdscan performs local actions, it walks paths client-side and sends ++ * individual files to clamd. Preserve clamd's procfs and CrossFilesystems=no ++ * behavior for those action scans so files skipped by daemon-side directory ++ * scans are not opened or quarantined by the client-side walk. ++ */ ++static void client_walk_policy_init(struct client_walk_policy *policy, const char *path) ++{ ++ STATBUF sb; ++ ++ policy->check_cross_filesystems = (NULL != action) && !optget(clamdopts, "CrossFilesystems")->enabled; ++ policy->have_dev = false; ++ policy->dev = 0; ++#ifdef C_LINUX ++ policy->check_proc = false; ++ policy->proc_dev = 0; ++ ++ if ((NULL != action) && (0 == CLAMSTAT("/proc", &sb)) && !sb.st_size) { ++ policy->check_proc = true; ++ policy->proc_dev = sb.st_dev; ++ } ++#endif ++ ++ if (policy->check_cross_filesystems && (0 == CLAMSTAT(path, &sb))) { ++ policy->have_dev = true; ++ policy->dev = sb.st_dev; ++ } ++} ++ ++/** ++ * Return nonzero when a path should be skipped by a client-side walk. ++ */ ++static int client_path_excluded(const char *path, const struct client_walk_policy *policy) ++{ ++ STATBUF sb; ++ ++ if (chkpath(path, clamdopts)) { ++ return 1; ++ } ++ ++#ifdef C_LINUX ++ if ((NULL != policy) && policy->check_proc && ++ (0 == CLAMSTAT(path, &sb)) && (sb.st_dev == policy->proc_dev)) { ++ return 1; ++ } ++#endif ++ ++ if ((NULL != policy) && policy->check_cross_filesystems && policy->have_dev && ++ (0 == CLAMSTAT(path, &sb)) && (sb.st_dev != policy->dev)) { ++ return 1; ++ } ++ ++ return 0; ++} ++ + static int ftw_chkpath(const char *path, struct cli_ftw_cbdata *data) + { +- UNUSEDPARAM(data); +- return chkpath(path, clamdopts); ++ const struct client_walk_policy *policy = NULL; ++ ++ if ((NULL != data) && (NULL != data->data)) { ++ policy = (const struct client_walk_policy *)data->data; ++ } ++ ++ return client_path_excluded(path, policy); + } + + /* Used by serial_callback() */ + struct client_serial_data { ++ /* Must be first: ftw_chkpath() receives only cli_ftw_cbdata::data. */ ++ struct client_walk_policy walk_policy; + int infected; + int scantype; + int printok; + int files; + int errors; ++ int flags; ++ int maxlevel; + }; + + /* FTW callback for scanning in non IDSESSION mode + * Returns SUCCESS or BREAK on success, CL_EXXX on error */ + static cl_error_t serial_callback(STATBUF *sb, char *filename, const char *path, enum cli_ftw_reason reason, struct cli_ftw_cbdata *data) + { +- int status = CL_EOPEN; ++ cl_error_t status = CL_EOPEN; + + struct client_serial_data *c = (struct client_serial_data *)data->data; + int sockd, ret; +- const char *f = filename; +- char *real_filename = NULL; ++ const char *f = filename; ++ const char *scan_path = path; ++ char *real_filter_path = NULL; ++ action_source_t action_source; ++ bool have_action_source = false; ++ ++ action_source_init(&action_source); + + UNUSEDPARAM(sb); + +- if (reason != visit_directory_toplev) { +- if (CL_SUCCESS != cli_realpath((const char *)path, &real_filename)) { +- logg(LOGG_DEBUG, "Failed to determine real filename of %s.\n", path); +- logg(LOGG_DEBUG, "Quarantine of the file may fail if file path contains symlinks.\n"); +- } else { +- path = real_filename; +- } ++ if (CL_SUCCESS != cli_realpath((const char *)path, &real_filter_path)) { ++ logg(LOGG_DEBUG, "Failed to determine real filename of %s.\n", path); ++ } else { ++ scan_path = real_filter_path; + } + +- if (chkpath(path, clamdopts)) { ++ if (client_path_excluded(scan_path, &c->walk_policy)) { + /* Exclude the path */ + status = CL_SUCCESS; + goto done; +@@ -134,12 +244,25 @@ static cl_error_t serial_callback(STATBUF *sb, char *filename, const char *path, + status = CL_SUCCESS; + goto done; + case visit_directory_toplev: +- if (c->scantype >= STREAM) { ++ if ((c->scantype >= STREAM) || action) { + status = CL_SUCCESS; + goto done; + } +- f = path; ++ f = scan_path; ++ break; + case visit_file: ++ if (action) { ++ ret = action_source_open_path(f, scan_path, &action_source); ++ if (CL_SUCCESS != ret) { ++ logg(LOGG_WARNING, "Can't open file %s for safe quarantine action: %s\n", f, cl_strerror(ret)); ++ c->errors++; ++ status = CL_SUCCESS; ++ goto done; ++ } ++ have_action_source = true; ++ } else { ++ f = scan_path; ++ } + break; + } + +@@ -147,7 +270,7 @@ static cl_error_t serial_callback(STATBUF *sb, char *filename, const char *path, + c->errors++; + goto done; + } +- ret = dsresult(sockd, c->scantype, f, &c->printok, &c->errors, clamdopts); ++ ret = dsresult(sockd, c->scantype, f, have_action_source ? &action_source : NULL, have_action_source, &c->printok, &c->errors, clamdopts); + closesocket(sockd); + if (ret < 0) { + c->errors++; +@@ -161,8 +284,11 @@ static cl_error_t serial_callback(STATBUF *sb, char *filename, const char *path, + + status = CL_SUCCESS; + done: +- if (NULL != real_filename) { +- free(real_filename); ++ if (have_action_source) { ++ action_source_close(&action_source); ++ } ++ if (NULL != real_filter_path) { ++ free(real_filter_path); + } + free(filename); + return status; +@@ -181,6 +307,9 @@ int serial_client_scan(char *file, int scantype, int *infected, int *err, int ma + cdata.errors = 0; + cdata.printok = printinfected ^ 1; + cdata.scantype = scantype; ++ cdata.flags = flags; ++ cdata.maxlevel = maxlevel ? maxlevel : INT_MAX; ++ client_walk_policy_init(&cdata.walk_policy, file); + data.data = &cdata; + + ftw = cli_ftw(file, flags, maxlevel ? maxlevel : INT_MAX, serial_callback, &data, ftw_chkpath); +@@ -200,6 +329,8 @@ int serial_client_scan(char *file, int scantype, int *infected, int *err, int ma + + /* Used in IDSESSION mode */ + struct client_parallel_data { ++ /* Must be first: ftw_chkpath() receives only cli_ftw_cbdata::data. */ ++ struct client_walk_policy walk_policy; + int infected; + int files; + int errors; +@@ -210,8 +341,11 @@ struct client_parallel_data { + struct SCANID { + unsigned int id; + const char *file; ++ action_source_t *action_source; + struct SCANID *next; + } *ids; ++ unsigned int action_sources; ++ unsigned int max_action_sources; + }; + + /* Sends a proper scan request to clamd and parses its replies +@@ -220,6 +354,7 @@ struct client_parallel_data { + static int dspresult(struct client_parallel_data *c) + { + const char *filename; ++ action_source_t *action_source; + char *bol, *eol; + unsigned int rid; + int len; +@@ -244,17 +379,17 @@ static int dspresult(struct client_parallel_data *c) + return 1; + } + filename = (*id)->file; ++ action_source = (*id)->action_source; + if (len > 7) { + char *colon = strrchr(bol, ':'); + if (!colon) { + logg(LOGG_ERROR, "Failed to parse reply\n"); +- free((void *)filename); + return 1; + } else if (!memcmp(eol - 7, " FOUND", 6)) { + c->infected++; + c->printok = 0; + logg(LOGG_INFO, "%s%s\n", filename, colon); +- if (action) action(filename); ++ if (action && (NULL != action_source)) action(action_source); + } else if (!memcmp(eol - 7, " ERROR", 6)) { + c->errors++; + c->printok = 0; +@@ -262,6 +397,13 @@ static int dspresult(struct client_parallel_data *c) + } + } + free((void *)filename); ++ if (NULL != action_source) { ++ action_source_close(action_source); ++ free(action_source); ++ if (c->action_sources > 0) { ++ c->action_sources--; ++ } ++ } + bol = (char *)*id; + *id = (*id)->next; + free(bol); +@@ -270,6 +412,30 @@ static int dspresult(struct client_parallel_data *c) + return 0; + } + ++static void free_scanids(struct client_parallel_data *c) ++{ ++ struct SCANID *id; ++ ++ if (NULL == c) { ++ return; ++ } ++ ++ while (NULL != c->ids) { ++ id = c->ids; ++ c->ids = id->next; ++ ++ free((void *)id->file); ++ if (NULL != id->action_source) { ++ action_source_close(id->action_source); ++ free(id->action_source); ++ if (c->action_sources > 0) { ++ c->action_sources--; ++ } ++ } ++ free(id); ++ } ++} ++ + /* FTW callback for scanning in IDSESSION mode + * Returns SUCCESS on success, CL_EXXX or BREAK on error */ + static cl_error_t parallel_callback(STATBUF *sb, char *filename, const char *path, enum cli_ftw_reason reason, struct cli_ftw_cbdata *data) +@@ -278,24 +444,21 @@ static cl_error_t parallel_callback(STATBUF *sb, char *filename, const char *pat + + struct client_parallel_data *c = (struct client_parallel_data *)data->data; + struct SCANID *cid = NULL; +- int res = CL_CLEAN; +- +- char *real_filename = NULL; ++ int res = 0; ++ action_source_t *action_source = NULL; ++ const char *scan_path = filename; ++ char *real_filter_path = NULL; + + UNUSEDPARAM(sb); + UNUSEDPARAM(path); + +- if (reason != visit_directory_toplev) { +- if (CL_SUCCESS != cli_realpath((const char *)filename, &real_filename)) { +- logg(LOGG_DEBUG, "Failed to determine real filename of %s.\n", filename); +- logg(LOGG_DEBUG, "Quarantine of the file may fail if file path contains symlinks.\n"); +- } else { +- free(filename); /* callback is responsible for free'ing filename parameter. */ +- filename = real_filename; +- } ++ if (CL_SUCCESS != cli_realpath((const char *)filename, &real_filter_path)) { ++ logg(LOGG_DEBUG, "Failed to determine real filename of %s.\n", filename); ++ } else { ++ scan_path = real_filter_path; + } + +- if (chkpath(filename, clamdopts)) { ++ if (client_path_excluded(scan_path, &c->walk_policy)) { + /* Exclude the path */ + status = CL_SUCCESS; + goto done; +@@ -328,6 +491,29 @@ static cl_error_t parallel_callback(STATBUF *sb, char *filename, const char *pat + break; + } + ++ if (action) { ++ while (c->action_sources >= c->max_action_sources) { ++ if (dspresult(c)) { ++ status = CL_BREAK; ++ goto done; ++ } ++ } ++ ++ action_source = malloc(sizeof(*action_source)); ++ if (NULL == action_source) { ++ logg(LOGG_ERROR, "Failed to allocate action source: %s\n", strerror(errno)); ++ c->errors++; ++ status = CL_EMEM; ++ goto done; ++ } ++ if (CL_SUCCESS != action_source_open_path(filename, scan_path, action_source)) { ++ logg(LOGG_WARNING, "Can't open file %s for safe quarantine action.\n", filename); ++ c->errors++; ++ status = CL_SUCCESS; ++ goto done; ++ } ++ } ++ + while (1) { + /* consume all the available input to let some of the clamd + * threads blocked on send() to be dead. +@@ -356,11 +542,11 @@ static cl_error_t parallel_callback(STATBUF *sb, char *filename, const char *pat + switch (c->scantype) { + #ifdef HAVE_FD_PASSING + case FILDES: +- res = send_fdpass(c->sockd, filename); ++ res = (NULL != action_source) ? send_fdpass_fd(c->sockd, action_source->scan_fd) : send_fdpass(c->sockd, scan_path); + break; + #endif + case STREAM: +- res = send_stream(c->sockd, filename, clamdopts); ++ res = (NULL != action_source) ? send_stream_fd_action(c->sockd, action_source->scan_fd, action_source->display_path, clamdopts) : send_stream(c->sockd, scan_path, clamdopts); + break; + } + if (res <= 0) { +@@ -377,20 +563,32 @@ static cl_error_t parallel_callback(STATBUF *sb, char *filename, const char *pat + goto done; + } + +- cid->id = ++c->lastid; +- cid->file = filename; +- cid->next = c->ids; +- c->ids = cid; ++ cid->id = ++c->lastid; ++ cid->file = filename; ++ cid->action_source = action_source; ++ cid->next = c->ids; ++ c->ids = cid; ++ if (NULL != action_source) { ++ c->action_sources++; ++ } + + /* Give up ownership of the filename to the client parallel scan ID list */ +- filename = NULL; ++ filename = NULL; ++ action_source = NULL; + + status = CL_SUCCESS; + + done: ++ if (NULL != action_source) { ++ action_source_close(action_source); ++ free(action_source); ++ } + if (NULL != filename) { + free(filename); + } ++ if (NULL != real_filter_path) { ++ free(real_filter_path); ++ } + return status; + } + +@@ -412,20 +610,24 @@ int parallel_client_scan(char *file, int scantype, int *infected, int *err, int + return 1; + } + +- cdata.infected = 0; +- cdata.files = 0; +- cdata.errors = 0; +- cdata.scantype = scantype; +- cdata.lastid = 0; +- cdata.ids = NULL; +- cdata.printok = printinfected ^ 1; +- data.data = &cdata; ++ cdata.infected = 0; ++ cdata.files = 0; ++ cdata.errors = 0; ++ cdata.scantype = scantype; ++ cdata.lastid = 0; ++ cdata.ids = NULL; ++ cdata.printok = printinfected ^ 1; ++ cdata.action_sources = 0; ++ cdata.max_action_sources = get_max_action_sources(); ++ client_walk_policy_init(&cdata.walk_policy, file); ++ data.data = &cdata; + + ftw = cli_ftw(file, flags, maxlevel ? maxlevel : INT_MAX, parallel_callback, &data, ftw_chkpath); + + if (ftw != CL_SUCCESS) { + *err += cdata.errors; + *infected += cdata.infected; ++ free_scanids(&cdata); + closesocket(cdata.sockd); + return 1; + } +@@ -439,6 +641,7 @@ int parallel_client_scan(char *file, int scantype, int *infected, int *err, int + + if (cdata.ids) { + logg(LOGG_ERROR, "Clamd closed the connection before scanning all files.\n"); ++ free_scanids(&cdata); + return 1; + } + if (cdata.errors) +diff --git a/clamonacc/client/client.c b/clamonacc/client/client.c +index d87914ab2..800f589d7 100644 +--- a/clamonacc/client/client.c ++++ b/clamonacc/client/client.c +@@ -403,6 +403,8 @@ cl_error_t onas_setup_client(struct onas_context **ctx) + const struct optstruct *opts; + cl_error_t err; + int remote; ++ bool action_requested; ++ bool fdpass_requested; + + errno = 0; + +@@ -427,9 +429,17 @@ cl_error_t onas_setup_client(struct onas_context **ctx) + } + + remote = (*ctx)->isremote | optget(opts, "stream")->enabled; ++ action_requested = (NULL != action) || ++ optget(opts, "move")->enabled || ++ optget(opts, "copy")->enabled || ++ optget(opts, "remove")->enabled; ++ fdpass_requested = action_requested || optget(opts, "fdpass")->enabled; ++ if (action_requested && optget(opts, "allmatch")->enabled) { ++ logg(LOGG_WARNING, "--allmatch is ignored when quarantine actions are enabled.\n"); ++ } + #ifdef HAVE_FD_PASSING +- if (!remote && optget((*ctx)->clamdopts, "LocalSocket")->enabled && (optget(opts, "fdpass")->enabled)) { +- if (onas_set_sock_only_once(*ctx) == CL_EWRITE) { ++ if (!remote && optget((*ctx)->clamdopts, "LocalSocket")->enabled && fdpass_requested) { ++ if (onas_set_sock_only_once(*ctx, fdpass_requested) == CL_EWRITE) { + return CL_EWRITE; + } + logg(LOGG_DEBUG, "ClamClient: client setup to scan via fd passing\n"); +@@ -437,7 +447,7 @@ cl_error_t onas_setup_client(struct onas_context **ctx) + (*ctx)->session = optget(opts, "multiscan")->enabled; + } else + #endif +- if (remote) { ++ if (action_requested || remote) { + logg(LOGG_DEBUG, "ClamClient: client setup to scan via streaming\n"); + (*ctx)->scantype = STREAM; + (*ctx)->session = optget(opts, "multiscan")->enabled; +@@ -534,20 +544,55 @@ int onas_client_scan(const char *tcpaddr, int64_t portnum, int32_t scantype, uin + CURL *curl = NULL; + CURLcode curlcode = CURLE_OK; + int errors = 0; +- int ret; ++ int scan_result = 0; ++ int printok = 1; ++ cl_error_t status = CL_CLEAN; ++ action_source_t action_source; ++ char *resolved_action_path = NULL; ++ bool have_action_source = false; ++ bool regular_file = S_ISREG(sb.st_mode); + static bool disconnected = false; + ++ action_source_init(&action_source); ++ + *infected = 0; ++ if (err) { ++ *err = 0; ++ } ++ if (ret_code) { ++ *ret_code = CL_SUCCESS; ++ } + +- if ((sb.st_mode & S_IFMT) != S_IFREG) { ++ if (!regular_file) { + scantype = STREAM; + } + ++ if (action && (NULL != fname) && regular_file) { ++ if (fd >= 0) { ++ status = action_source_from_fd(fname, fd, &action_source); ++ } else { ++ status = cli_realpath(fname, &resolved_action_path); ++ if (CL_SUCCESS == status) { ++ status = action_source_open_path(fname, resolved_action_path, &action_source); ++ } ++ } ++ if (CL_SUCCESS != status) { ++ logg(LOGG_WARNING, "Can't open file %s for safe quarantine action: %s\n", fname, cl_strerror(status)); ++ goto done; ++ } ++ have_action_source = true; ++ if (scantype < STREAM) { ++ scantype = STREAM; ++ } ++ } ++ + curlcode = onas_curl_init(&curl, tcpaddr, portnum, timeout); + if (CURLE_OK != curlcode) { + logg(LOGG_ERROR, "ClamClient: could not init curl for scanning, %s\n", curl_easy_strerror(curlcode)); + /* curl cleanup done in onas_curl_init on error */ +- return CL_ECREAT; ++ curl = NULL; ++ status = CL_ECREAT; ++ goto done; + } + + curlcode = curl_easy_perform(curl); +@@ -557,20 +602,43 @@ int onas_client_scan(const char *tcpaddr, int64_t portnum, int32_t scantype, uin + disconnected = true; + } + curl_easy_cleanup(curl); +- return CL_ECREAT; ++ curl = NULL; ++ status = CL_ECREAT; ++ goto done; + } + if (disconnected) { + logg(LOGG_INFO, "ClamClient: Connection to clamd re-established.\n"); + disconnected = false; + } + +- if ((ret = onas_dsresult(curl, scantype, maxstream, fname, fd, timeout, &ret, err, ret_code)) >= 0) { +- *infected = ret; ++ if ((scan_result = onas_dsresult(curl, scantype, maxstream, fname, have_action_source ? &action_source : NULL, fd, timeout, ++ &printok, err, ret_code)) >= 0) { ++ *infected = scan_result; + } else { +- logg(LOGG_DEBUG, "ClamClient: connection could not be established ... return code %d\n", *ret_code); ++ logg(LOGG_DEBUG, "ClamClient: connection could not be established ... return code %d\n", ret_code ? *ret_code : scan_result); + errors = 1; + } + +- curl_easy_cleanup(curl); +- return *infected ? CL_VIRUS : (errors ? CL_ECREAT : CL_CLEAN); ++ status = *infected ? CL_VIRUS : (errors ? CL_ECREAT : CL_CLEAN); ++ ++done: ++ if ((CL_CLEAN != status) && (CL_VIRUS != status)) { ++ if (err && (0 == *err)) { ++ (*err)++; ++ } ++ if (ret_code && (CL_SUCCESS == *ret_code)) { ++ *ret_code = status; ++ } ++ } ++ ++ if (NULL != curl) { ++ curl_easy_cleanup(curl); ++ } ++ if (have_action_source) { ++ action_source_close(&action_source); ++ } ++ if (NULL != resolved_action_path) { ++ free(resolved_action_path); ++ } ++ return status; + } +diff --git a/clamonacc/client/protocol.c b/clamonacc/client/protocol.c +index 30580ed7c..cc03a8bbd 100644 +--- a/clamonacc/client/protocol.c ++++ b/clamonacc/client/protocol.c +@@ -72,7 +72,7 @@ static const char *scancmd[] = {"CONTSCAN", "MULTISCAN", "INSTREAM", "FILDES", " + + /* Issues an INSTREAM command to clamd and streams the given file + * Returns >0 on success, 0 soft fail, -1 hard fail */ +-static int onas_send_stream(CURL *curl, const char *filename, int fd, int64_t timeout, uint64_t maxstream) ++static int onas_send_stream(CURL *curl, const char *filename, int fd, int64_t timeout, uint64_t maxstream, bool action_stream, cl_error_t *ret_code) + { + uint32_t buf[BUFSIZ / sizeof(uint32_t)]; + uint64_t len; +@@ -108,41 +108,85 @@ static int onas_send_stream(CURL *curl, const char *filename, int fd, int64_t ti + } + + if ((uint64_t)statbuf.st_size > maxstream) { +- ret = 0; ++ if (action_stream) { ++ logg(LOGG_ERROR, "%s: File size exceeds StreamMaxLength; refusing to send a truncated quarantine stream. ERROR\n", ++ filename ? filename : "FD"); ++ if (ret_code) { ++ *ret_code = CL_EMAXSIZE; ++ } ++ ret = -1; ++ } else { ++ ret = 0; ++ } + goto strm_out; + } + +- if (onas_sendln(curl, zINSTREAM, sizeof(zINSTREAM), timeout)) { +- ret = -1; +- goto strm_out; ++ if (0 != fd) { ++ (void)lseek(fd, 0, SEEK_SET); + } + +- len = statbuf.st_size; +- buf[0] = htonl(len); +- if (onas_sendln(curl, (const char *)buf, sizeof(uint32_t), timeout)) { ++ if (onas_sendln(curl, zINSTREAM, sizeof(zINSTREAM), timeout)) { + ret = -1; + goto strm_out; + } + ++ len = statbuf.st_size; + while (bytesRead < len) { +- ssize_t ret = read(fd, buf, sizeof(buf)); +- if (ret < 0) { ++ uint64_t remaining = len - bytesRead; ++ size_t read_len = (remaining < sizeof(buf)) ? (size_t)remaining : sizeof(buf); ++ ssize_t bytes = read(fd, buf, read_len); ++ uint32_t chunk_len; ++ ++ if (bytes < 0) { + logg(LOGG_ERROR, "Failed to read from %s.\n", filename ? filename : "FD"); + ret = -1; + goto strm_out; +- } else if (0 == ret) { ++ } else if (0 == bytes) { + break; + } +- bytesRead += ret; ++ bytesRead += bytes; ++ ++ chunk_len = htonl((uint32_t)bytes); ++ if (onas_sendln(curl, (const char *)&chunk_len, sizeof(chunk_len), timeout) || ++ onas_sendln(curl, (const char *)buf, (size_t)bytes, timeout)) { ++ ret = -1; ++ goto strm_out; ++ } ++ } ++ ++ if (bytesRead < len) { ++ logg(LOGG_ERROR, "%s: File changed while streaming; refusing to send a partial INSTREAM chunk. ERROR\n", ++ filename ? filename : "FD"); ++ if (ret_code) { ++ *ret_code = CL_EREAD; ++ } ++ ret = -1; ++ goto strm_out; ++ } ++ ++ if (action_stream && S_ISREG(statbuf.st_mode) && (bytesRead == len)) { ++ ssize_t bytes = read(fd, buf, 1); + +- if (onas_sendln(curl, (const char *)buf, ret, timeout)) { ++ if (bytes < 0) { ++ logg(LOGG_ERROR, "Failed to read from %s.\n", filename ? filename : "FD"); ++ ret = -1; ++ goto strm_out; ++ } else if (bytes > 0) { ++ logg(LOGG_ERROR, "%s: File size exceeds StreamMaxLength; refusing to send a truncated quarantine stream. ERROR\n", ++ filename ? filename : "FD"); ++ if (ret_code) { ++ *ret_code = CL_EMAXSIZE; ++ } + ret = -1; + goto strm_out; + } + } + + *buf = 0; +- onas_sendln(curl, (const char *)buf, 4, timeout); ++ if (onas_sendln(curl, (const char *)buf, 4, timeout)) { ++ ret = -1; ++ goto strm_out; ++ } + + strm_out: + if (close_flag) { +@@ -232,7 +276,8 @@ fd_out: + * This is used only in non IDSESSION mode + * Returns the number of infected files or -1 on error + * NOTE: filename may be NULL for STREAM scantype. */ +-int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *filename, int fd, int64_t timeout, int *printok, int *errors, cl_error_t *ret_code) ++int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *filename, const action_source_t *action_source, ++ int fd, int64_t timeout, int *printok, int *errors, cl_error_t *ret_code) + { + int infected = 0, len = 0, beenthere = 0; + char *bol, *eol; +@@ -240,11 +285,17 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + STATBUF sb; + int sockd = -1; + int (*recv_func)(struct onas_rcvln *, char **, char **, int64_t) = NULL; ++ const char *display_filename = (NULL != action_source) ? action_source->display_path : filename; ++ int scan_fd = (NULL != action_source) ? action_source->scan_fd : fd; + +- sockd = onas_get_sockd(); ++#ifdef HAVE_FD_PASSING ++ if (FILDES == scantype) { ++ sockd = onas_get_sockd(); ++ } ++#endif + + onas_recvlninit(&rcv, curl, sockd); +- if (rcv.sockd > 0) { ++ if ((FILDES == scantype) && (rcv.sockd > 0)) { + recv_func = &onas_fd_recvln; + } else { + recv_func = &onas_recvln; +@@ -285,25 +336,30 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + + case STREAM: + /* NULL filename safe in send_stream() */ +- len = onas_send_stream(curl, filename, fd, timeout, maxstream); ++ len = onas_send_stream(curl, display_filename, scan_fd, timeout, maxstream, NULL != action_source, ret_code); + break; + #ifdef HAVE_FD_PASSING + case FILDES: + /* NULL filename safe in send_fdpass() */ +- len = onas_fdpass(filename, fd, sockd); ++ len = onas_fdpass(display_filename, scan_fd, sockd); + break; + #endif + } + + if (len <= 0) { + *printok = 0; +- if (errors && len < 0) { +- /* Ignore error if len == 0 to reduce verbosity from file open() +- "errors" where the file has been deleted before we have a chance +- to scan it. */ ++ if (errors && ((len < 0) || (NULL != action_source))) { ++ /* ++ * Keep len == 0 as a soft skip for mutable path scans where a file ++ * disappeared before opening. Treat opened action-source failures ++ * as errors because the later action depends on that same object. ++ */ + (*errors)++; + } +- infected = len; ++ if (ret_code && (CL_SUCCESS == *ret_code) && ((len < 0) || (NULL != action_source))) { ++ *ret_code = CL_EWRITE; ++ } ++ infected = ((NULL != action_source) && (0 == len)) ? -1 : len; + goto done; + } + +@@ -318,7 +374,7 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + goto done; + } + beenthere = 1; +- if (!filename) { ++ if (!display_filename) { + logg(LOGG_INFO, "%s\n", bol); + } + if (len > 7) { +@@ -365,17 +421,21 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + } + } + +- if (filename) { ++ if (display_filename) { + if (scantype >= STREAM) { +- logg(LOGG_INFO, "%s%s FOUND\n", filename, colon); ++ logg(LOGG_INFO, "%s%s FOUND\n", display_filename, colon); + if (action) { +- action(filename); ++ if (NULL != action_source) { ++ action(action_source); ++ } + } + } else { + logg(LOGG_INFO, "%s FOUND\n", bol); + *colon = '\0'; + if (action) { +- action(bol); ++ if (NULL != action_source) { ++ action(action_source); ++ } + } + } + } +@@ -391,8 +451,8 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + } + *printok = 0; + +- if (filename) { +- (scantype >= STREAM) ? logg(LOGG_DEBUG, "%s%s\n", filename, colon) : logg(LOGG_DEBUG, "%s\n", bol); ++ if (display_filename) { ++ (scantype >= STREAM) ? logg(LOGG_DEBUG, "%s%s\n", display_filename, colon) : logg(LOGG_DEBUG, "%s\n", bol); + } + + if (ret_code) { +@@ -406,8 +466,8 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + } + *printok = 0; + +- if (filename) { +- (scantype >= STREAM) ? logg(LOGG_INFO, "%s%s\n", filename, colon) : logg(LOGG_INFO, "%s\n", bol); ++ if (display_filename) { ++ (scantype >= STREAM) ? logg(LOGG_INFO, "%s%s\n", display_filename, colon) : logg(LOGG_INFO, "%s\n", bol); + } + + if (ret_code) { +@@ -419,8 +479,8 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + } + *printok = 0; + +- if (filename) { +- (scantype >= STREAM) ? logg(LOGG_INFO, "%s%s\n", filename, colon) : logg(LOGG_INFO, "%s\n", bol); ++ if (display_filename) { ++ (scantype >= STREAM) ? logg(LOGG_INFO, "%s%s\n", display_filename, colon) : logg(LOGG_INFO, "%s\n", bol); + } + + if (ret_code) { +@@ -430,7 +490,7 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + } + } + if (!beenthere) { +- if (!filename) { ++ if (!display_filename) { + logg(LOGG_INFO, "STDIN: noreply from clamd\n."); + if (ret_code) { + *ret_code = CL_EACCES; +@@ -438,9 +498,9 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + infected = -1; + goto done; + } +- if (CLAMSTAT(filename, &sb) == -1) { ++ if (CLAMSTAT(display_filename, &sb) == -1) { + logg(LOGG_INFO, "%s: stat() failed with %s, clamd may not be responding\n", +- filename, strerror(errno)); ++ display_filename, strerror(errno)); + if (ret_code) { + *ret_code = CL_EACCES; + } +@@ -448,7 +508,7 @@ int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *file + goto done; + } + if (!S_ISDIR(sb.st_mode)) { +- logg(LOGG_INFO, "%s: no reply from clamd\n", filename); ++ logg(LOGG_INFO, "%s: no reply from clamd\n", display_filename); + if (ret_code) { + *ret_code = CL_EACCES; + } +diff --git a/clamonacc/client/protocol.h b/clamonacc/client/protocol.h +index 225860ef7..d5c7699f2 100644 +--- a/clamonacc/client/protocol.h ++++ b/clamonacc/client/protocol.h +@@ -27,5 +27,8 @@ + #include "misc.h" + #include "../clamonacc.h" + +-int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *filename, int fd, int64_t timeout, int *printok, int *errors, cl_error_t *ret_code); ++struct action_source; ++ ++int onas_dsresult(CURL *curl, int scantype, uint64_t maxstream, const char *filename, const struct action_source *action_source, ++ int fd, int64_t timeout, int *printok, int *errors, cl_error_t *ret_code); + #endif +diff --git a/clamonacc/client/socket.c b/clamonacc/client/socket.c +index d501ea793..850946157 100644 +--- a/clamonacc/client/socket.c ++++ b/clamonacc/client/socket.c +@@ -41,10 +41,11 @@ struct onas_sock_t onas_sock = {.written = 0}; + /** + * One time socket setup for unix file descriptor passing + * +- * @param ctx a point to the onas context struct ++ * @param ctx a pointer to the onas context struct ++ * @param allow_fdpass true if fd passing is enabled or required by this scan mode + * @return CL_SUCCESS if writing to socket struct was successful, CL_EWRITE if the socket has already been written to + */ +-cl_error_t onas_set_sock_only_once(struct onas_context *ctx) ++cl_error_t onas_set_sock_only_once(struct onas_context *ctx, bool allow_fdpass) + { + + const struct optstruct *opt; +@@ -54,7 +55,7 @@ cl_error_t onas_set_sock_only_once(struct onas_context *ctx) + if (((opt = + optget(ctx->clamdopts, "LocalSocket")) + ->enabled) && +- optget(ctx->opts, "fdpass")->enabled) { ++ allow_fdpass) { + memset((void *)&onas_sock, 0, sizeof(onas_sock)); + onas_sock.sock.sun_family = AF_UNIX; + strncpy(onas_sock.sock.sun_path, opt->strarg, sizeof(onas_sock.sock.sun_path)); +diff --git a/clamonacc/client/socket.h b/clamonacc/client/socket.h +index 8bb517cde..0862c64b9 100644 +--- a/clamonacc/client/socket.h ++++ b/clamonacc/client/socket.h +@@ -20,6 +20,7 @@ + + #include + #include ++#include + + #include "optparser.h" + #include "../clamonacc.h" +@@ -30,5 +31,5 @@ struct onas_sock_t { + struct sockaddr_un sock; + }; + +-cl_error_t onas_set_sock_only_once(struct onas_context *ctx); ++cl_error_t onas_set_sock_only_once(struct onas_context *ctx, bool allow_fdpass); + int onas_get_sockd(void); +diff --git a/clamscan/manager.c b/clamscan/manager.c +index 26b855b43..51f5c0369 100644 +--- a/clamscan/manager.c ++++ b/clamscan/manager.c +@@ -294,15 +294,21 @@ static void clamscan_virus_found_cb(int fd, const char *virname, void *context) + static void scanfile(const char *filename, struct cl_engine *engine, const struct optstruct *opts, struct cl_scan_options *options) + { + cl_error_t ret = CL_SUCCESS; +- int fd, included; ++ int fd = -1; ++ int included = 0; + unsigned i; + const struct optstruct *opt; + const char *virname = NULL; + STATBUF sb; +- struct metachain chain; +- struct clamscan_cb_data data; ++ struct metachain chain = {0}; ++ struct clamscan_cb_data data = {0}; ++ action_source_t action_source; ++ bool have_action_source = false; ++ bool have_stat = false; ++ const char *scan_path = filename; ++ char *real_filter_path = NULL; + +- char *real_filename = NULL; ++ action_source_init(&action_source); + + if (NULL == filename || NULL == engine || NULL == opts || NULL == options) { + logg(LOGG_INFO, "scanfile: Invalid args.\n"); +@@ -310,17 +316,13 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + goto done; + } + +- ret = cli_realpath((const char *)filename, &real_filename); +- if (CL_SUCCESS != ret) { +- logg(LOGG_DEBUG, "Failed to determine real filename of %s.\n", filename); +- logg(LOGG_DEBUG, "Quarantine of the file may fail if file path contains symlinks.\n"); +- } else { +- filename = real_filename; ++ if (CL_SUCCESS == cli_realpath(filename, &real_filter_path)) { ++ scan_path = real_filter_path; + } + + if ((opt = optget(opts, "exclude"))->enabled) { + while (opt) { +- if (match_regex(filename, opt->strarg) == 1) { ++ if (match_regex(scan_path, opt->strarg) == 1) { + if (!printinfected) + logg(LOGG_INFO, "%s: Excluded\n", filename); + +@@ -335,7 +337,7 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + included = 0; + + while (opt) { +- if (match_regex(filename, opt->strarg) == 1) { ++ if (match_regex(scan_path, opt->strarg) == 1) { + included = 1; + break; + } +@@ -351,8 +353,44 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + } + } + +- /* argh, don't scan /proc files */ +- if (CLAMSTAT(filename, &sb) != -1) { ++ if (!action && !have_stat && (CLAMSTAT(scan_path, &sb) != -1)) { ++ have_stat = true; ++ } ++ ++#ifdef C_LINUX ++ if (action && procdev && (CLAMSTAT(scan_path, &sb) != -1) && (sb.st_dev == procdev)) { ++ if (!printinfected) ++ logg(LOGG_INFO, "%s: Excluded (/proc)\n", filename); ++ ++ goto done; ++ } ++#endif ++ ++ if (action) { ++ /* ++ * Quarantine actions run on the same opened file object that is ++ * submitted to the scanner. Open it before file state checks so those ++ * checks describe the same object, including symlink targets on Windows ++ * where stat() can report the link entry as an empty file. ++ */ ++ ret = action_source_open_path(filename, scan_path, &action_source); ++ if (CL_SUCCESS != ret) { ++ logg(LOGG_WARNING, "Can't open file %s for safe quarantine action: %s\n", filename, cl_strerror(ret)); ++ info.errors++; ++ goto done; ++ } ++ fd = action_source.scan_fd; ++ have_action_source = true; ++ ++ if (action_source.has_stat) { ++ sb.st_dev = action_source.statbuf.st_dev; ++ sb.st_size = action_source.statbuf.st_size; ++ have_stat = true; ++ } ++ } ++ ++ if (have_stat) { ++ /* argh, don't scan /proc files */ + #ifdef C_LINUX + if (procdev && sb.st_dev == procdev) { + if (!printinfected) +@@ -361,6 +399,7 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + goto done; + } + #endif ++ + if (!sb.st_size) { + if (!printinfected) + logg(LOGG_INFO, "%s: Empty file\n", filename); +@@ -372,8 +411,8 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + } + + #ifndef _WIN32 +- if (geteuid()) { +- if (checkaccess(filename, NULL, R_OK) != 1) { ++ if (geteuid() && !have_action_source) { ++ if (checkaccess(scan_path, NULL, R_OK) != 1) { + if (!printinfected) + logg(LOGG_INFO, "%s: Access denied\n", filename); + +@@ -390,6 +429,7 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + chain.chains[0] = strdup(filename); + if (!chain.chains[0]) { + free(chain.chains); ++ chain.chains = NULL; + logg(LOGG_INFO, "Unable to allocate memory in scanfile()\n"); + info.errors++; + goto done; +@@ -400,7 +440,7 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + + logg(LOGG_DEBUG, "Scanning %s\n", filename); + +- if ((fd = safe_open(filename, O_RDONLY | O_BINARY)) == -1) { ++ if (!action && (fd = safe_open(scan_path, O_RDONLY | O_BINARY)) == -1) { + logg(LOGG_WARNING, "Can't open file %s: %s\n", filename, strerror(errno)); + info.errors++; + goto done; +@@ -436,19 +476,32 @@ static void scanfile(const char *filename, struct cl_engine *engine, const struc + info.errors++; + } + +- for (i = 0; i < chain.nchains; i++) +- free(chain.chains[i]); +- +- free(chain.chains); +- close(fd); ++done: ++ /* ++ * Run the action callback if the file was infected. ++ */ ++ if (ret == CL_VIRUS && action && have_action_source) { ++ action(&action_source); ++ } + +- if (ret == CL_VIRUS && action) +- action(filename); ++ if (have_action_source) { ++ action_source_close(&action_source); ++ fd = -1; ++ } else if (fd != -1) { ++ close(fd); ++ fd = -1; ++ } + +-done: +- if (NULL != real_filename) { +- free(real_filename); ++ if (NULL != real_filter_path) { ++ free(real_filter_path); + } ++ if (NULL != chain.chains) { ++ for (i = 0; i < chain.nchains; i++) { ++ free(chain.chains[i]); ++ } ++ free(chain.chains); ++ } ++ + return; + } + +@@ -458,6 +511,12 @@ static void scandirs(const char *dirname, struct cl_engine *engine, const struct + struct dirent *dent; + STATBUF sb; + char *fname; ++#ifdef _WIN32 ++ char **entries = NULL; ++ size_t entries_count = 0; ++ size_t entries_capacity = 0; ++ size_t entry_index; ++#endif + int included; + const struct optstruct *opt; + unsigned int dirlnk, filelnk; +@@ -503,6 +562,94 @@ static void scandirs(const char *dirname, struct cl_engine *engine, const struct + if ((dd = opendir(dirname)) != NULL) { + info.dirs++; + depth++; ++#ifdef _WIN32 ++ while ((dent = readdir(dd))) { ++ char **new_entries; ++ char *entry_name; ++ ++ if (!dent->d_ino) { ++ continue; ++ } ++ if (!strcmp(dent->d_name, ".") || !strcmp(dent->d_name, "..")) { ++ continue; ++ } ++ ++ entry_name = strdup(dent->d_name); ++ if (NULL == entry_name) { ++ logg(LOGG_ERROR, "scandirs: Memory allocation failed for entry name\n"); ++ break; ++ } ++ ++ if (entries_count == entries_capacity) { ++ size_t new_capacity = (0 == entries_capacity) ? 32 : (entries_capacity * 2); ++ new_entries = realloc(entries, new_capacity * sizeof(*entries)); ++ if (NULL == new_entries) { ++ logg(LOGG_ERROR, "scandirs: Memory allocation failed for entries list\n"); ++ free(entry_name); ++ break; ++ } ++ entries = new_entries; ++ entries_capacity = new_capacity; ++ } ++ ++ entries[entries_count++] = entry_name; ++ } ++ closedir(dd); ++ dd = NULL; ++ ++ for (entry_index = 0; entry_index < entries_count; entry_index++) { ++ dent = NULL; ++ fname = malloc(strlen(dirname) + strlen(entries[entry_index]) + 2); ++ if (fname == NULL) { ++ logg(LOGG_ERROR, "scandirs: Memory allocation failed for fname\n"); ++ continue; ++ } ++ ++ if (!strcmp(dirname, PATHSEP)) ++ sprintf(fname, PATHSEP "%s", entries[entry_index]); ++ else ++ sprintf(fname, "%s" PATHSEP "%s", dirname, entries[entry_index]); ++ ++ if (LSTAT(fname, &sb) != -1) { ++ if (!optget(opts, "cross-fs")->enabled) { ++ if (sb.st_dev != dev) { ++ if (!printinfected) ++ logg(LOGG_INFO, "%s: Excluded\n", fname); ++ ++ free(fname); ++ continue; ++ } ++ } ++ if (S_ISLNK(sb.st_mode)) { ++ if (dirlnk != 2 && filelnk != 2) { ++ if (!printinfected) ++ logg(LOGG_INFO, "%s: Symbolic link\n", fname); ++ } else if (CLAMSTAT(fname, &sb) != -1) { ++ if (S_ISREG(sb.st_mode) && filelnk == 2) { ++ scanfile(fname, engine, opts, options); ++ } else if (S_ISDIR(sb.st_mode) && dirlnk == 2) { ++ if (recursion) ++ scandirs(fname, engine, opts, options, depth, dev); ++ } else { ++ if (!printinfected) ++ logg(LOGG_INFO, "%s: Symbolic link\n", fname); ++ } ++ } ++ } else if (S_ISREG(sb.st_mode)) { ++ scanfile(fname, engine, opts, options); ++ } else if (S_ISDIR(sb.st_mode) && recursion) { ++ scandirs(fname, engine, opts, options, depth, dev); ++ } ++ } ++ ++ free(fname); ++ } ++ ++ for (entry_index = 0; entry_index < entries_count; entry_index++) { ++ free(entries[entry_index]); ++ } ++ free(entries); ++#else + while ((dent = readdir(dd))) { + if (dent->d_ino) { + if (strcmp(dent->d_name, ".") && strcmp(dent->d_name, "..")) { +@@ -556,6 +703,7 @@ static void scandirs(const char *dirname, struct cl_engine *engine, const struct + } + } + closedir(dd); ++#endif + } else { + if (!printinfected) + logg(LOGG_INFO, "%s: Can't open directory.\n", dirname); +diff --git a/common/actions.c b/common/actions.c +index 6615f648f..9f1f9fac9 100644 +--- a/common/actions.c ++++ b/common/actions.c +@@ -1,5 +1,5 @@ + /* +- * Copyright (C) 2013-2024 Cisco Systems, Inc. and/or its affiliates. All rights reserved. ++ * Copyright (C) 2013-2026 Cisco Systems, Inc. and/or its affiliates. All rights reserved. + * Copyright (C) 2009-2013 Sourcefire, Inc. + * + * Author: aCaB, Micah Snyder +@@ -27,20 +27,46 @@ + + #ifdef _WIN32 + #include ++#include + #include ++#include ++ ++#ifndef STATUS_OBJECT_NAME_EXISTS ++#define STATUS_OBJECT_NAME_EXISTS ((NTSTATUS)0x40000000L) ++#endif ++#ifndef STATUS_ACCESS_DENIED ++#define STATUS_ACCESS_DENIED ((NTSTATUS)0xC0000022L) ++#endif ++#ifndef STATUS_OBJECT_NAME_COLLISION ++#define STATUS_OBJECT_NAME_COLLISION ((NTSTATUS)0xC0000035L) ++#endif + #endif + + #if HAVE_CONFIG_H + #include "clamav-config.h" + #endif + ++#ifdef C_DARWIN ++#include ++#endif ++ + #include ++#include + #include ++#include ++#include + #include + #include + #if HAVE_UNISTD_H + #include + #endif ++#if defined(C_LINUX) && !defined(_WIN32) ++#include ++#include ++#ifndef RENAME_NOREPLACE ++#define RENAME_NOREPLACE (1U << 0) ++#endif ++#endif + #include + #include + #include +@@ -55,15 +81,2640 @@ + #include "misc.h" + #include "actions.h" + +-void (*action)(const char *) = NULL; ++void (*action)(const action_source_t *) = NULL; + unsigned int notmoved = 0, notremoved = 0; + +-static char *actarget; +-static int targlen; ++static char *actarget; ++static int targlen; ++#ifndef _WIN32 ++static int actarget_fd = -1; ++static char *actarget_lockname = NULL; ++static int action_unlinkat_nointr(int dirfd, const char *path, int flags); ++static int traverse_to(const char *directory, bool want_directory_handle, int *out_handle); ++#else ++static HANDLE actarget_handle = NULL; ++static char *actarget_normalized = NULL; ++static int traverse_to(const char *directory, bool want_directory_handle, HANDLE *out_handle); ++static size_t win32_path_root_length(const char *path, size_t path_len); ++static int win32_open_existing_path(const char *path, bool is_directory, ACCESS_MASK desired_access, HANDLE *out_handle); ++static int win32_delete_handle(HANDLE file_handle); ++static int win32_delete_dest_name(const char *dest_name); ++#endif ++static bool action_cleanup_registered = false; ++static void action_move(const action_source_t *source); ++static void action_copy(const action_source_t *source); ++static void action_remove(const action_source_t *source); ++ ++static int action_fstat_nointr(int fd, STATBUF *st) ++{ ++ int rc; ++ ++ do { ++ rc = FSTAT(fd, st); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static void action_cleanup(void) ++{ ++#ifndef _WIN32 ++ if ((-1 != actarget_fd) && (NULL != actarget_lockname)) { ++ if ((0 != action_unlinkat_nointr(actarget_fd, actarget_lockname, 0)) && (ENOENT != errno)) { ++ logg(LOGG_DEBUG, "action_cleanup: Failed to unlink quarantine lock '%s': %s\n", ++ actarget_lockname, ++ strerror(errno)); ++ } ++ } ++ ++ if (NULL != actarget_lockname) { ++ free(actarget_lockname); ++ actarget_lockname = NULL; ++ } ++ ++ if (-1 != actarget_fd) { ++ close(actarget_fd); ++ actarget_fd = -1; ++ } ++#else ++ if ((NULL != actarget_handle) && (INVALID_HANDLE_VALUE != actarget_handle)) { ++ CloseHandle(actarget_handle); ++ actarget_handle = NULL; ++ } ++ if (NULL != actarget_normalized) { ++ free(actarget_normalized); ++ actarget_normalized = NULL; ++ actarget = NULL; ++ } ++#endif ++} ++ ++static int action_close_dest_fd(int *fd) ++{ ++ int close_fd; ++ ++ if ((NULL == fd) || (*fd < 0)) { ++ errno = EBADF; ++ return -1; ++ } ++ ++ close_fd = *fd; ++ *fd = -1; ++ ++ return close(close_fd); ++} ++ ++#ifndef _WIN32 ++static int action_open_nointr(const char *path, int flags) ++{ ++ int fd; ++ ++ do { ++ fd = open(path, flags); ++ } while ((fd < 0) && (EINTR == errno)); ++ ++ return fd; ++} ++ ++static int action_openat_nointr(int dirfd, const char *path, int flags, mode_t mode) ++{ ++ int fd; ++ ++ do { ++ fd = openat(dirfd, path, flags, mode); ++ } while ((fd < 0) && (EINTR == errno)); ++ ++ return fd; ++} ++ ++static int action_open_flags_with_largefile(int flags) ++{ ++#ifdef O_LARGEFILE ++ flags |= O_LARGEFILE; ++#endif ++ return flags; ++} ++ ++static int action_directory_open_flags(void) ++{ ++ int flags = O_NOFOLLOW; ++ ++#ifdef O_SEARCH ++ flags |= O_SEARCH; ++#elif defined(O_PATH) ++ flags |= O_PATH; ++#else ++ flags |= O_RDONLY; ++#endif ++#ifdef O_DIRECTORY ++ flags |= O_DIRECTORY; ++#endif ++#ifdef O_NONBLOCK ++ flags |= O_NONBLOCK; ++#endif ++ ++ return flags; ++} ++ ++static int action_source_open_flags(void) ++{ ++ int flags = O_RDONLY | O_NOFOLLOW | O_BINARY; ++ ++#ifdef O_NONBLOCK ++ flags |= O_NONBLOCK; ++#endif ++ ++ return action_open_flags_with_largefile(flags); ++} ++ ++static int action_verify_directory_fd(int fd) ++{ ++ STATBUF statbuf; ++ ++ if (0 != FSTAT(fd, &statbuf)) { ++ return -1; ++ } ++ ++ if (!S_ISDIR(statbuf.st_mode)) { ++ errno = ENOTDIR; ++ return -1; ++ } ++ ++ return 0; ++} ++ ++static int action_open_directory_nointr(const char *path) ++{ ++ int fd = action_open_nointr(path, action_directory_open_flags()); ++ ++ if (fd >= 0 && 0 != action_verify_directory_fd(fd)) { ++ close(fd); ++ fd = -1; ++ } ++ ++ return fd; ++} ++ ++static int action_openat_directory_nointr(int dirfd, const char *path) ++{ ++ int fd = action_openat_nointr(dirfd, path, action_directory_open_flags(), 0); ++ ++ if (fd >= 0 && 0 != action_verify_directory_fd(fd)) { ++ close(fd); ++ fd = -1; ++ } ++ ++ return fd; ++} ++ ++static int action_openat_source_nointr(int dirfd, const char *path) ++{ ++ return action_openat_nointr(dirfd, path, action_source_open_flags(), 0); ++} ++ ++static int action_mkdirat_nointr(int dirfd, const char *path, mode_t mode) ++{ ++ int rc; ++ ++ do { ++ rc = mkdirat(dirfd, path, mode); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static int action_renameat_nointr(int olddirfd, const char *oldpath, int newdirfd, const char *newpath) ++{ ++ int rc; ++ ++ do { ++ rc = renameat(olddirfd, oldpath, newdirfd, newpath); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static int action_renameat_noreplace_nointr(int olddirfd, const char *oldpath, int newdirfd, const char *newpath) ++{ ++ int rc; ++ ++#if defined(C_DARWIN) ++ do { ++ rc = renameatx_np(olddirfd, oldpath, newdirfd, newpath, RENAME_EXCL); ++ } while ((rc < 0) && (EINTR == errno)); ++#elif defined(C_LINUX) && defined(SYS_renameat2) ++ do { ++ rc = (int)syscall( ++ SYS_renameat2, ++ olddirfd, ++ oldpath, ++ newdirfd, ++ newpath, ++ RENAME_NOREPLACE); ++ } while ((rc < 0) && (EINTR == errno)); ++#else ++ UNUSEDPARAM(olddirfd); ++ UNUSEDPARAM(oldpath); ++ UNUSEDPARAM(newdirfd); ++ UNUSEDPARAM(newpath); ++ errno = ENOTSUP; ++ rc = -1; ++#endif ++ ++ return rc; ++} ++ ++/** ++ * @brief Test whether no-replace rename works inside a private directory. ++ * ++ * Directory restore after a mismatched private capture depends on an atomic ++ * rename operation that refuses to overwrite the original basename. The ++ * expected-stat unlink path probes this before capture because a writable ++ * directory race can replace the checked regular file with a directory between ++ * restat and private capture; without no-replace restore, a refused action ++ * could still hide that directory in the private unlink directory. ++ * ++ * @param private_directory_fd Empty private directory fd used for this unlink. ++ * @return true No-replace rename failed with EEXIST as expected. ++ * @return false No-replace rename is unavailable or misbehaved. ++ */ ++static bool action_private_dir_supports_noreplace_rename(int private_directory_fd) ++{ ++ const char src_name[] = ".rename-noreplace-src"; ++ const char dst_name[] = ".rename-noreplace-dst"; ++ bool supported = false; ++ ++ if (private_directory_fd < 0) { ++ errno = EINVAL; ++ return false; ++ } ++ ++ if (0 != action_mkdirat_nointr(private_directory_fd, src_name, 0700)) { ++ return false; ++ } ++ ++ if (0 != action_mkdirat_nointr(private_directory_fd, dst_name, 0700)) { ++ (void)action_unlinkat_nointr(private_directory_fd, src_name, AT_REMOVEDIR); ++ return false; ++ } ++ ++ if (0 != action_renameat_noreplace_nointr( ++ private_directory_fd, ++ src_name, ++ private_directory_fd, ++ dst_name)) { ++ supported = (EEXIST == errno); ++ } ++ ++ (void)action_unlinkat_nointr(private_directory_fd, src_name, AT_REMOVEDIR); ++ (void)action_unlinkat_nointr(private_directory_fd, dst_name, AT_REMOVEDIR); ++ ++ if (!supported) { ++ errno = ENOTSUP; ++ } ++ ++ return supported; ++} ++ ++static int traverse_to(const char *directory, bool want_directory_handle, int *out_handle); ++ ++#ifndef C_DARWIN ++static ssize_t action_read_nointr(int fd, void *buf, size_t count) ++{ ++ ssize_t rc; ++ ++ do { ++ rc = read(fd, buf, count); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static ssize_t action_write_nointr(int fd, const void *buf, size_t count) ++{ ++ ssize_t rc; ++ ++ do { ++ rc = write(fd, buf, count); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++#endif ++ ++static off_t action_lseek_nointr(int fd, off_t offset, int whence) ++{ ++ off_t rc; ++ ++ do { ++ rc = lseek(fd, offset, whence); ++ } while (((off_t)-1 == rc) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++#ifndef C_DARWIN ++static int action_ftruncate_nointr(int fd, off_t length) ++{ ++ int rc; ++ ++ do { ++ rc = ftruncate(fd, length); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++#endif ++ ++static int action_unlinkat_nointr(int dirfd, const char *path, int flags) ++{ ++ int rc; ++ ++ do { ++ rc = unlinkat(dirfd, path, flags); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++#ifndef _WIN32 ++static int action_fstatat_nointr(int dirfd, const char *path, STATBUF *st, int flags) ++{ ++ int rc; ++ ++ do { ++#if defined(HAVE_STAT64) && STAT64_OK ++ rc = fstatat64(dirfd, path, st, flags); ++#else ++ rc = fstatat(dirfd, path, st, flags); ++#endif ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static int action_validate_actarget_path(void) ++{ ++ STATBUF fd_stat; ++ STATBUF path_stat; ++ ++ /* ++ * Destination entries are created through actarget_fd, but users see ++ * actarget/name. Refuse success if actarget no longer names that fd. ++ */ ++ if ((-1 == actarget_fd) || (NULL == actarget)) { ++ errno = EINVAL; ++ return -1; ++ } ++ ++ if (0 != action_fstat_nointr(actarget_fd, &fd_stat)) { ++ return -1; ++ } ++ ++ if (0 != CLAMSTAT(actarget, &path_stat)) { ++ return -1; ++ } ++ ++ if (!S_ISDIR(path_stat.st_mode) || ++ (path_stat.st_dev != fd_stat.st_dev) || ++ (path_stat.st_ino != fd_stat.st_ino)) { ++ errno = EAGAIN; ++ return -1; ++ } ++ ++ return 0; ++} ++#endif ++ ++static int action_fchmod_nointr(int fd, mode_t mode) ++{ ++ int rc; ++ ++ do { ++ rc = fchmod(fd, mode); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++#ifdef C_LINUX ++static int action_futimens_nointr(int fd, const struct timespec times[2]) ++{ ++ int rc; ++ ++ do { ++ rc = futimens(fd, times); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++#endif ++ ++static char *action_gen_quarantine_lockname(unsigned int attempt) ++{ ++ int needed; ++ char *lockname = NULL; ++ ++ needed = snprintf(NULL, 0, ".clamav-quarantine-lock.%lu.%u", ++ (unsigned long)getpid(), ++ attempt); ++ if (needed < 0) { ++ return NULL; ++ } ++ ++ lockname = malloc((size_t)needed + 1); ++ if (NULL == lockname) { ++ return NULL; ++ } ++ ++ if (snprintf(lockname, (size_t)needed + 1, ".clamav-quarantine-lock.%lu.%u", ++ (unsigned long)getpid(), ++ attempt) != needed) { ++ free(lockname); ++ return NULL; ++ } ++ ++ return lockname; ++} ++ ++int action_setup_quarantine_lock_at(int directory_fd, const char *directory_path, char **lockname_out) ++{ ++ char *lockname = NULL; ++ int fd = -1; ++ unsigned int i; ++ ++ if (directory_fd < 0 || NULL == lockname_out) { ++ return -1; ++ } ++ ++ *lockname_out = NULL; ++ ++ /* ++ * Create the lock relative to the validated directory handle so the lock ++ * stays bound to the directory we already traversed without following ++ * symlinks. ++ */ ++ for (i = 0; i < 100; i++) { ++ lockname = action_gen_quarantine_lockname(i); ++ if (NULL == lockname) { ++ return -1; ++ } ++ ++ fd = action_openat_nointr(directory_fd, lockname, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_BINARY, 0600); ++ if (fd >= 0) { ++ if (0 != close(fd)) { ++ (void)action_unlinkat_nointr(directory_fd, lockname, 0); ++ free(lockname); ++ return -1; ++ } ++ ++ *lockname_out = lockname; ++ return 0; ++ } ++ ++ if (EEXIST != errno) { ++ logg(LOGG_INFO, "action_setup: Failed to create quarantine lock file in %s: %s\n", ++ (NULL != directory_path) ? directory_path : "(unknown)", ++ strerror(errno)); ++ free(lockname); ++ return -1; ++ } ++ ++ free(lockname); ++ lockname = NULL; ++ } ++ ++ logg(LOGG_INFO, "action_setup: Failed to create quarantine lock file in %s after repeated name collisions.\n", ++ (NULL != directory_path) ? directory_path : "(unknown)"); ++ return -1; ++} ++ ++static int action_setup_quarantine_lock(void) ++{ ++ return action_setup_quarantine_lock_at(actarget_fd, actarget, &actarget_lockname); ++} ++#endif ++ ++#ifndef _WIN32 ++#ifdef C_LINUX ++static ssize_t action_flistxattr_nointr(int fd, char *list, size_t size) ++{ ++ ssize_t rc; ++ ++ do { ++ rc = flistxattr(fd, list, size); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static ssize_t action_fgetxattr_nointr(int fd, const char *name, void *value, size_t size) ++{ ++ ssize_t rc; ++ ++ do { ++ rc = fgetxattr(fd, name, value, size); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static int action_fsetxattr_nointr(int fd, const char *name, const void *value, size_t size, int flags) ++{ ++ int rc; ++ ++ do { ++ rc = fsetxattr(fd, name, value, size, flags); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ return rc; ++} ++ ++static int linux_copy_xattrs(int src_fd, int dest_fd) ++{ ++ ssize_t list_size; ++ char *names = NULL; ++ char *name = NULL; ++ int status = -1; ++ ++ list_size = action_flistxattr_nointr(src_fd, NULL, 0); ++ if (list_size < 0) { ++ if ((ENOTSUP == errno) || (EOPNOTSUPP == errno)) { ++ return 0; ++ } ++ return -1; ++ } ++ ++ if (0 == list_size) { ++ return 0; ++ } ++ ++ names = malloc((size_t)list_size); ++ if (NULL == names) { ++ return -1; ++ } ++ ++ list_size = action_flistxattr_nointr(src_fd, names, (size_t)list_size); ++ if (list_size < 0) { ++ if ((ENOTSUP == errno) || (EOPNOTSUPP == errno)) { ++ status = 0; ++ goto done; ++ } ++ goto done; ++ } ++ ++ for (name = names; name < names + list_size; name += strlen(name) + 1) { ++ ssize_t value_size; ++ void *value = NULL; ++ ++ value_size = action_fgetxattr_nointr(src_fd, name, NULL, 0); ++ if (value_size < 0) { ++ if (ENODATA == errno) { ++ continue; ++ } ++ goto done; ++ } ++ ++ if (value_size > 0) { ++ value = malloc((size_t)value_size); ++ if (NULL == value) { ++ goto done; ++ } ++ ++ value_size = action_fgetxattr_nointr(src_fd, name, value, (size_t)value_size); ++ if (value_size < 0) { ++ free(value); ++ if (ENODATA == errno) { ++ continue; ++ } ++ goto done; ++ } ++ } ++ ++ if (0 != action_fsetxattr_nointr(dest_fd, name, value, (size_t)((value_size < 0) ? 0 : value_size), 0)) { ++ int xattr_errno = errno; ++ ++ free(value); ++ ++ if ((EPERM == xattr_errno) || (EACCES == xattr_errno) || ++ (ENOTSUP == xattr_errno) || (EOPNOTSUPP == xattr_errno)) { ++ continue; ++ } ++ ++ errno = xattr_errno; ++ goto done; ++ } ++ ++ free(value); ++ } ++ ++ status = 0; ++ ++done: ++ if (NULL != names) { ++ free(names); ++ } ++ ++ return status; ++} ++ ++static int linux_copy_file_metadata(int src_fd, int dest_fd) ++{ ++ STATBUF src_stat; ++ struct timespec times[2]; ++ ++ if (0 != action_fstat_nointr(src_fd, &src_stat)) { ++ return -1; ++ } ++ ++ times[0] = src_stat.st_atim; ++ times[1] = src_stat.st_mtim; ++ if (0 != action_futimens_nointr(dest_fd, times)) { ++ return -1; ++ } ++ ++ if (0 != linux_copy_xattrs(src_fd, dest_fd)) { ++ int xattr_errno = errno; ++ logg(LOGG_DEBUG, "action: Failed to preserve extended attributes: %s\n", ++ strerror(xattr_errno)); ++ } ++ ++ return 0; ++} ++#endif ++ ++static int filecopy_to_fd(const action_source_t *source, int dest_fd, STATBUF *src_stat_out) ++{ ++ int src_fd; ++#ifdef C_DARWIN ++#else ++ char buf[8192]; ++ ssize_t got; ++#endif ++ ++ if ((NULL == source) || (dest_fd < 0) || (source->scan_fd < 0) || (false == source->has_stat)) { ++ return -1; ++ } ++ ++ src_fd = source->scan_fd; ++ ++ if (!S_ISREG(source->statbuf.st_mode)) { ++ errno = EINVAL; ++ return -1; ++ } ++ ++ if (action_lseek_nointr(src_fd, 0, SEEK_SET) == (off_t)-1 || ++ action_lseek_nointr(dest_fd, 0, SEEK_SET) == (off_t)-1) { ++ return -1; ++ } ++ ++#ifdef C_DARWIN ++ if (0 != fcopyfile(src_fd, dest_fd, NULL, COPYFILE_DATA | COPYFILE_XATTR)) { ++ return -1; ++ } ++#else ++ while ((got = action_read_nointr(src_fd, buf, sizeof(buf))) > 0) { ++ size_t off = 0; ++ ++ while (off < (size_t)got) { ++ ssize_t wrote = action_write_nointr(dest_fd, buf + off, (size_t)got - off); ++ if (wrote <= 0) { ++ return -1; ++ } ++ off += (size_t)wrote; ++ } ++ } ++ ++ if (got < 0 || ++ action_ftruncate_nointr(dest_fd, action_lseek_nointr(dest_fd, 0, SEEK_CUR)) != 0 || ++ action_lseek_nointr(dest_fd, 0, SEEK_SET) == (off_t)-1) { ++ return -1; ++ } ++#endif ++ ++#ifdef C_LINUX ++ if (0 != linux_copy_file_metadata(src_fd, dest_fd)) { ++ return -1; ++ } ++#endif ++ ++ if (0 != action_fchmod_nointr(dest_fd, S_IRUSR | S_IWUSR)) { ++ return -1; ++ } ++ ++ if (NULL != src_stat_out) { ++ *src_stat_out = source->statbuf; ++ } ++ ++ return 0; ++} ++#else ++typedef NTSTATUS(NTAPI *PNTCF)( ++ PHANDLE FileHandle, // OUT ++ ACCESS_MASK DesiredAccess, ++ POBJECT_ATTRIBUTES ObjectAttributes, ++ PIO_STATUS_BLOCK IoStatusBlock, // OUT ++ PLARGE_INTEGER AllocationSize, ++ ULONG FileAttributes, ++ ULONG ShareAccess, ++ ULONG CreateDisposition, ++ ULONG CreateOptions, ++ PVOID EaBuffer, ++ ULONG EaLength); ++ ++typedef VOID(NTAPI *PRIUS)( ++ PUNICODE_STRING DestinationString, ++ PCWSTR SourceString); ++ ++static ACCESS_MASK win32_directory_anchor_access(void) ++{ ++ /* ++ * Directory handles are used as validated anchors for later relative ++ * opens/creates. They need traversal and attributes for validation, but ++ * not FILE_LIST_DIRECTORY, so drop-box ACLs that deny listing can work. ++ */ ++ return SYNCHRONIZE | FILE_TRAVERSE | FILE_READ_ATTRIBUTES; ++} ++ ++static HANDLE win32_openat( ++ HANDLE current_handle, ++ const char *filename, ++ PNTCF pNtCreateFile, ++ PRIUS pRtlInitUnicodeString, ++ ACCESS_MASK desiredAccess, ++ ULONG fileAttributes, ++ ULONG createDisposition, ++ ULONG createOptions, ++ ULONG shareAccess); ++ ++static WCHAR *win32_utf8_to_utf16(const char *value) ++{ ++ int value_wchars; ++ WCHAR *value_w = NULL; ++ ++ if (NULL == value) { ++ return NULL; ++ } ++ ++ value_wchars = MultiByteToWideChar(CP_UTF8, 0, value, -1, NULL, 0); ++ if (0 == value_wchars) { ++ return NULL; ++ } ++ ++ value_w = malloc((size_t)value_wchars * sizeof(WCHAR)); ++ if (NULL == value_w) { ++ return NULL; ++ } ++ ++ if (0 == MultiByteToWideChar(CP_UTF8, 0, value, -1, value_w, value_wchars)) { ++ free(value_w); ++ return NULL; ++ } ++ ++ return value_w; ++} ++ ++static bool win32_is_extended_path_w(const WCHAR *path) ++{ ++ if (NULL == path) { ++ return false; ++ } ++ ++ return ((0 == wcsncmp(path, L"\\\\?\\", 4)) || ++ (0 == wcsncmp(path, L"\\\\.\\", 4))); ++} ++ ++static bool win32_is_drive_absolute_path_w(const WCHAR *path, size_t path_len) ++{ ++ return (path_len >= 3) && ++ (((L'A' <= path[0]) && (L'Z' >= path[0])) || ((L'a' <= path[0]) && (L'z' >= path[0]))) && ++ (L':' == path[1]) && ++ ((L'\\' == path[2]) || (L'/' == path[2])); ++} ++ ++static WCHAR *win32_dup_extended_path_w(const WCHAR *path_w) ++{ ++ size_t path_len; ++ size_t prefix_len; ++ size_t result_len; ++ const WCHAR *prefix = L"\\\\?\\"; ++ WCHAR *result = NULL; ++ ++ if (NULL == path_w) { ++ return NULL; ++ } ++ ++ if (true == win32_is_extended_path_w(path_w)) { ++ return _wcsdup(path_w); ++ } ++ ++ path_len = wcslen(path_w); ++ if (path_len < 2) { ++ return _wcsdup(path_w); ++ } ++ ++ if ((L'\\' == path_w[0]) && (L'\\' == path_w[1])) { ++ prefix = L"\\\\?\\UNC\\"; ++ prefix_len = wcslen(prefix); ++ result_len = prefix_len + path_len - 2; ++ result = malloc((result_len + 1) * sizeof(WCHAR)); ++ if (NULL == result) { ++ return NULL; ++ } ++ ++ memcpy(result, prefix, prefix_len * sizeof(WCHAR)); ++ memcpy(result + prefix_len, path_w + 2, (path_len - 1) * sizeof(WCHAR)); ++ return result; ++ } ++ ++ if (!win32_is_drive_absolute_path_w(path_w, path_len)) { ++ return _wcsdup(path_w); ++ } ++ ++ prefix_len = wcslen(prefix); ++ result_len = prefix_len + path_len; ++ result = malloc((result_len + 1) * sizeof(WCHAR)); ++ if (NULL == result) { ++ return NULL; ++ } ++ ++ memcpy(result, prefix, prefix_len * sizeof(WCHAR)); ++ memcpy(result + prefix_len, path_w, (path_len + 1) * sizeof(WCHAR)); ++ return result; ++} ++ ++static WCHAR *win32_dup_nonextended_path_w(const WCHAR *path_w) ++{ ++ const WCHAR *body = path_w; ++ ++ if (NULL == path_w) { ++ return NULL; ++ } ++ ++ if (0 == wcsncmp(path_w, L"\\\\?\\UNC\\", 8)) { ++ size_t body_len = wcslen(path_w + 8); ++ WCHAR *result = malloc((body_len + 3) * sizeof(WCHAR)); ++ if (NULL == result) { ++ return NULL; ++ } ++ ++ result[0] = L'\\'; ++ result[1] = L'\\'; ++ memcpy(result + 2, path_w + 8, (body_len + 1) * sizeof(WCHAR)); ++ return result; ++ } ++ ++ if ((0 == wcsncmp(path_w, L"\\\\?\\", 4)) || (0 == wcsncmp(path_w, L"\\\\.\\", 4))) { ++ body = path_w + 4; ++ } ++ ++ return _wcsdup(body); ++} ++ ++static char *win32_utf16_to_utf8(const WCHAR *value) ++{ ++ int value_bytes; ++ char *value_utf8 = NULL; ++ ++ if (NULL == value) { ++ return NULL; ++ } ++ ++ value_bytes = WideCharToMultiByte(CP_UTF8, 0, value, -1, NULL, 0, NULL, NULL); ++ if (0 == value_bytes) { ++ return NULL; ++ } ++ ++ value_utf8 = malloc((size_t)value_bytes); ++ if (NULL == value_utf8) { ++ return NULL; ++ } ++ ++ if (0 == WideCharToMultiByte(CP_UTF8, 0, value, -1, value_utf8, value_bytes, NULL, NULL)) { ++ free(value_utf8); ++ return NULL; ++ } ++ ++ return value_utf8; ++} ++ ++static int win32_copy_handle_data(HANDLE src_handle, HANDLE dest_handle) ++{ ++ BYTE buf[8192]; ++ DWORD got = 0; ++ DWORD wrote = 0; ++ LARGE_INTEGER zero; ++ ++ zero.QuadPart = 0; ++ if (FALSE == SetFilePointerEx(src_handle, zero, NULL, FILE_BEGIN)) { ++ logg(LOGG_INFO, "win32_copy_handle_data: SetFilePointerEx failed for source handle. Error: %lu\n", GetLastError()); ++ return -1; ++ } ++ if (FALSE == SetFilePointerEx(dest_handle, zero, NULL, FILE_BEGIN)) { ++ logg(LOGG_INFO, "win32_copy_handle_data: SetFilePointerEx failed for destination handle. Error: %lu\n", GetLastError()); ++ return -1; ++ } ++ ++ while (TRUE) { ++ if (FALSE == ReadFile(src_handle, buf, (DWORD)sizeof(buf), &got, NULL)) { ++ logg(LOGG_INFO, "win32_copy_handle_data: ReadFile failed. Error: %lu\n", GetLastError()); ++ return -1; ++ } ++ ++ if (0 == got) { ++ break; ++ } ++ ++ wrote = 0; ++ if ((FALSE == WriteFile(dest_handle, buf, got, &wrote, NULL)) || (wrote != got)) { ++ logg(LOGG_INFO, "win32_copy_handle_data: WriteFile failed. Error: %lu, wrote: %lu, expected: %lu\n", GetLastError(), wrote, got); ++ return -1; ++ } ++ } ++ ++ return 0; ++} ++ ++static int win32_copy_basic_info(HANDLE src_handle, HANDLE dest_handle) ++{ ++ FILE_BASIC_INFO basic_info; ++ ++ if (FALSE == GetFileInformationByHandleEx( ++ src_handle, ++ FileBasicInfo, ++ &basic_info, ++ sizeof(FILE_BASIC_INFO))) { ++ return -1; ++ } ++ ++ if (FALSE == SetFileInformationByHandle( ++ dest_handle, ++ FileBasicInfo, ++ &basic_info, ++ sizeof(FILE_BASIC_INFO))) { ++ return -1; ++ } ++ ++ return 0; ++} ++ ++static int win32_open_existing_directory_at(HANDLE current_handle, const char *dirname, HANDLE *out_handle) ++{ ++ HMODULE ntdll = NULL; ++ PNTCF pNtCreateFile = NULL; ++ PRIUS pRtlInitUnicodeString = NULL; ++ HANDLE directory_handle = NULL; ++ int status = -1; ++ ++ if ((NULL == dirname) || (NULL == out_handle)) { ++ return -1; ++ } ++ ++ ntdll = LoadLibraryA("ntdll.dll"); ++ if (NULL == ntdll) { ++ return -1; ++ } ++ ++ pNtCreateFile = (PNTCF)GetProcAddress(ntdll, "NtCreateFile"); ++ pRtlInitUnicodeString = (PRIUS)GetProcAddress(ntdll, "RtlInitUnicodeString"); ++ if ((NULL == pNtCreateFile) || (NULL == pRtlInitUnicodeString)) { ++ goto done; ++ } ++ ++ directory_handle = win32_openat( ++ current_handle, ++ dirname, ++ pNtCreateFile, ++ pRtlInitUnicodeString, ++ win32_directory_anchor_access(), ++ FILE_ATTRIBUTE_DIRECTORY, ++ FILE_OPEN, ++ FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_OPEN_FOR_BACKUP_INTENT, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); ++ if ((NULL == directory_handle) || (INVALID_HANDLE_VALUE == directory_handle)) { ++ goto done; ++ } ++ ++ *out_handle = directory_handle; ++ directory_handle = NULL; ++ status = 0; ++ ++done: ++ if ((NULL != directory_handle) && (INVALID_HANDLE_VALUE != directory_handle)) { ++ CloseHandle(directory_handle); ++ } ++ if (NULL != ntdll) { ++ FreeLibrary(ntdll); ++ } ++ return status; ++} ++ ++static int win32_backup_read_exact(HANDLE src_handle, BYTE *buffer, DWORD bytes_to_read, LPVOID *backup_context) ++{ ++ DWORD offset = 0; ++ ++ while (offset < bytes_to_read) { ++ DWORD bytes_read = 0; ++ ++ if (FALSE == BackupRead(src_handle, buffer + offset, bytes_to_read - offset, &bytes_read, FALSE, FALSE, backup_context)) { ++ return -1; ++ } ++ if (0 == bytes_read) { ++ return -1; ++ } ++ ++ offset += bytes_read; ++ } ++ ++ return 0; ++} ++ ++static int win32_backup_skip_stream(HANDLE src_handle, LARGE_INTEGER stream_size, LPVOID *backup_context) ++{ ++ BYTE discard[8192]; ++ ++ while (stream_size.QuadPart > 0) { ++ DWORD want; ++ DWORD skipped_low = 0; ++ DWORD skipped_high = 0; ++ ++ if (stream_size.QuadPart > UINT32_MAX) { ++ want = UINT32_MAX; ++ } else { ++ want = (DWORD)stream_size.QuadPart; ++ } ++ ++ if (BackupSeek(src_handle, want, 0, &skipped_low, &skipped_high, backup_context)) { ++ ULARGE_INTEGER skipped; ++ ++ skipped.LowPart = skipped_low; ++ skipped.HighPart = skipped_high; ++ if (0 == skipped.QuadPart) { ++ return -1; ++ } ++ ++ stream_size.QuadPart -= (LONGLONG)skipped.QuadPart; ++ continue; ++ } ++ ++ want = (stream_size.QuadPart > (LONGLONG)sizeof(discard)) ? (DWORD)sizeof(discard) : (DWORD)stream_size.QuadPart; ++ if (0 != win32_backup_read_exact(src_handle, discard, want, backup_context)) { ++ return -1; ++ } ++ stream_size.QuadPart -= want; ++ } ++ ++ return 0; ++} ++ ++static int win32_copy_backup_stream_data(HANDLE src_handle, HANDLE dest_handle, LARGE_INTEGER stream_size, LPVOID *backup_context) ++{ ++ BYTE buf[8192]; ++ ++ while (stream_size.QuadPart > 0) { ++ DWORD want = (stream_size.QuadPart > (LONGLONG)sizeof(buf)) ? (DWORD)sizeof(buf) : (DWORD)stream_size.QuadPart; ++ DWORD got = 0; ++ DWORD wrote; ++ ++ if (FALSE == BackupRead(src_handle, buf, want, &got, FALSE, FALSE, backup_context)) { ++ return -1; ++ } ++ if (0 == got) { ++ return -1; ++ } ++ ++ wrote = 0; ++ if ((FALSE == WriteFile(dest_handle, buf, got, &wrote, NULL)) || (wrote != got)) { ++ return -1; ++ } ++ ++ stream_size.QuadPart -= got; ++ } ++ ++ return 0; ++} ++ ++/** ++ * @brief Create an alternate data stream relative to an opened destination. ++ * ++ * Opening the stream from the destination handle keeps ADS copies bound to the ++ * quarantine file that getdest() already created, instead of reopening a ++ * basename that another process could replace in the quarantine directory. ++ * ++ * @param dest_handle Open handle for the quarantine destination file. ++ * @param stream_name Name returned by BackupRead(), such as ":ads:$DATA". ++ * @param[out] out_handle Open handle for the destination stream. ++ * @return 0 Stream was created. ++ * @return -1 Stream creation failed. ++ */ ++static int win32_create_dest_stream_handle(HANDLE dest_handle, const char *stream_name, HANDLE *out_handle) ++{ ++ HMODULE ntdll = NULL; ++ PNTCF pNtCreateFile = NULL; ++ PRIUS pRtlInitUnicodeString = NULL; ++ HANDLE stream_handle = NULL; ++ int status = -1; ++ ++ if ((NULL == dest_handle) || (INVALID_HANDLE_VALUE == dest_handle) || ++ (NULL == stream_name) || (NULL == out_handle)) { ++ return -1; ++ } ++ ++ ntdll = LoadLibraryA("ntdll.dll"); ++ if (NULL == ntdll) { ++ return -1; ++ } ++ ++ pNtCreateFile = (PNTCF)GetProcAddress(ntdll, "NtCreateFile"); ++ pRtlInitUnicodeString = (PRIUS)GetProcAddress(ntdll, "RtlInitUnicodeString"); ++ if ((NULL == pNtCreateFile) || (NULL == pRtlInitUnicodeString)) { ++ goto done; ++ } ++ ++ stream_handle = win32_openat( ++ dest_handle, ++ stream_name, ++ pNtCreateFile, ++ pRtlInitUnicodeString, ++ FILE_WRITE_DATA | SYNCHRONIZE, ++ FILE_ATTRIBUTE_NORMAL, ++ FILE_CREATE, ++ FILE_NON_DIRECTORY_FILE | FILE_SYNCHRONOUS_IO_NONALERT, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); ++ if ((NULL == stream_handle) || (INVALID_HANDLE_VALUE == stream_handle)) { ++ goto done; ++ } ++ ++ *out_handle = stream_handle; ++ stream_handle = NULL; ++ status = 0; ++ ++done: ++ if ((NULL != stream_handle) && (INVALID_HANDLE_VALUE != stream_handle)) { ++ CloseHandle(stream_handle); ++ } ++ if (NULL != ntdll) { ++ FreeLibrary(ntdll); ++ } ++ return status; ++} ++ ++static int win32_copy_alternate_streams(HANDLE src_handle, HANDLE dest_handle) ++{ ++ LPVOID backup_context = NULL; ++ const DWORD stream_id_size = (DWORD)offsetof(WIN32_STREAM_ID, cStreamName); ++ WIN32_STREAM_ID stream_id; ++ LARGE_INTEGER zero; ++ int status = -1; ++ ++ if ((NULL == src_handle) || (INVALID_HANDLE_VALUE == src_handle) || ++ (NULL == dest_handle) || (INVALID_HANDLE_VALUE == dest_handle)) { ++ return -1; ++ } ++ ++ zero.QuadPart = 0; ++ if (FALSE == SetFilePointerEx(src_handle, zero, NULL, FILE_BEGIN)) { ++ return -1; ++ } ++ ++ while (TRUE) { ++ WCHAR *stream_name_w = NULL; ++ char *stream_name_utf8 = NULL; ++ HANDLE dest_stream_handle = INVALID_HANDLE_VALUE; ++ DWORD bytes_read = 0; ++ ++ if (FALSE == BackupRead(src_handle, (BYTE *)&stream_id, stream_id_size, &bytes_read, FALSE, FALSE, &backup_context)) { ++ goto done; ++ } ++ ++ if (0 == bytes_read) { ++ status = 0; ++ goto done; ++ } ++ ++ if (bytes_read != stream_id_size) { ++ goto done; ++ } ++ ++ if (0 != stream_id.dwStreamNameSize) { ++ size_t stream_name_chars; ++ ++ if (0 != (stream_id.dwStreamNameSize % sizeof(WCHAR))) { ++ goto done; ++ } ++ ++ stream_name_chars = stream_id.dwStreamNameSize / sizeof(WCHAR); ++ if (stream_name_chars > (SIZE_MAX / sizeof(WCHAR)) - 1) { ++ goto done; ++ } ++ ++ stream_name_w = malloc((stream_name_chars + 1) * sizeof(WCHAR)); ++ if (NULL == stream_name_w) { ++ goto done; ++ } ++ ++ if (0 != win32_backup_read_exact(src_handle, (BYTE *)stream_name_w, stream_id.dwStreamNameSize, &backup_context)) { ++ free(stream_name_w); ++ goto done; ++ } ++ stream_name_w[stream_name_chars] = L'\0'; ++ } ++ ++ if ((BACKUP_ALTERNATE_DATA == stream_id.dwStreamId) && ++ (NULL != stream_name_w) && ++ (L'\0' != stream_name_w[0])) { ++ stream_name_utf8 = win32_utf16_to_utf8(stream_name_w); ++ if (NULL == stream_name_utf8) { ++ free(stream_name_w); ++ goto done; ++ } ++ ++ if (0 != win32_create_dest_stream_handle(dest_handle, stream_name_utf8, &dest_stream_handle) || ++ 0 != win32_copy_backup_stream_data(src_handle, dest_stream_handle, stream_id.Size, &backup_context)) { ++ if ((NULL != dest_stream_handle) && (INVALID_HANDLE_VALUE != dest_stream_handle)) { ++ CloseHandle(dest_stream_handle); ++ } ++ free(stream_name_utf8); ++ free(stream_name_w); ++ goto done; ++ } ++ ++ CloseHandle(dest_stream_handle); ++ free(stream_name_utf8); ++ } else if (0 != win32_backup_skip_stream(src_handle, stream_id.Size, &backup_context)) { ++ free(stream_name_w); ++ goto done; ++ } ++ ++ free(stream_name_w); ++ } ++ ++done: ++ if (NULL != backup_context) { ++ DWORD bytes_read = 0; ++ (void)BackupRead(src_handle, NULL, 0, &bytes_read, TRUE, FALSE, &backup_context); ++ } ++ ++ return status; ++} ++ ++static int filecopy_to_fd(const action_source_t *source, const char *dest_path, int dest_fd) ++{ ++ const char *src = NULL; ++ HANDLE src_handle = INVALID_HANDLE_VALUE; ++ HANDLE dest_handle = INVALID_HANDLE_VALUE; ++ int status = -1; ++ ++ if ((NULL == source) || (NULL == dest_path) || (dest_fd < 0)) { ++ return -1; ++ } ++ ++ src = (NULL != source->action_path) ? source->action_path : source->display_path; ++ src_handle = (HANDLE)source->handle; ++ if ((NULL == src) || (NULL == src_handle) || (INVALID_HANDLE_VALUE == src_handle)) { ++ return -1; ++ } ++ ++ dest_handle = (HANDLE)_get_osfhandle(dest_fd); ++ if ((NULL == dest_handle) || (INVALID_HANDLE_VALUE == dest_handle)) { ++ logg(LOGG_INFO, "filecopy_to_fd: Failed to get destination handle for '%s'.\n", dest_path); ++ goto done; ++ } ++ ++ if (0 != win32_copy_handle_data(src_handle, dest_handle)) { ++ logg(LOGG_INFO, "filecopy_to_fd: Failed copying unnamed stream for '%s'. Error: %lu\n", src, GetLastError()); ++ goto done; ++ } ++ ++ if (0 != win32_copy_alternate_streams(src_handle, dest_handle)) { ++ logg(LOGG_INFO, "filecopy_to_fd: Failed copying alternate data streams for '%s'. Error: %lu\n", src, GetLastError()); ++ goto done; ++ } ++ ++ if (0 != win32_copy_basic_info(src_handle, dest_handle)) { ++ logg(LOGG_INFO, "filecopy_to_fd: Failed copying basic file info for '%s'. Error: %lu\n", src, GetLastError()); ++ goto done; ++ } ++ ++ status = 0; ++ ++done: ++ return status; ++} ++ ++static int win32_delete_handle(HANDLE file_handle) ++{ ++ FILE_DISPOSITION_INFO file_info = {0}; ++ ++ if ((NULL == file_handle) || (INVALID_HANDLE_VALUE == file_handle)) { ++ return -1; ++ } ++ ++ file_info.DeleteFile = TRUE; ++ if (FALSE == SetFileInformationByHandle( ++ file_handle, ++ FileDispositionInfo, ++ &file_info, ++ sizeof(FILE_DISPOSITION_INFO))) { ++ return -1; ++ } ++ ++ return 0; ++} ++ ++static ACCESS_MASK win32_source_scan_desired_access(void) ++{ ++ return FILE_GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE; ++} ++ ++/** ++ * @brief Check whether the selected action will remove the source file. ++ * ++ * @return true The source file must be deleted after a detection. ++ * @return false The source file is only copied or inspected. ++ */ ++static bool win32_source_action_needs_delete_access(void) ++{ ++ return (action == action_move) || (action == action_remove); ++} ++ ++static int win32_same_file_handle(HANDLE first_handle, HANDLE second_handle) ++{ ++ BY_HANDLE_FILE_INFORMATION first_info; ++ BY_HANDLE_FILE_INFORMATION second_info; ++ ++ if ((NULL == first_handle) || (INVALID_HANDLE_VALUE == first_handle) || ++ (NULL == second_handle) || (INVALID_HANDLE_VALUE == second_handle)) { ++ SetLastError(ERROR_INVALID_HANDLE); ++ return -1; ++ } ++ ++ if (FALSE == GetFileInformationByHandle(first_handle, &first_info)) { ++ return -1; ++ } ++ if (FALSE == GetFileInformationByHandle(second_handle, &second_info)) { ++ return -1; ++ } ++ ++ if ((first_info.dwVolumeSerialNumber != second_info.dwVolumeSerialNumber) || ++ (first_info.nFileIndexHigh != second_info.nFileIndexHigh) || ++ (first_info.nFileIndexLow != second_info.nFileIndexLow)) { ++ SetLastError(ERROR_FILE_NOT_FOUND); ++ return -1; ++ } ++ ++ return 0; ++} ++ ++static int win32_open_delete_handle_for_source( ++ const char *target, ++ HANDLE source_handle, ++ HANDLE *out_handle) ++{ ++ HANDLE delete_handle = INVALID_HANDLE_VALUE; ++ int status = -1; ++ ++ if ((NULL == target) || (NULL == out_handle)) { ++ SetLastError(ERROR_INVALID_PARAMETER); ++ return -1; ++ } ++ ++ if (0 != win32_open_existing_path( ++ target, ++ false, ++ DELETE | FILE_READ_ATTRIBUTES | SYNCHRONIZE, ++ &delete_handle)) { ++ goto done; ++ } ++ ++ if ((NULL != source_handle) && (INVALID_HANDLE_VALUE != source_handle) && ++ (0 != win32_same_file_handle(source_handle, delete_handle))) { ++ goto done; ++ } ++ ++ *out_handle = delete_handle; ++ delete_handle = INVALID_HANDLE_VALUE; ++ status = 0; ++ ++done: ++ if (INVALID_HANDLE_VALUE != delete_handle) { ++ CloseHandle(delete_handle); ++ } ++ return status; ++} ++ ++static int win32_delete_dest_name(const char *dest_name) ++{ ++ HMODULE ntdll = NULL; ++ PNTCF pNtCreateFile = NULL; ++ PRIUS pRtlInitUnicodeString = NULL; ++ HANDLE delete_handle = NULL; ++ int status = -1; ++ ++ if ((NULL == dest_name) || (NULL == actarget_handle)) { ++ return -1; ++ } ++ ++ ntdll = LoadLibraryA("ntdll.dll"); ++ if (NULL == ntdll) { ++ return -1; ++ } ++ ++ pNtCreateFile = (PNTCF)GetProcAddress(ntdll, "NtCreateFile"); ++ pRtlInitUnicodeString = (PRIUS)GetProcAddress(ntdll, "RtlInitUnicodeString"); ++ if ((NULL == pNtCreateFile) || (NULL == pRtlInitUnicodeString)) { ++ goto done; ++ } ++ ++ delete_handle = win32_openat( ++ actarget_handle, ++ dest_name, ++ pNtCreateFile, ++ pRtlInitUnicodeString, ++ DELETE | FILE_READ_ATTRIBUTES | SYNCHRONIZE, ++ FILE_ATTRIBUTE_NORMAL, ++ FILE_OPEN, ++ FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_OPEN_FOR_BACKUP_INTENT | FILE_SYNCHRONOUS_IO_NONALERT, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); ++ if ((NULL == delete_handle) || (INVALID_HANDLE_VALUE == delete_handle)) { ++ goto done; ++ } ++ ++ status = win32_delete_handle(delete_handle); ++ ++done: ++ if ((NULL != delete_handle) && (INVALID_HANDLE_VALUE != delete_handle)) { ++ CloseHandle(delete_handle); ++ } ++ if (NULL != ntdll) { ++ FreeLibrary(ntdll); ++ } ++ return status; ++} ++ ++static int win32_delete_dest_path(const char *dest_path) ++{ ++ const char *slash; ++ const char *backslash; ++ const char *dest_name; ++ ++ if (NULL == dest_path) { ++ return -1; ++ } ++ ++ slash = strrchr(dest_path, '/'); ++ backslash = strrchr(dest_path, '\\'); ++ if ((NULL != slash) && ((NULL == backslash) || (slash > backslash))) { ++ dest_name = slash + 1; ++ } else if (NULL != backslash) { ++ dest_name = backslash + 1; ++ } else { ++ dest_name = dest_path; ++ } ++ ++ return win32_delete_dest_name(dest_name); ++} ++ ++static char *win32_parent_path_dup(const char *path) ++{ ++ char *copy = NULL; ++ char *slash = NULL; ++ char *backslash = NULL; ++ char *last_sep = NULL; ++ size_t path_len = 0; ++ size_t root_len = 0; ++ ++ if (NULL == path) { ++ return NULL; ++ } ++ ++ copy = strdup(path); ++ if (NULL == copy) { ++ return NULL; ++ } ++ ++ path_len = strlen(copy); ++ root_len = win32_path_root_length(copy, path_len); ++ ++ slash = strrchr(copy, '/'); ++ backslash = strrchr(copy, '\\'); ++ last_sep = (slash > backslash) ? slash : backslash; ++ if (NULL == last_sep) { ++ free(copy); ++ return strdup("."); ++ } ++ ++ if ((0 != root_len) && ((size_t)(last_sep - copy) < root_len)) { ++ copy[root_len] = '\0'; ++ } else { ++ *last_sep = '\0'; ++ } ++ ++ return copy; ++} ++ ++static bool win32_path_is_separator(char c) ++{ ++ return ('/' == c) || ('\\' == c); ++} ++ ++static bool win32_path_is_drive_letter(char c) ++{ ++ return (('A' <= c) && ('Z' >= c)) || (('a' <= c) && ('z' >= c)); ++} ++ ++static bool win32_path_ascii_equal_ignore_case(char lhs, char rhs) ++{ ++ if (('A' <= lhs) && ('Z' >= lhs)) { ++ lhs = (char)(lhs - 'A' + 'a'); ++ } ++ if (('A' <= rhs) && ('Z' >= rhs)) { ++ rhs = (char)(rhs - 'A' + 'a'); ++ } ++ ++ return lhs == rhs; ++} ++ ++static bool win32_path_match_ignore_case(const char *path, size_t path_len, size_t pos, const char *expected) ++{ ++ size_t i = 0; ++ ++ if ((NULL == path) || (NULL == expected)) { ++ return false; ++ } ++ ++ while ('\0' != expected[i]) { ++ if ((pos + i >= path_len) || ++ !win32_path_ascii_equal_ignore_case(path[pos + i], expected[i])) { ++ return false; ++ } ++ i++; ++ } ++ ++ return true; ++} ++ ++static bool win32_path_has_extended_prefix(const char *path, size_t path_len) ++{ ++ return (path_len >= 4) && ++ win32_path_is_separator(path[0]) && ++ win32_path_is_separator(path[1]) && ++ ('?' == path[2]) && ++ win32_path_is_separator(path[3]); ++} ++ ++static bool win32_path_has_extended_unc_prefix(const char *path, size_t path_len) ++{ ++ return (path_len >= 8) && ++ win32_path_has_extended_prefix(path, path_len) && ++ (('U' == path[4]) || ('u' == path[4])) && ++ (('N' == path[5]) || ('n' == path[5])) && ++ (('C' == path[6]) || ('c' == path[6])) && ++ win32_path_is_separator(path[7]); ++} ++ ++static size_t win32_path_extended_volume_root_length(const char *path, size_t path_len) ++{ ++ size_t pos = 4; ++ ++ if (!win32_path_has_extended_prefix(path, path_len) || ++ !win32_path_match_ignore_case(path, path_len, pos, "Volume{")) { ++ return 0; ++ } ++ pos += sizeof("Volume{") - 1; ++ ++ while ((pos < path_len) && ++ ('}' != path[pos]) && ++ !win32_path_is_separator(path[pos])) { ++ pos++; ++ } ++ ++ if ((pos >= path_len) || ('}' != path[pos])) { ++ return 0; ++ } ++ pos++; ++ ++ if ((pos < path_len) && win32_path_is_separator(path[pos])) { ++ return pos + 1; ++ } ++ ++ return path_len; ++} ++ ++static size_t win32_path_root_length(const char *path, size_t path_len) ++{ ++ size_t pos = 0; ++ size_t volume_root_len = 0; ++ ++ if ((NULL == path) || (0 == path_len)) { ++ return 0; ++ } ++ ++ /* Extended drive root, such as "\\?\C:\". */ ++ if ((path_len >= 7) && ++ win32_path_is_separator(path[0]) && ++ win32_path_is_separator(path[1]) && ++ ('?' == path[2]) && ++ win32_path_is_separator(path[3]) && ++ win32_path_is_drive_letter(path[4]) && ++ (':' == path[5]) && ++ win32_path_is_separator(path[6])) { ++ return 7; ++ } ++ ++ /* Normal drive root, such as "C:\". */ ++ if ((path_len >= 3) && ++ win32_path_is_drive_letter(path[0]) && ++ (':' == path[1]) && ++ win32_path_is_separator(path[2])) { ++ return 3; ++ } ++ ++ /* ++ * Drive-qualified paths, such as "C:" or "C:dir". The root prefix is ++ * only the drive designator, but trimming must not remove it. ++ */ ++ if ((path_len >= 2) && ++ win32_path_is_drive_letter(path[0]) && ++ (':' == path[1])) { ++ return 2; ++ } ++ ++ volume_root_len = win32_path_extended_volume_root_length(path, path_len); ++ if (0 != volume_root_len) { ++ return volume_root_len; ++ } ++ ++ if ((path_len >= 2) && ++ win32_path_is_separator(path[0]) && ++ win32_path_is_separator(path[1])) { ++ pos = 2; ++ if (win32_path_has_extended_unc_prefix(path, path_len)) { ++ /* Extended UNC root, such as "\\?\UNC\server\share\". */ ++ pos = 8; ++ } else if ((path_len >= 4) && ++ win32_path_has_extended_prefix(path, path_len)) { ++ /* Generic extended namespace prefix, such as "\\?\". */ ++ return 4; ++ } ++ ++ /* Skip leading separators, including repeated slashes. */ ++ while ((pos < path_len) && win32_path_is_separator(path[pos])) { ++ pos++; ++ } ++ /* Skip the UNC server name in "\\server\share\". */ ++ while ((pos < path_len) && !win32_path_is_separator(path[pos])) { ++ pos++; ++ } ++ if (pos >= path_len) { ++ return path_len; ++ } ++ /* Skip separators between the server and share names. */ ++ while ((pos < path_len) && win32_path_is_separator(path[pos])) { ++ pos++; ++ } ++ /* Skip the UNC share name. */ ++ while ((pos < path_len) && !win32_path_is_separator(path[pos])) { ++ pos++; ++ } ++ if ((pos < path_len) && win32_path_is_separator(path[pos])) { ++ /* Preserve the separator that terminates "\\server\share\". */ ++ return pos + 1; ++ } ++ return path_len; ++ } ++ ++ /* Current-drive absolute root, such as "\Windows". */ ++ if (win32_path_is_separator(path[0])) { ++ return 1; ++ } ++ ++ return 0; ++} ++ ++static char *win32_trim_trailing_path_separators_dup(const char *path) ++{ ++ char *trimmed = NULL; ++ size_t path_len = 0; ++ size_t root_len = 0; ++ size_t trim_len = 0; ++ ++ if (NULL == path) { ++ return NULL; ++ } ++ ++ path_len = strlen(path); ++ root_len = win32_path_root_length(path, path_len); ++ trim_len = path_len; ++ ++ while ((trim_len > root_len) && win32_path_is_separator(path[trim_len - 1])) { ++ trim_len--; ++ } ++ ++ trimmed = malloc(trim_len + 1); ++ if (NULL == trimmed) { ++ return NULL; ++ } ++ ++ memcpy(trimmed, path, trim_len); ++ trimmed[trim_len] = '\0'; ++ ++ return trimmed; ++} ++ ++static WCHAR *win32_normalize_full_path_w(const char *path) ++{ ++ WCHAR *path_w = NULL; ++ WCHAR *extended_w = NULL; ++ WCHAR *normalized_w = NULL; ++ DWORD normalized_len = 0; ++ ++ path_w = win32_utf8_to_utf16(path); ++ if (NULL == path_w) { ++ return NULL; ++ } ++ ++ extended_w = win32_dup_extended_path_w(path_w); ++ free(path_w); ++ if (NULL == extended_w) { ++ return NULL; ++ } ++ ++ normalized_len = GetFullPathNameW(extended_w, 0, NULL, NULL); ++ if (0 == normalized_len) { ++ free(extended_w); ++ return NULL; ++ } ++ ++ normalized_w = malloc((size_t)normalized_len * sizeof(WCHAR)); ++ if (NULL == normalized_w) { ++ free(extended_w); ++ return NULL; ++ } ++ ++ if (0 == GetFullPathNameW(extended_w, normalized_len, normalized_w, NULL)) { ++ free(normalized_w); ++ free(extended_w); ++ return NULL; ++ } ++ ++ free(extended_w); ++ extended_w = win32_dup_nonextended_path_w(normalized_w); ++ free(normalized_w); ++ return extended_w; ++} ++ ++static WCHAR *win32_get_final_path_no_prefix_w(HANDLE handle) ++{ ++ WCHAR *final_w = NULL; ++ WCHAR *trimmed_w = NULL; ++ DWORD final_len = 0; ++ DWORD copied_len = 0; ++ DWORD buffer_len = 0; ++ const WCHAR *body = NULL; ++ ++ final_len = GetFinalPathNameByHandleW(handle, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); ++ if (0 == final_len) { ++ return NULL; ++ } ++ buffer_len = final_len + 1; ++ if (buffer_len <= final_len) { ++ return NULL; ++ } ++ ++ final_w = malloc((size_t)buffer_len * sizeof(WCHAR)); ++ if (NULL == final_w) { ++ return NULL; ++ } ++ ++ copied_len = GetFinalPathNameByHandleW(handle, final_w, buffer_len, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); ++ if ((0 == copied_len) || (copied_len >= buffer_len)) { ++ free(final_w); ++ return NULL; ++ } ++ final_w[copied_len] = L'\0'; ++ ++ trimmed_w = win32_dup_nonextended_path_w(final_w); ++ free(final_w); ++ return trimmed_w; ++} ++ ++static int win32_reject_reparse_handle(HANDLE handle) ++{ ++ FILE_ATTRIBUTE_TAG_INFO tag_info = {0}; ++ ++ if (0 == GetFileInformationByHandleEx( ++ handle, ++ FileAttributeTagInfo, ++ &tag_info, ++ sizeof(FILE_ATTRIBUTE_TAG_INFO))) { ++ return -1; ++ } ++ ++ if (0 != (tag_info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { ++ return -1; ++ } ++ ++ return 0; ++} ++ ++static int win32_validate_opened_path(HANDLE handle, const char *expected_path) ++{ ++ WCHAR *expected_w = NULL; ++ WCHAR *actual_w = NULL; ++ int status = -1; ++ ++ expected_w = win32_normalize_full_path_w(expected_path); ++ if (NULL == expected_w) { ++ goto done; ++ } ++ ++ actual_w = win32_get_final_path_no_prefix_w(handle); ++ if (NULL == actual_w) { ++ goto done; ++ } ++ ++ /* ++ * Keep this validation conservative. GetFullPathNameW() may preserve an ++ * 8.3 alias while GetFinalPathNameByHandleW() reports the long path, so ++ * valid short-name aliases can be rejected. Failing quarantine setup is ++ * preferable to accepting a path spelling we cannot prove still names the ++ * opened object. ++ */ ++ if (0 != _wcsicmp(expected_w, actual_w)) { ++ goto done; ++ } ++ ++ if (0 != win32_reject_reparse_handle(handle)) { ++ goto done; ++ } ++ ++ status = 0; ++ ++done: ++ if (NULL != expected_w) { ++ free(expected_w); ++ } ++ if (NULL != actual_w) { ++ free(actual_w); ++ } ++ return status; ++} ++ ++static int action_validate_actarget_path(void) ++{ ++ /* ++ * Destination entries are created through actarget_handle, but users see ++ * actarget/name. Refuse success if actarget no longer names that handle. ++ */ ++ if ((NULL == actarget) || (NULL == actarget_handle) || ++ (INVALID_HANDLE_VALUE == actarget_handle)) { ++ errno = EINVAL; ++ SetLastError(ERROR_INVALID_PARAMETER); ++ return -1; ++ } ++ ++ if (0 != win32_validate_opened_path(actarget_handle, actarget)) { ++ errno = EAGAIN; ++ SetLastError(ERROR_FILE_NOT_FOUND); ++ return -1; ++ } ++ ++ return 0; ++} ++ ++static int win32_open_existing_path(const char *path, bool is_directory, ACCESS_MASK desired_access, HANDLE *out_handle) ++{ ++ WCHAR *path_w = NULL; ++ WCHAR *extended_path_w = NULL; ++ HANDLE handle = INVALID_HANDLE_VALUE; ++ DWORD flags = FILE_FLAG_OPEN_REPARSE_POINT; ++ int status = -1; ++ ++ if ((NULL == path) || (NULL == out_handle)) { ++ return -1; ++ } ++ ++ path_w = win32_utf8_to_utf16(path); ++ if (NULL == path_w) { ++ return -1; ++ } ++ ++ extended_path_w = win32_dup_extended_path_w(path_w); ++ if (NULL == extended_path_w) { ++ goto done; ++ } ++ ++ if (true == is_directory) { ++ flags |= FILE_FLAG_BACKUP_SEMANTICS; ++ } ++ ++ handle = CreateFileW( ++ extended_path_w, ++ desired_access, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, ++ NULL, ++ OPEN_EXISTING, ++ flags, ++ NULL); ++ if (INVALID_HANDLE_VALUE == handle) { ++ goto done; ++ } ++ ++ if (0 != win32_validate_opened_path(handle, path)) { ++ SetLastError(ERROR_ACCESS_DENIED); ++ CloseHandle(handle); ++ handle = INVALID_HANDLE_VALUE; ++ goto done; ++ } ++ ++ *out_handle = handle; ++ handle = INVALID_HANDLE_VALUE; ++ status = 0; ++ ++done: ++ if (INVALID_HANDLE_VALUE != handle) { ++ CloseHandle(handle); ++ } ++ if (NULL != path_w) { ++ free(path_w); ++ } ++ if (NULL != extended_path_w) { ++ free(extended_path_w); ++ } ++ return status; ++} ++ ++/** ++ * @brief Open the Windows quarantine source with the requested access mask. ++ * ++ * @param path Source path to open. ++ * @param require_resolved_path Whether to validate the opened path. ++ * @param desired_access Windows access mask requested for the handle. ++ * @param out_handle Opened source handle on success. ++ * @return 0 Source handle opened. ++ * @return -1 Source handle could not be opened. ++ */ ++static int win32_open_source_handle_with_access( ++ const char *path, ++ bool require_resolved_path, ++ ACCESS_MASK desired_access, ++ HANDLE *out_handle) ++{ ++ WCHAR *path_w = NULL; ++ WCHAR *extended_path_w = NULL; ++ HANDLE handle = INVALID_HANDLE_VALUE; ++ int status = -1; ++ ++ if ((NULL == path) || (NULL == out_handle)) { ++ return -1; ++ } ++ ++ if (require_resolved_path) { ++ return win32_open_existing_path(path, false, desired_access, out_handle); ++ } ++ ++ path_w = win32_utf8_to_utf16(path); ++ if (NULL == path_w) { ++ return -1; ++ } ++ ++ extended_path_w = win32_dup_extended_path_w(path_w); ++ if (NULL == extended_path_w) { ++ goto done; ++ } ++ ++ handle = CreateFileW( ++ extended_path_w, ++ desired_access, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, ++ NULL, ++ OPEN_EXISTING, ++ FILE_ATTRIBUTE_NORMAL, ++ NULL); ++ if (INVALID_HANDLE_VALUE == handle) { ++ goto done; ++ } ++ ++ *out_handle = handle; ++ handle = INVALID_HANDLE_VALUE; ++ status = 0; ++ ++done: ++ if (INVALID_HANDLE_VALUE != handle) { ++ CloseHandle(handle); ++ } ++ if (NULL != path_w) { ++ free(path_w); ++ } ++ if (NULL != extended_path_w) { ++ free(extended_path_w); ++ } ++ return status; ++} ++ ++/** ++ * @brief Open a Windows quarantine source for scanning. ++ * ++ * Move/remove actions first try to retain DELETE access on the scan source. ++ * If that fails, fall back to a read-only scan handle so quarantine-action ++ * permission failures do not suppress detection. ++ * ++ * @param path Source path to open. ++ * @param require_resolved_path Whether to validate the opened path. ++ * @param out_handle Opened source handle on success. ++ * @param out_handle_can_delete Whether the opened handle has DELETE access. ++ * @return 0 Source handle opened. ++ * @return -1 Source handle could not be opened. ++ */ ++static int win32_open_source_handle( ++ const char *path, ++ bool require_resolved_path, ++ HANDLE *out_handle, ++ bool *out_handle_can_delete) ++{ ++ ACCESS_MASK desired_access = win32_source_scan_desired_access(); ++ ++ if ((NULL == out_handle) || (NULL == out_handle_can_delete)) { ++ SetLastError(ERROR_INVALID_PARAMETER); ++ return -1; ++ } ++ ++ *out_handle = INVALID_HANDLE_VALUE; ++ *out_handle_can_delete = false; ++ ++ if (win32_source_action_needs_delete_access() && ++ (0 == win32_open_source_handle_with_access( ++ path, ++ require_resolved_path, ++ desired_access | DELETE, ++ out_handle))) { ++ *out_handle_can_delete = true; ++ return 0; ++ } ++ ++ return win32_open_source_handle_with_access( ++ path, ++ require_resolved_path, ++ desired_access, ++ out_handle); ++} ++ ++/** ++ * @brief An openat equivalent for Win32 with a check to NOFOLLOW soft-links. ++ * ++ * The caller is responsible for closing the HANDLE. ++ * ++ * For the desiredAccess, fileAttributes, createOptions, and shareAccess parameters ++ * see https://docs.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntcreatefile ++ * ++ * @param current_handle The current handle. If set to NULL, then filename should be a drive letter. ++ * @param filename The directory to open. If current_handle is valid, should be a directory found in the current directory. ++ * @param pNtCreateFile A function pointer to the NtCreateFile Win32 Native API. ++ * @param pRtlInitUnicodeString A function pointer to the RtlInitUnicodeString Win32 Native API. ++ * @param desiredAccess The DesiredAccess option for NtCreateFile ++ * @param fileAttributes The FileAttributes option for NtCreateFile ++ * @param createDisposition The CreateDisposition option for NtCreateFile ++ * @param createOptions The CreateOptions option for NtCreateFile ++ * @param shareAccess The ShareAccess option for NtCreateFile ++ * @return HANDLE A handle on success, NULL on failure. ++ */ ++static HANDLE win32_openat( ++ HANDLE current_handle, ++ const char *filename, ++ PNTCF pNtCreateFile, ++ PRIUS pRtlInitUnicodeString, ++ ACCESS_MASK desiredAccess, ++ ULONG fileAttributes, ++ ULONG createDisposition, ++ ULONG createOptions, ++ ULONG shareAccess) ++{ ++ HANDLE next_handle = NULL; ++ ++ LONG ntStatus; ++ WCHAR *filenameW = NULL; ++ UNICODE_STRING filenameU; ++ int cchNextDirectoryName = 0; ++ IO_STATUS_BLOCK ioStatusBlock = {0}; ++ OBJECT_ATTRIBUTES objAttributes = {0}; ++ FILE_ATTRIBUTE_TAG_INFO tagInfo = {0}; ++ ++ /* Convert filename to a UNICODE_STRING, required by the native API NtCreateFile() */ ++ cchNextDirectoryName = MultiByteToWideChar(CP_UTF8, 0, filename, -1, NULL, 0); ++ filenameW = malloc(cchNextDirectoryName * sizeof(WCHAR)); ++ if (NULL == filenameW) { ++ logg(LOGG_INFO, "win32_openat: failed to allocate memory for next directory name UTF16LE string\n"); ++ goto done; ++ } ++ if (0 == MultiByteToWideChar(CP_UTF8, 0, filename, -1, filenameW, cchNextDirectoryName)) { ++ logg(LOGG_INFO, "win32_openat: failed to allocate buffer for unicode version of intermediate directory name.\n"); ++ goto done; ++ } ++ pRtlInitUnicodeString(&filenameU, filenameW); ++ ++ InitializeObjectAttributes( ++ &objAttributes, // ObjectAttributes ++ &filenameU, // ObjectName ++ OBJ_CASE_INSENSITIVE, // Attributes ++ current_handle, // Root directory ++ NULL); // SecurityDescriptor ++ ++ ntStatus = pNtCreateFile( ++ &next_handle, // FileHandle ++ desiredAccess, // DesiredAccess ++ &objAttributes, // ObjectAttributes ++ &ioStatusBlock, // [out] status ++ 0, // AllocationSize ++ fileAttributes, // FileAttributes ++ shareAccess, // ShareAccess ++ createDisposition, ++ createOptions, // CreateOptions ++ NULL, // EaBuffer ++ 0); // EaLength ++ if (!NT_SUCCESS(ntStatus) || (NULL == next_handle)) { ++ switch (ntStatus) { ++ case STATUS_OBJECT_NAME_COLLISION: ++ case STATUS_OBJECT_NAME_EXISTS: ++ errno = EEXIST; ++ break; ++ case STATUS_ACCESS_DENIED: ++ errno = EACCES; ++ break; ++ default: ++ errno = EIO; ++ break; ++ } ++ logg(LOGG_INFO, "win32_openat: Failed to open file '%s'. \nError: 0x%x \nioStatusBlock: 0x%x\n", filename, ntStatus, ioStatusBlock.Information); ++ goto done; ++ } ++ logg(LOGG_DEBUG, "win32_openat: Opened file \"%s\"\n", filename); ++ ++ if (FILE_CREATE == createDisposition) { ++ goto done; ++ } ++ ++ if (0 == GetFileInformationByHandleEx( ++ next_handle, // hFile, ++ FileAttributeTagInfo, // FileInformationClass ++ &tagInfo, // lpFileInformation ++ sizeof(FILE_ATTRIBUTE_TAG_INFO))) { // dwBufferSize ++ logg(LOGG_INFO, "win32_openat: Failed to get file information by handle '%s'. Error: %d.\n", filename, GetLastError()); ++ ++ CloseHandle(next_handle); ++ next_handle = NULL; ++ goto done; ++ } ++ logg(LOGG_DEBUG, "win32_openat: tagInfo.FileAttributes: 0x%0x\n", tagInfo.FileAttributes); ++ logg(LOGG_DEBUG, "win32_openat: tagInfo.ReparseTag: 0x%0x\n", tagInfo.ReparseTag); ++ if (0 != (tagInfo.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { ++ logg(LOGG_INFO, "win32_openat: File is a soft link: '%s' Aborting path traversal.\n\n", filename); ++ ++ CloseHandle(next_handle); ++ next_handle = NULL; ++ goto done; ++ } ++ logg(LOGG_DEBUG, "win32_openat: File or directory is not a soft link.\n\n"); ++ ++done: ++ if (NULL != filenameW) { ++ free(filenameW); ++ } ++ ++ return next_handle; ++} ++ ++static int win32_create_dest_file(const char *dest_basename) ++{ ++ HMODULE ntdll = NULL; ++ PNTCF pNtCreateFile = NULL; ++ PRIUS pRtlInitUnicodeString = NULL; ++ HANDLE dest_handle = NULL; ++ int fd = -1; ++ ++ if ((NULL == dest_basename) || (NULL == actarget_handle)) { ++ return -1; ++ } ++ ++ ntdll = LoadLibraryA("ntdll.dll"); ++ if (NULL == ntdll) { ++ return -1; ++ } ++ ++ pNtCreateFile = (PNTCF)GetProcAddress(ntdll, "NtCreateFile"); ++ pRtlInitUnicodeString = (PRIUS)GetProcAddress(ntdll, "RtlInitUnicodeString"); ++ if ((NULL == pNtCreateFile) || (NULL == pRtlInitUnicodeString)) { ++ FreeLibrary(ntdll); ++ return -1; ++ } ++ ++ dest_handle = win32_openat( ++ actarget_handle, ++ dest_basename, ++ pNtCreateFile, ++ pRtlInitUnicodeString, ++ FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | FILE_READ_ATTRIBUTES | SYNCHRONIZE, ++ FILE_ATTRIBUTE_NORMAL, ++ FILE_CREATE, ++ FILE_NON_DIRECTORY_FILE | FILE_OPEN_FOR_BACKUP_INTENT | FILE_SYNCHRONOUS_IO_NONALERT, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); ++ if ((NULL == dest_handle) || (INVALID_HANDLE_VALUE == dest_handle)) { ++ FreeLibrary(ntdll); ++ return -1; ++ } ++ ++ fd = _open_osfhandle((intptr_t)dest_handle, _O_WRONLY | _O_BINARY); ++ if (fd < 0) { ++ CloseHandle(dest_handle); ++ (void)win32_delete_dest_name(dest_basename); ++ } ++ ++ FreeLibrary(ntdll); ++ return fd; ++} ++#endif ++ ++void action_source_init(action_source_t *source) ++{ ++ if (NULL == source) { ++ return; ++ } ++ ++ memset(source, 0, sizeof(*source)); ++ source->scan_fd = -1; ++#ifdef _WIN32 ++ source->handle = INVALID_HANDLE_VALUE; ++#endif ++} ++ ++static cl_error_t action_source_set_display_path(action_source_t *source, const char *display_path) ++{ ++ source->display_path = strdup(display_path); ++ if (NULL == source->display_path) { ++ return CL_EMEM; ++ } ++ ++ return CL_SUCCESS; ++} ++ ++#ifndef _WIN32 ++static cl_error_t action_source_absolute_path_dup(const char *path, char **absolute_path) ++{ ++ char cwd[PATH_MAX + 1]; ++ ++ if ((NULL == path) || (NULL == absolute_path)) { ++ return CL_EARG; ++ } ++ ++ *absolute_path = NULL; ++ ++ if (cli_is_abspath(path)) { ++ *absolute_path = strdup(path); ++ } else { ++ if (NULL == getcwd(cwd, sizeof(cwd))) { ++ return CL_EOPEN; ++ } ++ ++ *absolute_path = cli_newfilepath(cwd, path); ++ } ++ ++ return (NULL == *absolute_path) ? CL_EMEM : CL_SUCCESS; ++} ++ ++static cl_error_t action_source_fallback_action_path_dup(const char *path, char **action_path) ++{ ++ cl_error_t status; ++ ++ if ((NULL == path) || (NULL == action_path)) { ++ return CL_EARG; ++ } ++ ++ *action_path = NULL; ++ ++ status = cli_realpath(path, action_path); ++ if ((CL_SUCCESS == status) && (NULL != *action_path)) { ++ return CL_SUCCESS; ++ } ++ ++ if (NULL != *action_path) { ++ free(*action_path); ++ *action_path = NULL; ++ } ++ ++ if ((action == action_move) || (action == action_remove)) { ++ return (CL_SUCCESS == status) ? CL_EOPEN : status; ++ } ++ ++ return action_source_absolute_path_dup(path, action_path); ++} ++ ++static cl_error_t action_source_populate_posix(action_source_t *source, int fd, const char *open_path) ++{ ++ cl_error_t status = CL_EOPEN; ++ ++ if (0 != action_fstat_nointr(fd, &source->statbuf)) { ++ goto done; ++ } ++ source->has_stat = true; ++ ++ if (!S_ISREG(source->statbuf.st_mode)) { ++ errno = EINVAL; ++ status = CL_EOPEN; ++ goto done; ++ } ++ ++ status = cli_get_filepath_from_filedesc(fd, &source->action_path); ++ if (CL_SUCCESS != status) { ++ /* ++ * Some supported POSIX targets cannot derive a path from an open fd, ++ * and Linux can also fail here when /proc/self/fd is unavailable. Fall ++ * back to an absolute version of the submitted display path only for ++ * those path-resolution failures. The opened fd remains the source for ++ * scan/copy bytes, and move/remove still verify the path against the ++ * recorded stat before unlinking. ++ */ ++ if ((CL_BREAK != status) && (CL_EOPEN != status)) { ++ goto done; ++ } ++ ++ status = action_source_fallback_action_path_dup(open_path, &source->action_path); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ } ++ ++ status = CL_SUCCESS; ++ ++done: ++ return status; ++} ++ ++static cl_error_t action_source_open_posix_path(const char *open_path, bool require_resolved_path, int *fd) ++{ ++ if ((NULL == open_path) || (NULL == fd)) { ++ return CL_EARG; ++ } ++ ++ *fd = -1; ++ ++ if (require_resolved_path) { ++ if (!cli_is_abspath(open_path)) { ++ return CL_EOPEN; ++ } ++ ++ if (0 != traverse_to(open_path, false, fd)) { ++ return CL_EOPEN; ++ } ++ } else { ++ *fd = safe_open(open_path, action_source_open_flags()); ++ if (*fd < 0) { ++ return CL_EOPEN; ++ } ++ } ++ ++ return CL_SUCCESS; ++} ++#else ++static cl_error_t action_source_populate_win32( ++ action_source_t *source, ++ HANDLE source_handle, ++ bool source_handle_can_delete) ++{ ++ HANDLE scan_handle = INVALID_HANDLE_VALUE; ++ cl_error_t status = CL_EOPEN; ++ ++ if (FALSE == DuplicateHandle( ++ GetCurrentProcess(), ++ source_handle, ++ GetCurrentProcess(), ++ &scan_handle, ++ 0, ++ FALSE, ++ DUPLICATE_SAME_ACCESS)) { ++ goto done; ++ } ++ ++ source->scan_fd = _open_osfhandle((intptr_t)scan_handle, _O_RDONLY | _O_BINARY); ++ if (source->scan_fd < 0) { ++ CloseHandle(scan_handle); ++ goto done; ++ } ++ scan_handle = INVALID_HANDLE_VALUE; ++ ++ if (0 != action_fstat_nointr(source->scan_fd, &source->statbuf)) { ++ goto done; ++ } ++ source->has_stat = true; ++ ++ if (!S_ISREG(source->statbuf.st_mode)) { ++ errno = EINVAL; ++ status = CL_EOPEN; ++ goto done; ++ } ++ ++ status = cli_get_filepath_from_handle(source_handle, &source->action_path); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ ++ source->handle = source_handle; ++ source->handle_can_delete = source_handle_can_delete; ++ status = CL_SUCCESS; ++ ++done: ++ if (INVALID_HANDLE_VALUE != scan_handle) { ++ CloseHandle(scan_handle); ++ } ++ return status; ++} ++#endif ++ ++static cl_error_t action_source_open_path_impl(const char *display_path, const char *open_path, action_source_t *source, bool require_resolved_path) ++{ ++ cl_error_t status = CL_EARG; ++#ifndef _WIN32 ++ int fd = -1; ++#else ++ HANDLE source_handle = INVALID_HANDLE_VALUE; ++ bool source_handle_can_delete = false; ++#endif ++ ++ if ((NULL == display_path) || (NULL == open_path) || (NULL == source)) { ++ return CL_EARG; ++ } ++ ++ action_source_init(source); ++ ++ status = action_source_set_display_path(source, display_path); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ ++#ifndef _WIN32 ++ status = action_source_open_posix_path(open_path, require_resolved_path, &fd); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ ++ status = action_source_populate_posix(source, fd, open_path); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ ++ source->scan_fd = fd; ++ fd = -1; ++#else ++ if (0 != win32_open_source_handle( ++ open_path, ++ require_resolved_path, ++ &source_handle, ++ &source_handle_can_delete)) { ++ status = CL_EOPEN; ++ goto done; ++ } ++ ++ status = action_source_populate_win32(source, source_handle, source_handle_can_delete); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ source_handle = INVALID_HANDLE_VALUE; ++#endif ++ ++done: ++#ifndef _WIN32 ++ if (-1 != fd) { ++ close(fd); ++ } ++#else ++ if (INVALID_HANDLE_VALUE != source_handle) { ++ CloseHandle(source_handle); ++ } ++#endif ++ if (CL_SUCCESS != status) { ++ action_source_close(source); ++ } ++ return status; ++} ++ ++cl_error_t action_source_open_path(const char *display_path, const char *open_path, action_source_t *source) ++{ ++ return action_source_open_path_impl(display_path, open_path, source, true); ++} ++ ++cl_error_t action_source_open(const char *display_path, action_source_t *source) ++{ ++ return action_source_open_path_impl(display_path, display_path, source, false); ++} ++ ++cl_error_t action_source_from_fd(const char *display_path, int fd, action_source_t *source) ++{ ++ cl_error_t status = CL_EARG; ++#ifndef _WIN32 ++ int dup_fd = -1; ++#else ++ intptr_t source_osfhandle = -1; ++ HANDLE source_handle = INVALID_HANDLE_VALUE; ++ HANDLE dup_handle = INVALID_HANDLE_VALUE; ++#endif ++ ++ if ((NULL == display_path) || (fd < 0) || (NULL == source)) { ++ return CL_EARG; ++ } ++ ++ action_source_init(source); ++ ++ status = action_source_set_display_path(source, display_path); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ ++#ifndef _WIN32 ++ do { ++ dup_fd = dup(fd); ++ } while ((dup_fd < 0) && (EINTR == errno)); ++ if (dup_fd < 0) { ++ status = CL_EOPEN; ++ goto done; ++ } ++ ++ status = action_source_populate_posix(source, dup_fd, display_path); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ ++ source->scan_fd = dup_fd; ++ dup_fd = -1; ++#else ++ source_osfhandle = _get_osfhandle(fd); ++ if (-1 == source_osfhandle) { ++ status = CL_EOPEN; ++ goto done; ++ } ++ source_handle = (HANDLE)source_osfhandle; ++ ++ if (FALSE == DuplicateHandle( ++ GetCurrentProcess(), ++ source_handle, ++ GetCurrentProcess(), ++ &dup_handle, ++ 0, ++ FALSE, ++ DUPLICATE_SAME_ACCESS)) { ++ status = CL_EOPEN; ++ goto done; ++ } ++ ++ status = action_source_populate_win32(source, dup_handle, false); ++ if (CL_SUCCESS != status) { ++ goto done; ++ } ++ dup_handle = INVALID_HANDLE_VALUE; ++#endif ++ ++done: ++#ifndef _WIN32 ++ if (-1 != dup_fd) { ++ close(dup_fd); ++ } ++#else ++ if (INVALID_HANDLE_VALUE != dup_handle) { ++ CloseHandle(dup_handle); ++ } ++#endif ++ if (CL_SUCCESS != status) { ++ action_source_close(source); ++ } ++ return status; ++} ++ ++void action_source_close(action_source_t *source) ++{ ++ if (NULL == source) { ++ return; ++ } ++ ++ if (-1 != source->scan_fd) { ++ close(source->scan_fd); ++ } ++#ifdef _WIN32 ++ if ((NULL != source->handle) && (INVALID_HANDLE_VALUE != source->handle)) { ++ CloseHandle((HANDLE)source->handle); ++ } ++#endif ++ if (NULL != source->display_path) { ++ free(source->display_path); ++ } ++ if (NULL != source->action_path) { ++ free(source->action_path); ++ } ++ ++ action_source_init(source); ++} + + static int getdest(const char *fullpath, char **newname) + { + char *tmps, *filename; ++ const char *dest_basename; + int fd, i; + + tmps = strdup(fullpath); +@@ -77,15 +2728,34 @@ static int getdest(const char *fullpath, char **newname) + free(tmps); + return -1; + } +- sprintf(*newname, "%s" PATHSEP "%s", actarget, filename); ++ dest_basename = filename; ++ sprintf(*newname, "%s" PATHSEP "%s", actarget, dest_basename); + for (i = 1; i < 1000; i++) { +- fd = open(*newname, O_WRONLY | O_CREAT | O_EXCL, 0600); ++#ifndef _WIN32 ++ fd = action_openat_nointr(actarget_fd, dest_basename, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_BINARY, 0600); ++#else ++ fd = win32_create_dest_file(dest_basename); ++#endif + if (fd >= 0) { ++ if (0 != action_validate_actarget_path()) { ++ int validate_errno = errno; ++ ++ (void)action_close_dest_fd(&fd); ++#ifndef _WIN32 ++ (void)action_unlinkat_nointr(actarget_fd, dest_basename, 0); ++#else ++ (void)win32_delete_dest_name(dest_basename); ++#endif ++ errno = validate_errno; ++ break; ++ } + free(tmps); + return fd; + } + if (errno != EEXIST) break; + sprintf(*newname, "%s" PATHSEP "%s.%03u", actarget, filename, i); ++ dest_basename = strrchr(*newname, *PATHSEP); ++ dest_basename = (NULL == dest_basename) ? *newname : dest_basename + 1; + } + free(tmps); + free(*newname); +@@ -93,129 +2763,104 @@ static int getdest(const char *fullpath, char **newname) + return -1; + } + +-#ifdef _WIN32 ++#ifndef _WIN32 ++static int action_unlink_dest_at(const char *dest_path) ++{ ++ const char *dest_basename; + +-typedef LONG (*PNTCF)( +- PHANDLE FileHandle, // OUT +- ACCESS_MASK DesiredAccess, +- POBJECT_ATTRIBUTES ObjectAttributes, +- PIO_STATUS_BLOCK IoStatusBlock, // OUT +- PLARGE_INTEGER AllocationSize, +- ULONG FileAttributes, +- ULONG ShareAccess, +- ULONG CreateDisposition, +- ULONG CreateOptions, +- PVOID EaBuffer, +- ULONG EaLength); ++ if ((NULL == dest_path) || (-1 == actarget_fd)) { ++ errno = EINVAL; ++ return -1; ++ } + +-typedef void (*PRIUS)( +- PUNICODE_STRING DestinationString, +- PCWSTR SourceString); ++ dest_basename = strrchr(dest_path, *PATHSEP); ++ dest_basename = (NULL == dest_basename) ? dest_path : dest_basename + 1; + +-/** +- * @brief An openat equivalent for Win32 with a check to NOFOLLOW soft-links. +- * +- * The caller is responsible for closing the HANDLE. +- * +- * For the desiredAccess, fileAttributes, createOptions, and shareAccess parameters +- * see https://docs.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntcreatefile +- * +- * @param current_handle The current handle. If set to NULL, then filename should be a drive letter. +- * @param filename The directory to open. If current_handle is valid, should be a directory found in the current directory. +- * @param pNtCreateFile A function pointer to the NtCreateFile Win32 Native API. +- * @param pRtlInitUnicodeString A function pointer to the RtlInitUnicodeString Win32 Native API. +- * @param desiredAccess The DesiredAccess option for NtCreateFile +- * @param fileAttributes The FileAttributes option for NtCreateFile +- * @param createOptions The CreateOptions option for NtCreateFile +- * @param shareAccess The ShareAccess option for NtCreateFile +- * @return HANDLE A handle on success, NULL on failure. +- */ +-static HANDLE win32_openat( +- HANDLE current_handle, +- const char *filename, +- PNTCF pNtCreateFile, +- PRIUS pRtlInitUnicodeString, +- ACCESS_MASK desiredAccess, +- ULONG fileAttributes, +- ULONG createOptions, +- ULONG shareAccess) ++ return action_unlinkat_nointr(actarget_fd, dest_basename, 0); ++} ++ ++static int action_link_source_to_dest(const action_source_t *source, char **newname, STATBUF *source_stat_out) + { +- HANDLE next_handle = NULL; ++ char *tmps = NULL; ++ char *dest_path = NULL; ++ char *filename; ++ const char *dest_basename; ++ STATBUF dest_stat; ++ int status = -1; ++ int i; + +- LONG ntStatus; +- WCHAR *filenameW = NULL; +- UNICODE_STRING filenameU; +- int cchNextDirectoryName = 0; +- IO_STATUS_BLOCK ioStatusBlock = {0}; +- OBJECT_ATTRIBUTES objAttributes = {0}; +- FILE_ATTRIBUTE_TAG_INFO tagInfo = {0}; ++ if ((NULL == source) || (NULL == source->action_path) || ++ (NULL == newname) || (-1 == actarget_fd) || ++ (false == source->has_stat) || !S_ISREG(source->statbuf.st_mode)) { ++ errno = EINVAL; ++ return -1; ++ } + +- /* Convert filename to a UNICODE_STRING, required by the native API NtCreateFile() */ +- cchNextDirectoryName = MultiByteToWideChar(CP_UTF8, 0, filename, -1, NULL, 0); +- filenameW = malloc(cchNextDirectoryName * sizeof(WCHAR)); +- if (NULL == filenameW) { +- logg(LOGG_INFO, "win32_openat: failed to allocate memory for next directory name UTF16LE string\n"); +- goto done; ++ *newname = NULL; ++ ++ tmps = strdup(source->action_path); ++ if (NULL == tmps) { ++ return -1; + } +- if (0 == MultiByteToWideChar(CP_UTF8, 0, filename, -1, filenameW, cchNextDirectoryName)) { +- logg(LOGG_INFO, "win32_openat: failed to allocate buffer for unicode version of intermediate directory name.\n"); ++ filename = basename(tmps); ++ ++ dest_path = (char *)malloc(targlen + strlen(filename) + 6); ++ if (NULL == dest_path) { + goto done; + } +- pRtlInitUnicodeString(&filenameU, filenameW); + +- InitializeObjectAttributes( +- &objAttributes, // ObjectAttributes +- &filenameU, // ObjectName +- OBJ_CASE_INSENSITIVE, // Attributes +- current_handle, // Root directory +- NULL); // SecurityDescriptor ++ dest_basename = filename; ++ sprintf(dest_path, "%s" PATHSEP "%s", actarget, dest_basename); ++ for (i = 1; i < 1000; i++) { ++ if (0 == linkat(AT_FDCWD, source->action_path, actarget_fd, dest_basename, 0)) { ++ if (0 != action_fstatat_nointr(actarget_fd, dest_basename, &dest_stat, AT_SYMLINK_NOFOLLOW)) { ++ (void)action_unlink_dest_at(dest_path); ++ goto done; ++ } + +- ntStatus = pNtCreateFile( +- &next_handle, // FileHandle +- desiredAccess, // DesiredAccess +- &objAttributes, // ObjectAttributes +- &ioStatusBlock, // [out] status +- 0, // AllocationSize +- fileAttributes, // FileAttributes +- shareAccess, // ShareAccess +- FILE_OPEN, // CreateDisposition +- createOptions, // CreateOptions +- NULL, // EaBuffer +- 0); // EaLength +- if (!NT_SUCCESS(ntStatus) || (NULL == next_handle)) { +- logg(LOGG_INFO, "win32_openat: Failed to open file '%s'. \nError: 0x%x \nioStatusBlock: 0x%x\n", filename, ntStatus, ioStatusBlock.Information); +- goto done; +- } +- logg(LOGG_DEBUG, "win32_openat: Opened file \"%s\"\n", filename); ++ if (!S_ISREG(dest_stat.st_mode) || ++ dest_stat.st_dev != source->statbuf.st_dev || ++ dest_stat.st_ino != source->statbuf.st_ino) { ++ (void)action_unlink_dest_at(dest_path); ++ errno = EAGAIN; ++ goto done; ++ } + +- if (0 == GetFileInformationByHandleEx( +- next_handle, // hFile, +- FileAttributeTagInfo, // FileInformationClass +- &tagInfo, // lpFileInformation +- sizeof(FILE_ATTRIBUTE_TAG_INFO))) { // dwBufferSize +- logg(LOGG_INFO, "win32_openat: Failed to get file information by handle '%s'. Error: %d.\n", filename, GetLastError()); ++ if (0 != action_validate_actarget_path()) { ++ int validate_errno = errno; + +- CloseHandle(next_handle); +- next_handle = NULL; +- goto done; +- } +- logg(LOGG_DEBUG, "win32_openat: tagInfo.FileAttributes: 0x%0x\n", tagInfo.FileAttributes); +- logg(LOGG_DEBUG, "win32_openat: tagInfo.ReparseTag: 0x%0x\n", tagInfo.ReparseTag); +- if (0 != (tagInfo.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { +- logg(LOGG_INFO, "win32_openat: File is a soft link: '%s' Aborting path traversal.\n\n", filename); ++ (void)action_unlink_dest_at(dest_path); ++ errno = validate_errno; ++ goto done; ++ } + +- CloseHandle(next_handle); +- next_handle = NULL; +- goto done; ++ if (NULL != source_stat_out) { ++ *source_stat_out = source->statbuf; ++ } ++ *newname = dest_path; ++ dest_path = NULL; ++ status = 0; ++ goto done; ++ } ++ ++ if (EEXIST != errno) { ++ goto done; ++ } ++ ++ sprintf(dest_path, "%s" PATHSEP "%s.%03u", actarget, filename, i); ++ dest_basename = strrchr(dest_path, *PATHSEP); ++ dest_basename = (NULL == dest_basename) ? dest_path : dest_basename + 1; + } +- logg(LOGG_DEBUG, "win32_openat: File or directory is not a soft link.\n\n"); + + done: +- if (NULL != filenameW) { +- free(filenameW); ++ if (NULL != dest_path) { ++ free(dest_path); ++ } ++ if (NULL != tmps) { ++ free(tmps); + } + +- return next_handle; ++ return status; + } + #endif + +@@ -249,19 +2894,9 @@ static int traverse_to(const char *directory, bool want_directory_handle, HANDLE + int current_handle = -1; + int next_handle = -1; + #else +- bool bNeedDeleteFileAccess = false; +- +- HMODULE ntdll = NULL; +- PNTCF pNtCreateFile = NULL; +- PRIUS pRtlInitUnicodeString = NULL; +- +- PHANDLE current_handle = NULL; +- PHANDLE next_handle = NULL; +- +- ACCESS_MASK desiredAccess = STANDARD_RIGHTS_READ | STANDARD_RIGHTS_WRITE | SYNCHRONIZE | FILE_READ_ATTRIBUTES | FILE_READ_EA; +- ULONG fileAttributes = FILE_ATTRIBUTE_DIRECTORY; +- ULONG createOptions = FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT; +- ULONG shareAccess = FILE_SHARE_READ; ++ HANDLE current_handle = NULL; ++ char *path_to_open = NULL; ++ bool is_directory = want_directory_handle; + #endif + + if (NULL == directory || NULL == out_handle) { +@@ -269,24 +2904,6 @@ static int traverse_to(const char *directory, bool want_directory_handle, HANDLE + goto done; + } + +-#ifdef _WIN32 +- ntdll = LoadLibraryA("ntdll.dll"); +- if (NULL == ntdll) { +- logg(LOGG_INFO, "traverse_to: failed to load ntdll!\n"); +- goto done; +- } +- pNtCreateFile = (PNTCF)GetProcAddress(ntdll, "NtCreateFile"); +- if (NULL == pNtCreateFile) { +- logg(LOGG_INFO, "traverse_to: failed to get NtCreateFile proc address!\n"); +- goto done; +- } +- pRtlInitUnicodeString = (PRIUS)GetProcAddress(ntdll, "RtlInitUnicodeString"); +- if (NULL == pRtlInitUnicodeString) { +- logg(LOGG_INFO, "traverse_to: failed to get pRtlInitUnicodeString proc address!\n"); +- goto done; +- } +-#endif +- + tokenized_directory = strdup(directory); + if (NULL == tokenized_directory) { + logg(LOGG_INFO, "traverse_to: Failed to get copy of directory path to be tokenized!\n"); +@@ -304,7 +2921,7 @@ static int traverse_to(const char *directory, bool want_directory_handle, HANDLE + * Open the root(/) directory, because it won't be the first token like a + * drive letter (i.e. "C:") would be on Windows. + */ +- current_handle = open("/", O_RDONLY | O_NOFOLLOW); ++ current_handle = action_open_directory_nointr("/"); + if (-1 == current_handle) { + logg(LOGG_INFO, "traverse_to: Failed to open file descriptor for '/' directory.\n"); + goto done; +@@ -315,10 +2932,14 @@ static int traverse_to(const char *directory, bool want_directory_handle, HANDLE + tokens_count -= 1; + } + ++#ifndef _WIN32 + if (0 == tokens_count) { +- logg(LOGG_INFO, "traverse_to: Failed to get copy of directory path to be tokenized!\n"); ++ status = 0; ++ *out_handle = current_handle; ++ current_handle = -1; + goto done; + } ++#endif + + for (i = 0; i < tokens_count; i++) { + if (0 == strlen(tokens[i])) { +@@ -327,8 +2948,12 @@ static int traverse_to(const char *directory, bool want_directory_handle, HANDLE + } + + #ifndef _WIN32 ++ bool is_final_component; + +- next_handle = openat(current_handle, tokens[i], O_RDONLY | O_NOFOLLOW); ++ is_final_component = (false == want_directory_handle) && (i + 1 == tokens_count); ++ next_handle = is_final_component ++ ? action_openat_source_nointr(current_handle, tokens[i]) ++ : action_openat_directory_nointr(current_handle, tokens[i]); + if (-1 == next_handle) { + logg(LOGG_INFO, "traverse_to: Failed open %s\n", tokens[i]); + goto done; +@@ -338,193 +2963,349 @@ static int traverse_to(const char *directory, bool want_directory_handle, HANDLE + next_handle = -1; + + #else ++ (void)i; ++ break; ++#endif + +- if (true != want_directory_handle) { +- if (i == tokens_count - 1) { +- /* Change createfile options for our target file instead of an intermediate directory. */ +- desiredAccess = FILE_GENERIC_READ | DELETE; +- fileAttributes = FILE_ATTRIBUTE_NORMAL; +- createOptions = FILE_NON_DIRECTORY_FILE; +- shareAccess = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; +- } +- } +- if (i == 0) { +- /* NtCreateFile requires the \???\ prefix on drive letters. Eg: \???\C:\ */ +- size_t driveroot_len = strlen("\\??\\\\") + strlen(tokens[0]) + 1; +- char *driveroot = malloc(driveroot_len); +- snprintf(driveroot, driveroot_len + 1, "\\??\\%s\\", tokens[0]); +- next_handle = win32_openat(current_handle, +- driveroot, +- pNtCreateFile, +- pRtlInitUnicodeString, +- desiredAccess, +- fileAttributes, +- createOptions, +- shareAccess); +- free(driveroot); +- } else { +- next_handle = win32_openat(current_handle, +- tokens[i], +- pNtCreateFile, +- pRtlInitUnicodeString, +- desiredAccess, +- fileAttributes, +- createOptions, +- shareAccess); +- } +- if (NULL == next_handle) { +- logg(LOGG_INFO, "traverse_to: Failed open %s\n", tokens[i]); ++ logg(LOGG_DEBUG, "traverse_to: Handle opened for '%s' directory.\n", tokens[i]); ++ } ++ ++#ifdef _WIN32 ++ if (true == want_directory_handle) { ++ path_to_open = win32_parent_path_dup(directory); ++ if (NULL == path_to_open) { ++ logg(LOGG_INFO, "traverse_to: Failed to determine parent directory for '%s'\n", directory); ++ goto done; ++ } ++ } else { ++ path_to_open = strdup(directory); ++ if (NULL == path_to_open) { ++ logg(LOGG_INFO, "traverse_to: Failed to copy path '%s'\n", directory); + goto done; + } ++ } ++ ++ if (0 != (is_directory ++ ? win32_open_existing_path( ++ path_to_open, ++ true, ++ win32_directory_anchor_access(), ++ ¤t_handle) ++ : win32_open_existing_path( ++ path_to_open, ++ false, ++ FILE_GENERIC_READ | READ_CONTROL | DELETE, ++ ¤t_handle))) { ++ logg(LOGG_INFO, "traverse_to: Failed open %s\n", path_to_open); ++ goto done; ++ } ++#endif ++ ++ status = 0; ++ *out_handle = current_handle; ++ ++done: ++#ifndef _WIN32 ++ if ((-1 == status) && (-1 != current_handle)) { ++ close(current_handle); ++ } ++#else ++ if ((-1 == status) && (NULL != current_handle)) { + CloseHandle(current_handle); +- current_handle = next_handle; +- next_handle = NULL; ++ } ++ if (NULL != path_to_open) { ++ free(path_to_open); ++ } + #endif ++ if (NULL != tokenized_directory) { ++ free(tokenized_directory); ++ } + +- logg(LOGG_DEBUG, "traverse_to: Handle opened for '%s' directory.\n", tokens[i]); ++ return status; ++} ++ ++#ifndef _WIN32 ++#define ACTION_PRIVATE_UNLINK_NAME_SIZE 96 ++ ++/** ++ * @brief Compare two stat results to determine whether they name the same file. ++ */ ++static bool action_stat_same_file(const STATBUF *first, const STATBUF *second) ++{ ++ return (first->st_dev == second->st_dev) && (first->st_ino == second->st_ino); ++} ++ ++/** ++ * @brief Check whether a stat result describes a private unlink directory. ++ */ ++static bool action_stat_is_private_unlink_dir(const STATBUF *statbuf) ++{ ++ return S_ISDIR(statbuf->st_mode) && ++ (statbuf->st_uid == geteuid()) && ++ (0 == (statbuf->st_mode & (S_IRWXG | S_IRWXO))); ++} ++ ++/** ++ * @brief Stat an opened private directory and the parent-relative name for it. ++ */ ++static int action_stat_open_private_dir(int parent_fd, const char *name, int private_fd, STATBUF *fd_stat, STATBUF *path_stat) ++{ ++ if ((parent_fd < 0) || (NULL == name) || (private_fd < 0) || (NULL == fd_stat) || (NULL == path_stat)) { ++ errno = EINVAL; ++ return -1; ++ } ++ ++ if (0 != action_fstat_nointr(private_fd, fd_stat)) { ++ return -1; ++ } ++ ++ if (0 != action_fstatat_nointr(parent_fd, name, path_stat, AT_SYMLINK_NOFOLLOW)) { ++ return -1; ++ } ++ ++ if (!action_stat_same_file(fd_stat, path_stat)) { ++ errno = EAGAIN; ++ return -1; ++ } ++ ++ return 0; ++} ++ ++/** ++ * @brief Verify that an opened private unlink directory is still safely named. ++ */ ++static int action_verify_private_unlink_dir(int parent_fd, const char *name, int private_fd, const STATBUF *created_stat) ++{ ++ STATBUF fd_stat; ++ STATBUF path_stat; ++ ++ if (NULL == created_stat) { ++ errno = EINVAL; ++ return -1; ++ } ++ ++ if (0 != action_stat_open_private_dir(parent_fd, name, private_fd, &fd_stat, &path_stat)) { ++ return -1; ++ } ++ ++ if (!action_stat_same_file(&fd_stat, created_stat) || ++ !action_stat_same_file(&path_stat, created_stat)) { ++ errno = EAGAIN; ++ return -1; ++ } ++ ++ /* ++ * mkdirat() returns no fd, so a hostile writable parent can rename the new ++ * directory before openat(). Refuse a replacement that was not created by ++ * this effective user or is accessible by group/other users. ++ */ ++ if (!action_stat_is_private_unlink_dir(&fd_stat) || ++ !action_stat_is_private_unlink_dir(&path_stat)) { ++ errno = EACCES; ++ return -1; ++ } ++ ++ return 0; ++} ++ ++/** ++ * @brief Remove a private unlink directory only if its name still matches its fd. ++ */ ++static int action_unlink_private_unlink_dir(int parent_fd, const char *name, int private_fd) ++{ ++ STATBUF fd_stat; ++ STATBUF path_stat; ++ ++ if (0 != action_stat_open_private_dir(parent_fd, name, private_fd, &fd_stat, &path_stat)) { ++ return -1; ++ } ++ ++ if (!action_stat_is_private_unlink_dir(&fd_stat) || ++ !action_stat_is_private_unlink_dir(&path_stat)) { ++ errno = EACCES; ++ return -1; + } + +- status = 0; +- *out_handle = current_handle; ++ return action_unlinkat_nointr(parent_fd, name, AT_REMOVEDIR); ++} + +-done: +-#ifndef _WIN32 +- if ((-1 == status) && (-1 != current_handle)) { +- close(current_handle); ++/** ++ * @brief Remove a just-created private unlink directory before its fd is open. ++ */ ++static int action_unlink_created_private_unlink_dir(int parent_fd, const char *name, const STATBUF *created_stat) ++{ ++ STATBUF path_stat; ++ ++ if ((parent_fd < 0) || (NULL == name) || (NULL == created_stat)) { ++ errno = EINVAL; ++ return -1; + } +-#else +- if ((-1 == status) && (NULL != current_handle)) { +- CloseHandle(current_handle); ++ ++ if (0 != action_fstatat_nointr(parent_fd, name, &path_stat, AT_SYMLINK_NOFOLLOW)) { ++ return -1; + } +-#endif +- if (NULL != tokenized_directory) { +- free(tokenized_directory); ++ ++ if (!action_stat_same_file(&path_stat, created_stat) || ++ !action_stat_is_private_unlink_dir(&path_stat)) { ++ errno = EAGAIN; ++ return -1; + } + +- return status; ++ return action_unlinkat_nointr(parent_fd, name, AT_REMOVEDIR); + } + + /** +- * @brief Rename (move) a file from Source to Destination without following symlinks. ++ * @brief Create and open a private directory under an already validated parent. + * +- * This approach mitigates the possibility that one of the directories +- * in the path has been replaced with a malicious symlink. ++ * The private directory is used to capture a source basename with renameat() ++ * before verifying and unlinking it. Holding the directory fd keeps later ++ * verification and deletion bound to the directory object even if the private ++ * directory name is renamed by another process. + * +- * @param source Source pathname. +- * @param destination Destination pathname (including file name) +- * @return 0 Rename succeeded. +- * @return -1 Rename failed. ++ * @param parent_fd Validated parent directory fd. ++ * @param[out] name Buffer that receives the private directory basename. ++ * @param name_size Size of the name buffer. ++ * @param[out] private_fd Open fd for the private directory. ++ * @return 0 Directory was created and opened. ++ * @return -1 Directory creation/open failed. + */ +-static int traverse_rename(const char *source, const char *destination) ++static int action_create_private_unlink_dir(int parent_fd, char *name, size_t name_size, int *private_fd) + { +- int status = -1; +-#ifndef _WIN32 +- cl_error_t ret; +- int source_directory_fd = -1; +- char *source_basename = NULL; +-#else +- FILE_RENAME_INFO *fileInfo = NULL; +- HANDLE source_file_handle = NULL; +- HANDLE destination_dir_handle = NULL; +- WCHAR *destFilepathW = NULL; +- int cchDestFilepath = 0; +-#endif +- +- if (NULL == source || NULL == destination) { +- logg(LOGG_INFO, "traverse_rename: Invalid arguments!\n"); +- goto done; +- } ++ unsigned int i; + +-#ifndef _WIN32 +- if (0 != traverse_to(source, true, &source_directory_fd)) { +- logg(LOGG_INFO, "traverse_rename: Failed to open file descriptor for source directory!\n"); +- goto done; +- } +-#else +- if (0 != traverse_to(source, false, &source_file_handle)) { +- logg(LOGG_INFO, "traverse_rename: Failed to open file descriptor for source file!\n"); +- goto done; +- } +- if (0 != traverse_to(destination, true, &destination_dir_handle)) { +- logg(LOGG_INFO, "traverse_rename: Failed to open file descriptor for destination directory!\n"); +- goto done; ++ if ((parent_fd < 0) || (NULL == name) || (0 == name_size) || (NULL == private_fd)) { ++ errno = EINVAL; ++ return -1; + } +-#endif + +-#ifndef _WIN32 +- ret = cli_basename(source, strlen(source), &source_basename); +- if (CL_SUCCESS != ret) { +- logg(LOGG_INFO, "traverse_rename: Failed to get basename of source path:%s\n\tError: %d\n", source, (int)ret); +- goto done; +- } ++ *private_fd = -1; ++ name[0] = '\0'; ++ ++ for (i = 0; i < 1000; i++) { ++ STATBUF created_stat; ++ int rc; ++ ++ rc = snprintf( ++ name, ++ name_size, ++ ".clamav-unlink-%ld-%u-%u", ++ (long)getpid(), ++ cli_rndnum(0xffffff), ++ i); ++ if ((rc < 0) || ((size_t)rc >= name_size)) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } + +- if (0 != renameat(source_directory_fd, source_basename, -1, destination)) { +- logg(LOGG_INFO, "traverse_rename: Failed to rename: %s\n\tto: %s\nError:%s\n", source, destination, strerror(errno)); +- goto done; +- } +-#else +- /* Convert destination filepath to a PWCHAR */ +- cchDestFilepath = MultiByteToWideChar(CP_UTF8, 0, destination, strlen(destination), NULL, 0); +- destFilepathW = calloc(cchDestFilepath * sizeof(WCHAR), 1); +- if (NULL == destFilepathW) { +- logg(LOGG_INFO, "traverse_rename: failed to allocate memory for destination basename UTF16LE string\n"); +- goto done; +- } +- if (0 == MultiByteToWideChar(CP_UTF8, 0, destination, strlen(destination), destFilepathW, cchDestFilepath)) { +- logg(LOGG_INFO, "traverse_rename: failed to allocate buffer for UTF16LE version of destination file basename.\n"); +- goto done; +- } ++ if (0 == action_mkdirat_nointr(parent_fd, name, 0700)) { ++ if (0 != action_fstatat_nointr(parent_fd, name, &created_stat, AT_SYMLINK_NOFOLLOW)) { ++ return -1; ++ } ++ if (!action_stat_is_private_unlink_dir(&created_stat)) { ++ errno = EACCES; ++ return -1; ++ } + +- fileInfo = calloc(1, sizeof(FILE_RENAME_INFO) + cchDestFilepath * sizeof(WCHAR)); +- if (NULL == fileInfo) { +- logg(LOGG_INFO, "traverse_rename: failed to allocate memory for fileInfo struct\n"); +- goto done; +- } ++ *private_fd = action_openat_directory_nointr(parent_fd, name); ++ if (*private_fd < 0) { ++ int saved_errno = errno; + +- fileInfo->ReplaceIfExists = TRUE; +- fileInfo->RootDirectory = NULL; +- memcpy(fileInfo->FileName, destFilepathW, cchDestFilepath * sizeof(WCHAR)); +- fileInfo->FileNameLength = cchDestFilepath; +- if (FALSE == SetFileInformationByHandle( +- source_file_handle, // FileHandle +- FileRenameInfo, // FileInformationClass +- fileInfo, // FileInformation +- sizeof(FILE_RENAME_INFO) + cchDestFilepath * sizeof(WCHAR))) { // Length ++ (void)action_unlink_created_private_unlink_dir(parent_fd, name, &created_stat); ++ errno = saved_errno; ++ return -1; ++ } ++ if (0 != action_verify_private_unlink_dir(parent_fd, name, *private_fd, &created_stat)) { ++ int saved_errno = errno; ++ ++ (void)action_unlink_private_unlink_dir(parent_fd, name, *private_fd); ++ close(*private_fd); ++ *private_fd = -1; ++ errno = saved_errno; ++ return -1; ++ } ++ return 0; ++ } + +- logg(LOGG_INFO, "traverse_rename: Failed to set file rename info for '%s' to '%s'.\nError: %d\n", source, destination, GetLastError()); +- goto done; ++ if (EEXIST != errno) { ++ return -1; ++ } + } +-#endif + +- status = 0; ++ errno = EEXIST; ++ return -1; ++} + +-done: ++/** ++ * @brief Restore a captured replacement that should not be unlinked. ++ * ++ * The capture directory contains a basename that did not match the scanned ++ * source after renameat(). Restore without overwriting anything that may have ++ * appeared at the original basename while the action was deciding whether to ++ * unlink the captured entry. ++ * ++ * @param target_directory_fd Validated directory fd for the original basename. ++ * @param private_directory_fd Private capture directory fd. ++ * @param target_basename Basename shared by the original and captured path. ++ * @param captured_stat stat() result for the captured basename. ++ * @param supports_noreplace_restore Whether no-replace rename is available. ++ * @return 0 Captured entry was restored and removed from the ++ * private directory. ++ * @return -1 Restore failed or cannot be done safely. ++ */ ++static int action_restore_captured_unlink_target( ++ int target_directory_fd, ++ int private_directory_fd, ++ const char *target_basename, ++ const STATBUF *captured_stat, ++ bool supports_noreplace_restore) ++{ ++ int link_errno; + +-#ifndef _WIN32 +- if (NULL != source_basename) { +- free(source_basename); ++ if ((target_directory_fd < 0) || (private_directory_fd < 0) || ++ (NULL == target_basename) || (NULL == captured_stat)) { ++ errno = EINVAL; ++ return -1; + } + +- if (-1 != source_directory_fd) { +- close(source_directory_fd); +- } +-#else +- if (NULL != fileInfo) { +- free(fileInfo); +- } +- if (NULL != destFilepathW) { +- free(destFilepathW); ++ /* ++ * linkat() restores regular files, symlinks, FIFOs, and other linkable ++ * non-directory entries without overwriting a new entry that may have ++ * appeared at the original basename. ++ */ ++ if (0 == linkat( ++ private_directory_fd, ++ target_basename, ++ target_directory_fd, ++ target_basename, ++ 0)) { ++ return action_unlinkat_nointr(private_directory_fd, target_basename, 0); + } +- if (NULL != source_file_handle) { +- CloseHandle(source_file_handle); ++ link_errno = errno; ++ ++ if (EEXIST == link_errno) { ++ errno = link_errno; ++ return -1; + } +- if (NULL != destination_dir_handle) { +- CloseHandle(destination_dir_handle); ++ ++ /* ++ * Directories cannot be hard-linked, and non-directory hard-link restores ++ * can be denied by policy such as Linux protected_hardlinks. Only use a ++ * no-replace rename fallback where the platform provides one; plain ++ * renameat() could overwrite a new entry that raced into the original ++ * basename. ++ */ ++ if (supports_noreplace_restore) { ++ return action_renameat_noreplace_nointr( ++ private_directory_fd, ++ target_basename, ++ target_directory_fd, ++ target_basename); + } +-#endif + +- return status; ++ errno = S_ISDIR(captured_stat->st_mode) ? ENOTSUP : link_errno; ++ return -1; + } ++#endif + + /** + * @brief Unlink (delete) a target file without following symlinks. +@@ -536,15 +3317,28 @@ done: + * @return 0 Unlink succeeded. + * @return -1 Unlink failed. + */ +-static int traverse_unlink(const char *target) ++#ifndef _WIN32 ++static int traverse_unlink(const char *target, const STATBUF *expected_stat) ++#else ++static int traverse_unlink( ++ const char *target, ++ HANDLE target_file_handle, ++ bool target_file_handle_can_delete) ++#endif + { + int status = -1; + cl_error_t ret; + #ifndef _WIN32 + int target_directory_fd = -1; ++ int private_directory_fd = -1; ++ char private_directory_name[ACTION_PRIVATE_UNLINK_NAME_SIZE] = {0}; ++ STATBUF current_stat; ++ STATBUF captured_stat; ++ int rc; ++ bool supports_noreplace_restore = false; + #else +- FILE_DISPOSITION_INFO fileInfo = {0}; +- HANDLE target_file_handle = NULL; ++ HANDLE delete_handle = INVALID_HANDLE_VALUE; ++ bool close_delete_handle = true; + #endif + char *target_basename = NULL; + +@@ -556,41 +3350,114 @@ static int traverse_unlink(const char *target) + #ifndef _WIN32 + /* On posix, we want a file descriptor for the directory */ + if (0 != traverse_to(target, true, &target_directory_fd)) { ++ goto done; ++ } + #else +- /* On Windows, we want a handle to the file, not the directory */ +- if (0 != traverse_to(target, false, &target_file_handle)) { +-#endif +- logg(LOGG_INFO, "traverse_unlink: Failed to open file descriptor for target directory!\n"); ++ if (target_file_handle_can_delete) { ++ delete_handle = target_file_handle; ++ close_delete_handle = false; ++ } else { ++ if (0 != win32_open_delete_handle_for_source(target, target_file_handle, &delete_handle)) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to open '%s' for delete. Error: %lu\n", target, GetLastError()); ++ goto done; ++ } ++ } ++ ++ if (0 != win32_delete_handle(delete_handle)) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to delete '%s'. Error: %lu\n", target, GetLastError()); + goto done; + } ++#endif + ++#ifndef _WIN32 + ret = cli_basename(target, strlen(target), &target_basename); + if (CL_SUCCESS != ret) { + logg(LOGG_INFO, "traverse_unlink: Failed to get basename of target path: %s\n\tError: %d\n", target, (int)ret); + goto done; + } + +-#ifndef _WIN32 +- if (0 != unlinkat(target_directory_fd, target_basename, 0)) { +- logg(LOGG_INFO, "traverse_unlink: Failed to unlink: %s\nError:%s\n", target, strerror(errno)); +- goto done; +- } +-#else +- fileInfo.DeleteFileA = TRUE; +- if (FALSE == SetFileInformationByHandle( +- target_file_handle, // FileHandle +- FileDispositionInfo, // FileInformationClass +- &fileInfo, // FileInformation +- sizeof(FILE_DISPOSITION_INFO))) { // Length ++ if (NULL == expected_stat) { ++ if (0 != action_unlinkat_nointr(target_directory_fd, target_basename, 0)) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to unlink: %s\nError:%s\n", target, strerror(errno)); ++ goto done; ++ } ++ } else { ++ if (0 != action_create_private_unlink_dir( ++ target_directory_fd, ++ private_directory_name, ++ sizeof(private_directory_name), ++ &private_directory_fd)) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to create private unlink directory for '%s': %s\n", target, strerror(errno)); ++ goto done; ++ } + +- logg(LOGG_INFO, "traverse_unlink: Failed to set file disposition to 'DELETE' for '%s'.\n", target); +- goto done; +- } +- if (FALSE == CloseHandle(target_file_handle)) { +- logg(LOGG_INFO, "traverse_unlink: Failed to set close & delete file '%s'.\n", target); +- goto done; ++ supports_noreplace_restore = action_private_dir_supports_noreplace_rename(private_directory_fd); ++ if (!supports_noreplace_restore) { ++ /* ++ * This is intentionally conservative. Regular captured entries can ++ * usually be restored with linkat(), but the basename can still ++ * race from the checked regular file to a directory before the ++ * private rename. Without an atomic no-replace directory restore, ++ * refusing the action preserves the CLAM-2959 guarantee that a ++ * failed quarantine action does not remove or hide a replacement. ++ */ ++ logg(LOGG_INFO, "traverse_unlink: Refusing to capture '%s' because no-replace restore is unavailable.\n", target); ++ errno = ENOTSUP; ++ goto done; ++ } ++ ++ do { ++ rc = action_fstatat_nointr(target_directory_fd, target_basename, ¤t_stat, AT_SYMLINK_NOFOLLOW); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ if (0 != rc) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to restat '%s' before capture: %s\n", target, strerror(errno)); ++ goto done; ++ } ++ ++ if (!S_ISREG(current_stat.st_mode) || ++ current_stat.st_dev != expected_stat->st_dev || ++ current_stat.st_ino != expected_stat->st_ino) { ++ errno = EAGAIN; ++ logg(LOGG_INFO, "traverse_unlink: Refusing to capture '%s' because the source changed after validation.\n", target); ++ goto done; ++ } ++ ++ if (0 != action_renameat_nointr(target_directory_fd, target_basename, private_directory_fd, target_basename)) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to capture '%s' before unlink: %s\n", target, strerror(errno)); ++ goto done; ++ } ++ ++ do { ++ rc = action_fstatat_nointr(private_directory_fd, target_basename, &captured_stat, AT_SYMLINK_NOFOLLOW); ++ } while ((rc < 0) && (EINTR == errno)); ++ ++ if (0 != rc) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to restat captured '%s' before unlink: %s\n", target, strerror(errno)); ++ goto done; ++ } ++ ++ if (!S_ISREG(captured_stat.st_mode) || ++ captured_stat.st_dev != expected_stat->st_dev || ++ captured_stat.st_ino != expected_stat->st_ino) { ++ if (0 != action_restore_captured_unlink_target( ++ target_directory_fd, ++ private_directory_fd, ++ target_basename, ++ &captured_stat, ++ supports_noreplace_restore)) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to restore captured replacement for '%s': %s\n", target, strerror(errno)); ++ } ++ errno = EAGAIN; ++ logg(LOGG_INFO, "traverse_unlink: Refusing to unlink '%s' because the captured source does not match the scanned file.\n", target); ++ goto done; ++ } ++ ++ if (0 != action_unlinkat_nointr(private_directory_fd, target_basename, 0)) { ++ logg(LOGG_INFO, "traverse_unlink: Failed to unlink captured source for %s\nError:%s\n", target, strerror(errno)); ++ goto done; ++ } + } +- target_file_handle = NULL; + #endif + + status = 0; +@@ -602,85 +3469,301 @@ done: + } + + #ifndef _WIN32 ++ if (-1 != private_directory_fd) { ++ if (('\0' != private_directory_name[0]) && ++ (0 != action_unlink_private_unlink_dir(target_directory_fd, private_directory_name, private_directory_fd)) && ++ (ENOENT != errno)) { ++ logg(LOGG_DEBUG, "traverse_unlink: Failed to remove private unlink directory '%s': %s\n", ++ private_directory_name, ++ strerror(errno)); ++ } ++ close(private_directory_fd); ++ } + if (-1 != target_directory_fd) { + close(target_directory_fd); + } + #else +- if (NULL != target_file_handle) { +- CloseHandle(target_file_handle); ++ if (close_delete_handle && (INVALID_HANDLE_VALUE != delete_handle)) { ++ CloseHandle(delete_handle); + } + #endif + return status; + } + +-static void action_move(const char *filename) ++static const char *action_source_display_path(const action_source_t *source) ++{ ++ return ((NULL != source) && (NULL != source->display_path)) ? source->display_path : "(null)"; ++} ++ ++static const char *action_source_action_path(const action_source_t *source) ++{ ++ return ((NULL != source) && (NULL != source->action_path)) ? source->action_path : NULL; ++} ++ ++static bool action_source_show_action_path(const action_source_t *source) ++{ ++ const char *display_path = action_source_display_path(source); ++ const char *action_path = action_source_action_path(source); ++ ++ return (NULL != action_path) && (0 != strcmp(display_path, action_path)); ++} ++ ++static void action_move(const action_source_t *source) + { +- char *nuname = NULL; +- char *real_filename = NULL; +- int fd = -1; +- int copied = 0; ++ char *nuname = NULL; ++ int fd = -1; ++ const char *filename; ++ const char *action_filename; ++ bool show_action_path; ++#ifndef _WIN32 ++ STATBUF source_stat; ++#endif ++ ++ filename = action_source_display_path(source); ++ action_filename = action_source_action_path(source); ++ show_action_path = action_source_show_action_path(source); ++ ++ if ((NULL == source) || (NULL == action_filename)) { ++ logg(LOGG_ERROR, "Can't move file '%s'\n", filename); ++ notmoved++; ++ goto done; ++ } + +- if (NULL == filename) { ++#ifndef _WIN32 ++ if (0 == action_link_source_to_dest(source, &nuname, &source_stat)) { ++ if (0 != action_validate_actarget_path()) { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't move file '%s' (real path: '%s'): quarantine destination '%s' is unavailable\n", filename, action_filename, actarget); ++ } else { ++ logg(LOGG_ERROR, "Can't move file '%s': quarantine destination '%s' is unavailable\n", filename, actarget); ++ } ++ (void)action_unlink_dest_at(nuname); ++ notmoved++; ++ goto done; ++ } ++ if (0 != traverse_unlink(action_filename, &source_stat)) { ++ int unlink_errno = errno; ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't unlink '%s' (real path: '%s') after linking into quarantine: %s\n", filename, action_filename, strerror(unlink_errno)); ++ } else { ++ logg(LOGG_ERROR, "Can't unlink '%s' after linking into quarantine: %s\n", filename, strerror(unlink_errno)); ++ } ++ if (EAGAIN != unlink_errno) { ++ (void)action_unlink_dest_at(nuname); ++ } ++ notmoved++; ++ } else if (show_action_path) { ++ logg(LOGG_INFO, "%s (real path: '%s'): moved to '%s'\n", filename, action_filename, nuname); ++ } else { ++ logg(LOGG_INFO, "%s: moved to '%s'\n", filename, nuname); ++ } + goto done; + } ++#endif + +- fd = getdest(filename, &nuname); ++ fd = getdest(action_filename, &nuname); + + #ifndef _WIN32 +- if (fd < 0 || (0 != traverse_rename(filename, nuname) && ((copied = 1)) && filecopy(filename, nuname))) { ++ if (fd < 0 || filecopy_to_fd(source, fd, &source_stat) || action_close_dest_fd(&fd)) { + #else +- if (fd < 0 || (((copied = 1)) && filecopy(filename, nuname))) { ++ if (fd < 0 || filecopy_to_fd(source, nuname, fd) || action_close_dest_fd(&fd)) { + #endif +- logg(LOGG_ERROR, "Can't move file %s to %s\n", filename, nuname); ++ if (NULL != nuname) { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't move file '%s' (real path: '%s') to '%s'\n", filename, action_filename, nuname); ++ } else { ++ logg(LOGG_ERROR, "Can't move file '%s' to '%s'\n", filename, nuname); ++ } ++ } else { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't move file '%s' (real path: '%s'): quarantine destination '%s' is unavailable\n", filename, action_filename, actarget); ++ } else { ++ logg(LOGG_ERROR, "Can't move file '%s': quarantine destination '%s' is unavailable\n", filename, actarget); ++ } ++ } + notmoved++; +- if (nuname) traverse_unlink(nuname); ++ if (nuname) { ++#ifndef _WIN32 ++ action_unlink_dest_at(nuname); ++#else ++ if (fd >= 0) { ++ close(fd); ++ fd = -1; ++ } ++ (void)win32_delete_dest_path(nuname); ++#endif ++ } + } else { +- if (copied && (0 != traverse_unlink(filename))) +- logg(LOGG_ERROR, "Can't unlink '%s' after copy: %s\n", filename, strerror(errno)); +- else +- logg(LOGG_INFO, "%s: moved to '%s'\n", filename, nuname); ++ if (0 != action_validate_actarget_path()) { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't move file '%s' (real path: '%s'): quarantine destination '%s' is unavailable\n", filename, action_filename, actarget); ++ } else { ++ logg(LOGG_ERROR, "Can't move file '%s': quarantine destination '%s' is unavailable\n", filename, actarget); ++ } ++ notmoved++; ++#ifndef _WIN32 ++ action_unlink_dest_at(nuname); ++#else ++ if (fd >= 0) { ++ close(fd); ++ fd = -1; ++ } ++ (void)win32_delete_dest_path(nuname); ++#endif ++ goto done; ++ } ++#ifndef _WIN32 ++ if (0 != traverse_unlink(action_filename, &source_stat)) { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't unlink '%s' (real path: '%s') after copy: %s\n", filename, action_filename, strerror(errno)); ++ } else { ++ logg(LOGG_ERROR, "Can't unlink '%s' after copy: %s\n", filename, strerror(errno)); ++ } ++#else ++ if (0 != traverse_unlink( ++ action_filename, ++ (HANDLE)source->handle, ++ source->handle_can_delete)) { ++ DWORD delete_error = GetLastError(); ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't unlink '%s' (real path: '%s') after copy. Error: %lu\n", filename, action_filename, delete_error); ++ } else { ++ logg(LOGG_ERROR, "Can't unlink '%s' after copy. Error: %lu\n", filename, delete_error); ++ } ++#endif ++ notmoved++; ++ } else { ++ if (show_action_path) { ++ logg(LOGG_INFO, "%s (real path: '%s'): moved to '%s'\n", filename, action_filename, nuname); ++ } else { ++ logg(LOGG_INFO, "%s: moved to '%s'\n", filename, nuname); ++ } ++ } + } + + done: +- if (NULL != real_filename) free(real_filename); + if (fd >= 0) close(fd); + if (NULL != nuname) free(nuname); + return; + } + +-static void action_copy(const char *filename) ++static void action_copy(const action_source_t *source) + { +- char *nuname; +- int fd = getdest(filename, &nuname); ++ char *nuname = NULL; ++ int fd = -1; ++ const char *filename; ++ const char *action_filename; ++ bool show_action_path; ++ ++ filename = action_source_display_path(source); ++ action_filename = action_source_action_path(source); ++ show_action_path = action_source_show_action_path(source); + +- if (fd < 0 || filecopy(filename, nuname)) { ++ if ((NULL == source) || (NULL == action_filename)) { + logg(LOGG_ERROR, "Can't copy file '%s'\n", filename); + notmoved++; +- if (nuname) traverse_unlink(nuname); +- } else ++ return; ++ } ++ ++ fd = getdest(action_filename, &nuname); ++ ++#ifndef _WIN32 ++ if (fd < 0 || filecopy_to_fd(source, fd, NULL) || action_close_dest_fd(&fd)) { ++#else ++ if (fd < 0 || filecopy_to_fd(source, nuname, fd) || action_close_dest_fd(&fd)) { ++#endif ++ if (NULL != nuname) { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't copy file '%s' (real path: '%s') to '%s'\n", filename, action_filename, nuname); ++ } else { ++ logg(LOGG_ERROR, "Can't copy file '%s' to '%s'\n", filename, nuname); ++ } ++ } else { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't copy file '%s' (real path: '%s'): quarantine destination '%s' is unavailable\n", filename, action_filename, actarget); ++ } else { ++ logg(LOGG_ERROR, "Can't copy file '%s': quarantine destination '%s' is unavailable\n", filename, actarget); ++ } ++ } ++ notmoved++; ++ if (nuname) { ++#ifndef _WIN32 ++ action_unlink_dest_at(nuname); ++#else ++ if (fd >= 0) { ++ close(fd); ++ fd = -1; ++ } ++ (void)win32_delete_dest_path(nuname); ++#endif ++ } ++ } else if (0 != action_validate_actarget_path()) { ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't copy file '%s' (real path: '%s'): quarantine destination '%s' is unavailable\n", filename, action_filename, actarget); ++ } else { ++ logg(LOGG_ERROR, "Can't copy file '%s': quarantine destination '%s' is unavailable\n", filename, actarget); ++ } ++ notmoved++; ++#ifndef _WIN32 ++ action_unlink_dest_at(nuname); ++#else ++ if (fd >= 0) { ++ close(fd); ++ fd = -1; ++ } ++ (void)win32_delete_dest_path(nuname); ++#endif ++ } else if (show_action_path) { ++ logg(LOGG_INFO, "%s (real path: '%s'): copied to '%s'\n", filename, action_filename, nuname); ++ } else { + logg(LOGG_INFO, "%s: copied to '%s'\n", filename, nuname); ++ } + + if (fd >= 0) close(fd); + if (nuname) free(nuname); + } + +-static void action_remove(const char *filename) ++static void action_remove(const action_source_t *source) + { +- char *real_filename = NULL; ++ const char *filename; ++ const char *action_filename; ++ bool show_action_path; + +- if (NULL == filename) { ++ filename = action_source_display_path(source); ++ action_filename = action_source_action_path(source); ++ show_action_path = action_source_show_action_path(source); ++ ++ if ((NULL == source) || (NULL == action_filename)) { ++ logg(LOGG_ERROR, "Can't remove file '%s'\n", filename); ++ notremoved++; + goto done; + } + +- if (0 != traverse_unlink(filename)) { +- logg(LOGG_ERROR, "Can't remove file '%s'\n", filename); ++#ifndef _WIN32 ++ if ((false == source->has_stat) || ++ !S_ISREG(source->statbuf.st_mode) || ++ (0 != traverse_unlink(action_filename, &source->statbuf))) { ++#else ++ if (0 != traverse_unlink( ++ action_filename, ++ (HANDLE)source->handle, ++ source->handle_can_delete)) { ++#endif ++ if (show_action_path) { ++ logg(LOGG_ERROR, "Can't remove file '%s' (real path: '%s')\n", filename, action_filename); ++ } else { ++ logg(LOGG_ERROR, "Can't remove file '%s'\n", filename); ++ } + notremoved++; + } else { +- logg(LOGG_INFO, "%s: Removed.\n", filename); ++ if (show_action_path) { ++ logg(LOGG_INFO, "%s (real path: '%s'): Removed.\n", filename, action_filename); ++ } else { ++ logg(LOGG_INFO, "%s: Removed.\n", filename); ++ } + } + + done: +- if (NULL != real_filename) free(real_filename); + return; + } + +@@ -701,21 +3784,100 @@ static int isdir(void) + int actsetup(const struct optstruct *opts) + { + int move = optget(opts, "move")->enabled; ++ ++ action_cleanup(); ++ if (false == action_cleanup_registered) { ++ if (0 != atexit(action_cleanup)) { ++ logg(LOGG_INFO, "action_setup: Failed to register cleanup handler.\n"); ++ return 1; ++ } ++ action_cleanup_registered = true; ++ } ++ + if (move || optget(opts, "copy")->enabled) { ++ const char *requested_actarget = optget(opts, move ? "move" : "copy")->strarg; + #ifndef _WIN32 + cl_error_t ret; ++ int actarget_parent_fd = -1; ++ char *actarget_basename = NULL; ++ char *resolved_actarget = NULL; ++ ++ ret = cli_realpath(requested_actarget, &resolved_actarget); ++ if (CL_SUCCESS != ret || NULL == resolved_actarget) { ++ logg(LOGG_INFO, "action_setup: Failed to get realpath of %s\n", requested_actarget); ++ if (NULL != resolved_actarget) { ++ free(resolved_actarget); ++ } ++ return 1; ++ } ++ actarget = resolved_actarget; ++#else ++ actarget_normalized = win32_trim_trailing_path_separators_dup(requested_actarget); ++ if (NULL == actarget_normalized) { ++ logg(LOGG_INFO, "action_setup: Failed to normalize quarantine directory path %s\n", requested_actarget); ++ return 1; ++ } ++ actarget = actarget_normalized; + #endif +- actarget = optget(opts, move ? "move" : "copy")->strarg; ++ if (!isdir()) return 1; ++ targlen = strlen(actarget); + #ifndef _WIN32 +- ret = cli_realpath((const char *)actarget, &actarget); +- if (CL_SUCCESS != ret || NULL == actarget) { +- logg(LOGG_INFO, "action_setup: Failed to get realpath of %s\n", actarget); +- return 0; ++ ret = cli_basename(actarget, strlen(actarget), &actarget_basename); ++ if ((CL_SUCCESS != ret) || ++ (0 != traverse_to(actarget, true, &actarget_parent_fd)) || ++ (-1 == (actarget_fd = action_openat_directory_nointr(actarget_parent_fd, actarget_basename)))) { ++ if (-1 != actarget_parent_fd) { ++ close(actarget_parent_fd); ++ } ++ if (NULL != actarget_basename) { ++ free(actarget_basename); ++ } ++ logg(LOGG_INFO, "action_setup: Failed to open quarantine directory handle for %s\n", actarget); ++ return 1; ++ } ++ close(actarget_parent_fd); ++ free(actarget_basename); ++ if (0 != action_setup_quarantine_lock()) { ++ action_cleanup(); ++ return 1; ++ } ++#else ++ HANDLE actarget_parent_handle = NULL; ++ char *actarget_basename = NULL; ++ cl_error_t ret = CL_EARG; ++ size_t actarget_root_len = win32_path_root_length(actarget, strlen(actarget)); ++ ++ if ((0 != actarget_root_len) && (actarget_root_len == strlen(actarget))) { ++ if (0 != win32_open_existing_path( ++ actarget, ++ true, ++ win32_directory_anchor_access(), ++ &actarget_handle)) { ++ actarget_handle = INVALID_HANDLE_VALUE; ++ } ++ } else if (0 == traverse_to(actarget, true, &actarget_parent_handle)) { ++ ret = cli_basename(actarget, strlen(actarget), &actarget_basename); ++ if ((CL_SUCCESS == ret) && ++ (0 == win32_open_existing_directory_at(actarget_parent_handle, actarget_basename, &actarget_handle))) { ++ CloseHandle(actarget_parent_handle); ++ free(actarget_basename); ++ } else { ++ CloseHandle(actarget_parent_handle); ++ if (NULL != actarget_basename) { ++ free(actarget_basename); ++ } ++ actarget_handle = INVALID_HANDLE_VALUE; ++ } ++ } else { ++ actarget_handle = INVALID_HANDLE_VALUE; ++ } ++ ++ if ((NULL == actarget_handle) || (INVALID_HANDLE_VALUE == actarget_handle)) { ++ logg(LOGG_INFO, "action_setup: Failed to open quarantine directory handle for %s\n", actarget); ++ return 1; + } + #endif +- if (!isdir()) return 1; + action = move ? action_move : action_copy; +- targlen = strlen(actarget); + } else if (optget(opts, "remove")->enabled) + action = action_remove; + return 0; +diff --git a/common/actions.h b/common/actions.h +index 2976c4abb..22cad65e9 100644 +--- a/common/actions.h ++++ b/common/actions.h +@@ -28,14 +28,80 @@ + #ifndef ACTIONS_H + #define ACTIONS_H + ++#include ++#include ++#include ++ ++#include "clamav.h" + #include "optparser.h" + ++/** ++ * @brief Opened source object used for quarantine I/O. ++ * ++ * The source owns scan_fd and any platform-specific handle state. Callers scan ++ * scan_fd, then pass the same source to the selected action before closing it. ++ */ ++typedef struct action_source { ++ char *display_path; ++ char *action_path; ++ int scan_fd; ++ STATBUF statbuf; ++ bool has_stat; ++#ifdef _WIN32 ++ void *handle; ++ bool handle_can_delete; ++#endif ++} action_source_t; ++ + /** + * @brief Callback function to perform the action requested when actsetup() was invoked. + * +- * @param filename ++ * @param source Open source object that was submitted for scanning. ++ */ ++extern void (*action)(const action_source_t *); ++ ++/** ++ * @brief Initialize an action source to an empty closed state. ++ * ++ * @param source Source object to initialize. ++ */ ++void action_source_init(action_source_t *source); ++ ++/** ++ * @brief Open a path for scan and later quarantine action. ++ * ++ * @param display_path Original path to use for scan output. ++ * @param source Source object to populate. ++ * @return cl_error_t CL_SUCCESS if the source is open. + */ +-extern void (*action)(const char *); ++cl_error_t action_source_open(const char *display_path, action_source_t *source); ++ ++/** ++ * @brief Open a resolved path for scan and later quarantine action. ++ * ++ * @param display_path Original path to use for scan output. ++ * @param open_path Path to open for scan and quarantine action. ++ * @param source Source object to populate. ++ * @return cl_error_t CL_SUCCESS if the source is open. ++ */ ++cl_error_t action_source_open_path(const char *display_path, const char *open_path, action_source_t *source); ++ ++/** ++ * @brief Duplicate an existing descriptor for later quarantine action. ++ * ++ * @param display_path Original path to use for scan output. ++ * @param fd Existing descriptor for the scan target. ++ * @param source Source object to populate. ++ * @return cl_error_t CL_SUCCESS if the source is open. ++ */ ++cl_error_t action_source_from_fd(const char *display_path, int fd, action_source_t *source); ++ ++/** ++ * @brief Close and reset an action source. ++ * ++ * @param source Source object to close. ++ */ ++void action_source_close(action_source_t *source); + + /** + * @brief Select the appropriate callback function based on the configuration options. +@@ -48,4 +114,8 @@ int actsetup(const struct optstruct *opts); + + extern unsigned int notremoved, notmoved; + ++#ifndef _WIN32 ++int action_setup_quarantine_lock_at(int directory_fd, const char *directory_path, char **lockname_out); ++#endif ++ + #endif +diff --git a/common/clamdcom.c b/common/clamdcom.c +index 54c4d8c71..6379f160d 100644 +--- a/common/clamdcom.c ++++ b/common/clamdcom.c +@@ -25,6 +25,7 @@ + + #include + #include ++#include + #include + #include + #include +@@ -172,25 +173,20 @@ done: + #ifdef HAVE_FD_PASSING + /* Issues a FILDES command and pass a FD to clamd + * Returns >0 on success, 0 soft fail, -1 hard fail */ +-int send_fdpass(int sockd, const char *filename) ++int send_fdpass_fd(int sockd, int fd) + { + struct iovec iov[1]; + struct msghdr msg; + struct cmsghdr *cmsg; + unsigned char fdbuf[CMSG_SPACE(sizeof(int))]; + char dummy[] = ""; +- int fd; + const char zFILDES[] = "zFILDES"; + +- if (filename) { +- if ((fd = open(filename, O_RDONLY)) < 0) { +- logg(LOGG_INFO, "%s: Failed to open file\n", filename); +- return 0; +- } +- } else +- fd = 0; ++ if (fd < 0) { ++ return 0; ++ } ++ + if (sendln(sockd, zFILDES, sizeof(zFILDES))) { +- close(fd); + return -1; + } + +@@ -208,61 +204,136 @@ int send_fdpass(int sockd, const char *filename) + *(int *)CMSG_DATA(cmsg) = fd; + if (sendmsg(sockd, &msg, 0) == -1) { + logg(LOGG_ERROR, "FD send failed: %s\n", strerror(errno)); +- close(fd); + return -1; + } +- close(fd); + return 1; + } ++ ++/* Issues a FILDES command and pass a FD to clamd ++ * Returns >0 on success, 0 soft fail, -1 hard fail */ ++int send_fdpass(int sockd, const char *filename) ++{ ++ int fd; ++ int ret; ++ int close_fd = 0; ++ ++ if (filename) { ++ if ((fd = open(filename, O_RDONLY)) < 0) { ++ logg(LOGG_INFO, "%s: Failed to open file\n", filename); ++ return 0; ++ } ++ close_fd = 1; ++ } else ++ fd = 0; ++ ret = send_fdpass_fd(sockd, fd); ++ if (close_fd) { ++ close(fd); ++ } ++ return ret; ++} + #endif + + /* Issues an INSTREAM command to clamd and streams the given file + * Returns >0 on success, 0 soft fail, -1 hard fail */ +-int send_stream(int sockd, const char *filename, struct optstruct *clamdopts) ++static int send_stream_fd_common(int sockd, int fd, const char *display_filename, struct optstruct *clamdopts, bool reject_over_limit) + { + uint32_t buf[BUFSIZ / sizeof(uint32_t)]; +- int fd, len; ++ int len; + unsigned long int todo = optget(clamdopts, "StreamMaxLength")->numarg; + const char zINSTREAM[] = "zINSTREAM"; ++ STATBUF sb; + +- if (filename) { +- if ((fd = safe_open(filename, O_RDONLY | O_BINARY)) < 0) { +- logg(LOGG_INFO, "%s: Failed to open file. ERROR\n", filename); +- return 0; +- } +- } else { +- /* Read stream from STDIN */ +- fd = 0; ++ if (fd < 0) { ++ return 0; ++ } ++ ++ if (reject_over_limit && ++ (0 == FSTAT(fd, &sb)) && ++ S_ISREG(sb.st_mode) && ++ (sb.st_size > 0) && ++ ((uint64_t)sb.st_size > (uint64_t)todo)) { ++ logg(LOGG_ERROR, "%s: File size exceeds StreamMaxLength; refusing to send a truncated quarantine stream. ERROR\n", ++ display_filename ? display_filename : "STDIN"); ++ return 0; + } + + if (sendln(sockd, zINSTREAM, sizeof(zINSTREAM))) { +- close(fd); + return -1; + } + ++ if (0 != fd) { ++ (void)lseek(fd, 0, SEEK_SET); ++ } ++ + while ((len = read(fd, &buf[1], sizeof(buf) - sizeof(uint32_t))) > 0) { ++ if (reject_over_limit && ((unsigned int)len > todo)) { ++ logg(LOGG_ERROR, "%s: File size exceeds StreamMaxLength; refusing to send a truncated quarantine stream. ERROR\n", ++ display_filename ? display_filename : "STDIN"); ++ return -1; ++ } + if ((unsigned int)len > todo) len = todo; + buf[0] = htonl(len); + if (sendln(sockd, (const char *)buf, len + sizeof(uint32_t))) { +- close(fd); + return -1; + } + todo -= len; + if (!todo) { +- len = 0; ++ if (reject_over_limit) { ++ len = read(fd, &buf[1], 1); ++ if (len > 0) { ++ logg(LOGG_ERROR, "%s: File size exceeds StreamMaxLength; refusing to send a truncated quarantine stream. ERROR\n", ++ display_filename ? display_filename : "STDIN"); ++ return -1; ++ } ++ } else { ++ len = 0; ++ } + break; + } + } +- close(fd); + if (len) { +- logg(LOGG_ERROR, "Failed to read from %s.\n", filename ? filename : "STDIN"); +- return 0; ++ logg(LOGG_ERROR, "Failed to read from %s.\n", display_filename ? display_filename : "STDIN"); ++ return reject_over_limit ? -1 : 0; + } + *buf = 0; + sendln(sockd, (const char *)buf, 4); + return 1; + } + ++int send_stream_fd(int sockd, int fd, const char *display_filename, struct optstruct *clamdopts) ++{ ++ return send_stream_fd_common(sockd, fd, display_filename, clamdopts, false); ++} ++ ++int send_stream_fd_action(int sockd, int fd, const char *display_filename, struct optstruct *clamdopts) ++{ ++ return send_stream_fd_common(sockd, fd, display_filename, clamdopts, true); ++} ++ ++/* Issues an INSTREAM command to clamd and streams the given file ++ * Returns >0 on success, 0 soft fail, -1 hard fail */ ++int send_stream(int sockd, const char *filename, struct optstruct *clamdopts) ++{ ++ int fd; ++ int ret; ++ ++ if (filename) { ++ if ((fd = safe_open(filename, O_RDONLY | O_BINARY)) < 0) { ++ logg(LOGG_INFO, "%s: Failed to open file. ERROR\n", filename); ++ return 0; ++ } ++ } else { ++ /* Read stream from STDIN */ ++ fd = 0; ++ } ++ ++ ret = send_stream_fd(sockd, fd, filename, clamdopts); ++ if (0 != fd) { ++ close(fd); ++ } ++ return ret; ++} ++ + /* Connects to clamd + * Returns a FD or -1 on error */ + int dconnect(struct optstruct *clamdopts) +@@ -334,18 +405,13 @@ int dconnect(struct optstruct *clamdopts) + * This is used only in non IDSESSION mode + * Returns the number of infected files or -1 on error + * NOTE: filename may be NULL for STREAM scantype. */ +-int dsresult(int sockd, int scantype, const char *filename, int *printok, int *errors, struct optstruct *clamdopts) ++int dsresult(int sockd, int scantype, const char *filename, const action_source_t *action_source, bool apply_action, int *printok, int *errors, struct optstruct *clamdopts) + { + int infected = 0, len = 0, beenthere = 0; + char *bol, *eol; + struct RCVLN rcv; + STATBUF sb; +- +- if (filename) { +- if (1 == chkpath(filename, clamdopts)) { +- goto done; +- } +- } ++ const char *display_filename = (NULL != action_source) ? action_source->display_path : filename; + + recvlninit(&rcv, sockd); + +@@ -375,12 +441,12 @@ int dsresult(int sockd, int scantype, const char *filename, int *printok, int *e + + case STREAM: + /* NULL filename safe in send_stream() */ +- len = send_stream(sockd, filename, clamdopts); ++ len = (NULL != action_source) ? send_stream_fd_action(sockd, action_source->scan_fd, display_filename, clamdopts) : send_stream(sockd, filename, clamdopts); + break; + #ifdef HAVE_FD_PASSING + case FILDES: + /* NULL filename safe in send_fdpass() */ +- len = send_fdpass(sockd, filename); ++ len = (NULL != action_source) ? send_fdpass_fd(sockd, action_source->scan_fd) : send_fdpass(sockd, filename); + break; + #endif + } +@@ -434,15 +500,14 @@ int dsresult(int sockd, int scantype, const char *filename, int *printok, int *e + last_filename[PATH_MAX] = '\0'; + } + } +- if (filename) { ++ if (display_filename) { + if (scantype >= STREAM) { +- logg(LOGG_INFO, "%s%s FOUND\n", filename, colon); +- if (action) action(filename); ++ logg(LOGG_INFO, "%s%s FOUND\n", display_filename, colon); ++ if (apply_action && action && (NULL != action_source)) action(action_source); + } else { + logg(LOGG_INFO, "%s FOUND\n", bol); + *colon = '\0'; +- if (action) +- action(bol); ++ if (apply_action && action && (NULL != action_source)) action(action_source); + } + } + } else if (!memcmp(eol - 7, " ERROR", 6)) { +@@ -450,9 +515,9 @@ int dsresult(int sockd, int scantype, const char *filename, int *printok, int *e + (*errors)++; + if (printok) + *printok = 0; +- if (filename) { ++ if (display_filename) { + if (scantype >= STREAM) +- logg(LOGG_INFO, "%s%s\n", filename, colon); ++ logg(LOGG_INFO, "%s%s\n", display_filename, colon); + else + logg(LOGG_INFO, "%s\n", bol); + } +diff --git a/common/clamdcom.h b/common/clamdcom.h +index 34f13b01e..2cbdce50f 100644 +--- a/common/clamdcom.h ++++ b/common/clamdcom.h +@@ -26,12 +26,16 @@ + #include "clamav-config.h" + #endif + ++#include ++ + #if HAVE_SYS_PARAM_H + #include + #endif + + #include "misc.h" + ++struct action_source; ++ + enum { + CONT, + MULTI, +@@ -59,9 +63,12 @@ int recvln(struct RCVLN *s, char **rbol, char **reol); + + int chkpath(const char *path, struct optstruct *clamdopts); + #ifdef HAVE_FD_PASSING ++int send_fdpass_fd(int sockd, int fd); + int send_fdpass(int sockd, const char *filename); + #endif ++int send_stream_fd(int sockd, int fd, const char *display_filename, struct optstruct *clamdopts); ++int send_stream_fd_action(int sockd, int fd, const char *display_filename, struct optstruct *clamdopts); + int send_stream(int sockd, const char *filename, struct optstruct *clamdopts); + int dconnect(struct optstruct *clamdopts); +-int dsresult(int sockd, int scantype, const char *filename, int *printok, int *errors, struct optstruct *clamdopts); ++int dsresult(int sockd, int scantype, const char *filename, const struct action_source *action_source, bool apply_action, int *printok, int *errors, struct optstruct *clamdopts); + #endif +diff --git a/common/scanmem.c b/common/scanmem.c +index a211d8e22..f6763d454 100644 +--- a/common/scanmem.c ++++ b/common/scanmem.c +@@ -38,22 +38,25 @@ typedef int (*proc_callback)(PROCESSENTRY32 ProcStruct, MODULEENTRY32 me32, void + int sock; + struct optstruct *clamdopts; + +-static inline int lookup_cache(filelist_t **list, const char *filename) ++static inline bool lookup_cache(filelist_t **list, const char *filename, cl_error_t *result) + { + filelist_t *current = *list; + while (current) { + /* Cache hit */ + if (!_stricmp(filename, current->filename)) { +- return current->res; ++ if (NULL != result) { ++ *result = current->res; ++ } ++ return true; + } + current = current->next; + } + +- return -1; ++ return false; + } + + static inline void insert_cache(filelist_t **list, const char *filename, +- int res) ++ cl_error_t res) + { + filelist_t *current = *list, *prev = NULL; + +@@ -89,6 +92,47 @@ static inline void free_cache(filelist_t **list) + } while (current); + } + ++/** ++ * @brief Resolve a memory module path using the same sharing policy as action ++ * source opens. ++ * ++ * @param path Path to resolve. ++ * @param resolved_path Resolved path on success. Caller must free it. ++ * @return cl_error_t CL_SUCCESS if the path was resolved. ++ */ ++static cl_error_t scanmem_resolve_action_path(const char *path, char **resolved_path) ++{ ++ HANDLE handle = INVALID_HANDLE_VALUE; ++ cl_error_t status = CL_EARG; ++ ++ if ((NULL == path) || (NULL == resolved_path)) { ++ return CL_EARG; ++ } ++ ++ *resolved_path = NULL; ++ ++ handle = CreateFileA( ++ path, ++ FILE_GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, ++ NULL, ++ OPEN_EXISTING, ++ FILE_ATTRIBUTE_NORMAL, ++ NULL); ++ if (INVALID_HANDLE_VALUE == handle) { ++ status = CL_EOPEN; ++ goto done; ++ } ++ ++ status = cli_get_filepath_from_handle(handle, resolved_path); ++ ++done: ++ if (INVALID_HANDLE_VALUE != handle) { ++ CloseHandle(handle); ++ } ++ return status; ++} ++ + static inline char *wc2mb(const wchar_t *wc, DWORD flags) + { + BOOL invalid = FALSE; +@@ -529,22 +573,87 @@ int dump_pe(const char *filename, PROCESSENTRY32 ProcStruct, + return ret; + } + +-int scanfile(const char *filename, scanmem_data *scan_data, struct mem_info *info) ++/* ++ * filename is the display name for scan output. scan_open_path is the path to ++ * submit or open for scanning. action_target is the original on-disk module to ++ * quarantine when scanning a temporary dump of a packed module finds the ++ * infection. action_open_path is an optional resolved path to open for ++ * quarantine action; when action_open_path_required is true, action setup fails ++ * closed instead of opening action_target by its unresolved name. ++ */ ++cl_error_t scanfile( ++ const char *filename, ++ const char *scan_open_path, ++ const char *action_target, ++ const char *action_open_path, ++ bool action_open_path_required, ++ scanmem_data *scan_data, ++ struct mem_info *info, ++ action_source_t *infected_action_source) + { +- int fd; ++ int fd = -1; + int scantype; +- int ret = CL_CLEAN; ++ cl_error_t status = CL_CLEAN; ++ int infected = 0; ++ const char *scan_path = (NULL != scan_open_path) ? scan_open_path : filename; ++ const char *action_display_filename = (NULL != action_target) ? action_target : filename; ++ bool action_target_is_scan_target = ++ ((NULL == action_target) || (0 == strcmp(action_target, filename))) && ++ ((NULL == action_open_path) || (0 == strcmp(action_open_path, scan_path))); ++ action_source_t action_source; ++ bool have_action_source = false; ++ bool action_source_open_failed = false; ++ bool scan_uses_action_source = false; ++ cl_error_t action_source_status = CL_SUCCESS; + const char *virname = NULL; ++ int scan_errors_before = 0; ++ int scan_result = 0; ++ ++ action_source_init(&action_source); + + logg(LOGG_DEBUG, "Scanning %s\n", filename); + +- if ((fd = safe_open(filename, O_RDONLY | O_BINARY)) == -1) { +- logg(LOGG_WARNING, "Can't open file %s, %s\n", filename, strerror(errno)); +- return -1; ++ if (action) { ++ if (NULL != action_open_path) { ++ action_source_status = action_source_open_path( ++ action_display_filename, ++ action_open_path, ++ &action_source); ++ } else if (action_open_path_required) { ++ action_source_status = CL_EOPEN; ++ } else { ++ action_source_status = action_source_open(action_display_filename, &action_source); ++ } ++ if (CL_SUCCESS != action_source_status) { ++ logg(LOGG_WARNING, "Can't open file %s for safe quarantine action: %s\n", action_display_filename, cl_strerror(action_source_status)); ++ action_source_open_failed = true; ++ } else { ++ have_action_source = true; ++ } ++ } ++ ++ if (have_action_source && action_target_is_scan_target) { ++ fd = action_source.scan_fd; ++ scan_uses_action_source = true; ++ } else if ((fd = safe_open(scan_path, O_RDONLY | O_BINARY)) == -1) { ++ logg(LOGG_WARNING, "Can't open file %s, %s\n", scan_path, strerror(errno)); ++ if (have_action_source) { ++ action_source_close(&action_source); ++ } ++ return CL_EOPEN; + } + + if (info->d) { // clamdscan +- if (optget(info->opts, "stream")->enabled) ++ if (scan_uses_action_source) { ++#ifdef HAVE_FD_PASSING ++ if (optget(clamdopts, "LocalSocket")->enabled) { ++ scantype = FILDES; ++ } else ++#endif ++ { ++ scantype = STREAM; ++ } ++ } else if (optget(info->opts, "stream")->enabled) + scantype = STREAM; + else if (optget(info->opts, "multiscan")->enabled) + scantype = MULTI; +@@ -555,24 +664,66 @@ int scanfile(const char *filename, scanmem_data *scan_data, struct mem_info *inf + + if ((sock = dconnect(clamdopts)) < 0) { + info->errors++; +- return -1; ++ if (!scan_uses_action_source) { ++ close(fd); ++ } ++ if (have_action_source) { ++ action_source_close(&action_source); ++ } ++ return CL_EOPEN; + } +- if (dsresult(sock, scantype, filename, NULL, &info->errors, clamdopts) > 0) { ++ scan_errors_before = info->errors; ++ scan_result = dsresult(sock, scantype, scan_path, scan_uses_action_source ? &action_source : NULL, false, NULL, &info->errors, clamdopts); ++ if (scan_result > 0) { + info->ifiles++; +- ret = CL_VIRUS; ++ status = CL_VIRUS; ++ infected = 1; ++ } else if ((scan_result < 0) || (info->errors > scan_errors_before)) { ++ status = CL_EOPEN; + } + } else { // clamscan +- ret = cl_scandesc(fd, filename, &virname, &info->blocks, info->engine, info->options); +- if (ret == CL_VIRUS) { ++ status = cl_scandesc(fd, filename, &virname, &info->blocks, info->engine, info->options); ++ if (status == CL_VIRUS) { + logg(LOGG_INFO, "%s: %s FOUND\n", filename, virname); + info->ifiles++; ++ infected = 1; + } else if (scan_data->printclean) { + logg(LOGG_INFO, "%s: OK \n", filename); + } + } + +- close(fd); +- return ret; ++ if (!scan_uses_action_source) { ++ close(fd); ++ } ++ fd = -1; ++ ++ if (infected && action) { ++ if (have_action_source) { ++ if (NULL != infected_action_source) { ++ *infected_action_source = action_source; ++ action_source_init(&action_source); ++ have_action_source = false; ++ } else { ++ action(&action_source); ++ } ++ } else if (action_source_open_failed) { ++ if (optget(info->opts, "remove")->enabled) { ++ notremoved++; ++ } else { ++ notmoved++; ++ } ++ logg(LOGG_ERROR, "Can't apply quarantine action for scan target '%s': safe source '%s' is unavailable: %s\n", ++ filename, ++ action_display_filename, ++ cl_strerror(action_source_status)); ++ } ++ } ++ ++ if (have_action_source) { ++ action_source_close(&action_source); ++ } ++ ++ return status; + } + + int scanmem_cb(PROCESSENTRY32 ProcStruct, MODULEENTRY32 me32, void *data, struct mem_info *info) +@@ -582,10 +733,20 @@ int scanmem_cb(PROCESSENTRY32 ProcStruct, MODULEENTRY32 me32, void *data, struct + int isprocess = 0; + char modulename[MAX_PATH] = ""; + char expandmodule[MAX_PATH] = ""; ++ char *resolved_modulename = NULL; ++ const char *module_scan_path = modulename; ++ const char *module_action_open_path = NULL; ++ action_source_t deferred_action_source; ++ cl_error_t cached_result = CL_CLEAN; ++ cl_error_t resolve_status = CL_CLEAN; ++ bool cache_hit = false; ++ bool module_filter_enabled = false; ++ bool module_excluded = false; + + if (!scan_data) + return 0; + scan_data->res = CL_CLEAN; ++ action_source_init(&deferred_action_source); + + modulename[0] = 0; + /* Special case, btw why I get \SystemRoot\ in process szExePath? +@@ -607,11 +768,14 @@ int scanmem_cb(PROCESSENTRY32 ProcStruct, MODULEENTRY32 me32, void *data, struct + modulename[MAX_PATH - 1] = 0; + } + +- scan_data->res = lookup_cache(&scan_data->files, modulename); ++ cache_hit = lookup_cache(&scan_data->files, modulename, &cached_result); ++ if (cache_hit) { ++ scan_data->res = cached_result; ++ } + isprocess = !_stricmp(ProcStruct.szExeFile, modulename) || + !_stricmp(ProcStruct.szExeFile, me32.szModule); + +- if (scan_data->res == -1) { ++ if (!cache_hit) { + if (isprocess) + scan_data->processes++; + else +@@ -621,20 +785,65 @@ int scanmem_cb(PROCESSENTRY32 ProcStruct, MODULEENTRY32 me32, void *data, struct + + /* check for module exclusion */ + scan_data->res = CL_CLEAN; +- if (!(scan_data->exclude && chkpath(modulename, clamdopts))) +- scan_data->res = scanfile(modulename, scan_data, info); ++ module_filter_enabled = info->d || scan_data->exclude; ++ module_excluded = module_filter_enabled && chkpath(modulename, clamdopts); ++ ++ if (action && !module_excluded) { ++ resolve_status = scanmem_resolve_action_path(modulename, &resolved_modulename); ++ if ((CL_SUCCESS == resolve_status) && (NULL != resolved_modulename)) { ++ module_scan_path = resolved_modulename; ++ module_action_open_path = resolved_modulename; ++ if (module_filter_enabled) { ++ module_excluded = chkpath(resolved_modulename, clamdopts); ++ } ++ } else { ++ logg(LOGG_WARNING, "Can't resolve module path %s for safe quarantine action: %s\n", ++ modulename, ++ cl_strerror(resolve_status)); ++ } ++ } + +- if ((scan_data->res != CL_VIRUS) && is_packed(modulename)) { ++ if (!module_excluded) ++ scan_data->res = scanfile( ++ modulename, ++ module_scan_path, ++ modulename, ++ module_action_open_path, ++ NULL != action, ++ scan_data, ++ info, ++ &deferred_action_source); ++ ++ if (!module_excluded && (CL_CLEAN == scan_data->res) && is_packed(module_scan_path)) { + char *dumped = cli_gentemp(NULL); + int fd = -1; + if ((fd = dump_pe(dumped, ProcStruct, me32)) > 0) { + close(fd); +- scan_data->res = scanfile(dumped, scan_data, info); ++ scan_data->res = scanfile( ++ dumped, ++ dumped, ++ modulename, ++ module_action_open_path, ++ NULL != action, ++ scan_data, ++ info, ++ &deferred_action_source); + DeleteFileA(dumped); + } + free(dumped); + } +- insert_cache(&scan_data->files, modulename, scan_data->res); ++ /* ++ * Keep caching clean results. Do not cache infected module results ++ * while a quarantine action is enabled: move/remove may need to retry ++ * after kill/unload or after an action failure, and action() does not ++ * report whether a copy/move/remove succeeded. This can duplicate ++ * --copy output for a module mapped into multiple processes, but it ++ * avoids suppressing later move/remove opportunities. ++ */ ++ if ((CL_CLEAN == scan_data->res) || ++ ((CL_VIRUS == scan_data->res) && (NULL == action))) { ++ insert_cache(&scan_data->files, modulename, scan_data->res); ++ } + } + + if (scan_data->res == CL_VIRUS) { +@@ -645,12 +854,22 @@ int scanmem_cb(PROCESSENTRY32 ProcStruct, MODULEENTRY32 me32, void *data, struct + logg(LOGG_INFO, "Unloading module %s from %s\n", me32.szModule, modulename); + if ((rc = unload_module(ProcStruct.th32ProcessID, me32.hModule)) == -1) + /* CreateProcessThread() is not implemented */ +- return 0; ++ goto done; ++ } ++ ++ if (action && !cache_hit) { ++ if (-1 != deferred_action_source.scan_fd) { ++ action(&deferred_action_source); ++ } + } ++ } + +- if (action) +- action(modulename); +- return rc; ++done: ++ if (-1 != deferred_action_source.scan_fd) { ++ action_source_close(&deferred_action_source); ++ } ++ if (NULL != resolved_modulename) { ++ free(resolved_modulename); + } + return rc; + } +diff --git a/common/scanmem.h b/common/scanmem.h +index da2d6d078..cf475b7dc 100644 +--- a/common/scanmem.h ++++ b/common/scanmem.h +@@ -22,6 +22,8 @@ + #ifndef __SCANMEM_H + #define __SCANMEM_H + ++#include ++ + #ifndef TH32CS_SNAPMODULE32 + #define TH32CS_SNAPMODULE32 0x00000010 + #endif +@@ -33,7 +35,7 @@ int scanmem(struct mem_info *info); + /* cache helpers */ + typedef struct _filelist_t { + char filename[MAX_PATH]; +- int res; ++ cl_error_t res; + struct _filelist_t *next; + } filelist_t; + +@@ -48,7 +50,7 @@ typedef struct _cb_data_t { + typedef struct _scanmem_data_t { + filelist_t *files; + int printclean, kill, unload, exclude; +- int res; ++ cl_error_t res; + uint32_t processes, modules; + + } scanmem_data; +diff --git a/docs/man/clamd.8.in b/docs/man/clamd.8.in +index ff037c5e4..416fc8c7e 100644 +--- a/docs/man/clamd.8.in ++++ b/docs/man/clamd.8.in +@@ -35,10 +35,12 @@ Scan a file or a directory (recursively) with archive support enabled (if not di + Scan file or directory (recursively) with archive support enabled and don't stop the scanning when a virus is found. + .TP + \fBMULTISCAN file/directory\fR +-Scan file in a standard way or scan directory (recursively) using multiple threads (to make the scanning faster on SMP machines). ++Scan a directory recursively using multiple threads (to make the scanning faster on SMP machines). If the target is not a directory, clamd falls back to non-multiscan scanning behavior. + .TP + \fBALLMATCHSCAN file/directory\fR +-ALLMATCHSCAN works just like SCAN except that it sets a mode where scanning continues after finding a match within a file. ++It is mandatory to prefix this command with \fBn\fR or \fBz\fR. ++ ++ALLMATCHSCAN works just like SCAN except that it sets a mode where scanning continues after finding a match within a file. This command may be disabled with \fBAllowAllMatchScan\fR in clamd.conf. If disabled, clamd replies with an ALLMATCHSCAN error. + .TP + \fBINSTREAM\fR + It is mandatory to prefix this command with \fBn\fR or \fBz\fR. +@@ -56,13 +58,12 @@ Alternatively the file descriptor may be sent in the same packet, including the + \fBSTATS\fR + It is mandatory to newline terminate this command, or prefix with \fBn\fR or \fBz\fR, it is recommended to only use the \fBz\fR prefix. + +-Replies with statistics about the scan queue, contents of scan queue, and memory +-usage. The exact reply format is subject to change in future releases. ++Replies with statistics about the scan queue, contents of scan queue, and memory usage. The exact reply format is subject to change in future releases. + .TP + \fBIDSESSION, END\fR + It is mandatory to prefix this command with \fBn\fR or \fBz\fR, and all commands inside IDSESSION must be prefixed. + +-Start/end a clamd session. Within a session multiple SCAN, INSTREAM, FILDES, VERSION, STATS commands can be sent on the same socket without opening new connections. Replies from clamd will be in the form ': ' where is the request number (in ascii, starting from 1) and is the usual clamd reply. ++Start/end a clamd session. Within a session multiple SCAN, INSTREAM, FILDES, VERSION, VERSIONCOMMANDS, PING, and STATS commands can be sent on the same socket without opening new connections. CONTSCAN, MULTISCAN, ALLMATCHSCAN, RELOAD, and SHUTDOWN are not accepted inside an IDSESSION. Replies from clamd will be in the form ': ' where is the request number (in ascii, starting from 1) and is the usual clamd reply. + The reply lines have same delimiter as the corresponding command had. + Clamd will process the commands asynchronously, and reply as soon as it has finished processing. + +@@ -75,19 +76,16 @@ If clamd detects that a client has deadlocked, it will close the connection. No + It is mandatory to prefix this command with either \fBn\fR or \fBz\fR. + It is recommended to use \fBnVERSIONCOMMANDS\fR. + +-Print program and database versions, followed by "| COMMANDS:" and a +-space-delimited list of supported commands. +-Clamd <0.95 will recognize this as the VERSION command, and reply only with +-their version, without the commands list. ++Print program and database versions, followed by "| COMMANDS:" and a space-delimited list of supported commands. ++Clamd <0.95 will recognize this as the VERSION command, and reply only with their version, without the commands list. + +-This command can be used as an easy way to check for IDSESSION support for +-example. ++This command can be used as an easy way to check for IDSESSION support for example. + .LP + .TP +-\fBDEPRECATED COMMANDS\fR ++\fBREMOVED COMMANDS\fR + .TP + \fBSTREAM\fR +-Scan stream \- on this command clamd will return "PORT number" you should connect to and send data to scan. (\fBDEPRECATED\fR, use INSTREAM instead) ++The legacy STREAM command is no longer supported. Use INSTREAM instead. + .LP + .TP + \fBNOT SUPPORTED COMMANDS\fR +diff --git a/docs/man/clamdscan.1.in b/docs/man/clamdscan.1.in +index 01de8268d..647fc2d4d 100644 +--- a/docs/man/clamdscan.1.in ++++ b/docs/man/clamdscan.1.in +@@ -43,7 +43,7 @@ Ping clamd up to [A] times at optional interval [I] until it responds. + Wait up to 30 seconds for clamd to start. Optionally use alongside ping to set attempts [A] and interval [I] to check clamd. + .TP + \fB\-m, \-\-multiscan\fR +-In the multiscan mode clamd will attempt to scan the directory contents in parallel using available threads. This option is especially useful on multiprocessor and multi-core systems. If you pass more than one file or directory in the command line, they are put in a queue and sent to clamd individually. This means, that single files are always scanned by a single thread. Similarly, clamdscan will wait for clamd to finish a directory scan (performed in multiscan mode) before sending request to scan another directory. This option can be combined with \-\-fdpass (see below). ++Use multiscan mode. For local directory scans where the daemon opens paths directly, clamd attempts to scan directory contents in parallel using available threads. Single files are not affected. When this option is combined with \-\-stream, with \-\-fdpass on a local socket, or with a quarantine action, clamdscan walks the requested paths itself and submits individual file scans while still allowing clamd to process multiple files in parallel. + .TP + \fB\-z, \-\-allmatch\fR + After a match, continue scanning within the file for additional matches. +@@ -63,18 +63,51 @@ Do not display summary at the end of scanning. + \fB\-\-reload\fR + Request clamd to reload virus database. + .TP +-\fB\-\-allmatch\fR +-Continue scanning within file after finding a match. +-.TP + \fB\-i, \-\-infected\fR + Only print infected files + .TP + \fB\-\-fdpass\fR +-Open the file and pass the opened file descriptor to clamd. This is useful if clamd is running as a different user or if clamd sees a different file system tree. This is faster than streaming the file to clamd. +-Only available if connected to clamd via local(unix) socket. ++Open the file and pass the opened file descriptor to clamd. This is useful if clamd is running as a different user or if clamd sees a different filesystem tree. This is faster than streaming the file to clamd. ++Only available if connected to clamd via Unix/local socket. + .TP + \fB\-\-stream\fR +-Forces file streaming to clamd. This is generally not needed as clamdscan detects automatically if streaming is required. This option only exists for debugging and testing purposes, in all other cases \-\-fdpass is preferred. ++Force clamdscan to send file contents to clamd instead of asking clamd to open file paths. This is generally not needed because clamdscan detects automatically if sending file contents is required. This option mostly exists for debugging and testing. In other local-socket cases, \-\-fdpass is usually preferred. ++ ++.SH "OPTION INTERACTIONS" ++.LP ++clamdscan behavior depends first on the socket type, and then on command-line options. ++.TP ++\fBUnix/local socket\fR ++By default, clamdscan asks the local daemon to open the paths you provide. The daemon must be able to see those paths and have permission to read them. ++.RS ++.IP \(bu 2 ++With \-\-fdpass, clamdscan opens each file itself and passes the open file to the daemon. This is useful when the daemon runs as a different user or sees a different filesystem view. This option only works with a Unix/local socket and fd-passing support. ++.IP \(bu 2 ++With \-\-stream, clamdscan sends file contents to the daemon instead of asking the daemon to open paths. ++.IP \(bu 2 ++With \-\-multiscan, directory scans can use multiple daemon worker threads. If \-\-stream, \-\-fdpass, or a quarantine action is also used, clamdscan walks the requested paths itself and submits individual file scans while still allowing the daemon to process multiple files in parallel. ++.IP \(bu 2 ++\-\-allmatch is only effective when clamdscan is asking the daemon to open paths directly and is not using \-\-multiscan, \-\-stream, or \-\-fdpass. ++.RE ++.TP ++\fBTCP socket\fR ++When the daemon is remote, clamdscan sends file contents instead of local paths because client paths may not exist on the daemon host. When the daemon is local and clamdscan can determine that it is local, clamdscan may ask the daemon to open local paths directly. ++.RS ++.IP \(bu 2 ++\-\-stream forces clamdscan to send file contents, even when the daemon is local. ++.IP \(bu 2 ++\-\-fdpass is not available over TCP. Use either \-\-stream or \-\-fdpass for a scan, based on the socket type and how the daemon can access the files. ++.IP \(bu 2 ++With \-\-multiscan, local directory scans may use multiple daemon worker threads. Remote scans, \-\-stream scans, and scans with quarantine actions are handled as individual file scans, while still allowing the daemon to process multiple files in parallel. ++.IP \(bu 2 ++\-\-allmatch is only effective when clamdscan is asking a local daemon to open paths directly and is not using \-\-multiscan or \-\-stream. ++.RE ++.TP ++\fBOption guidance\fR ++\-\-allmatch is not useful with \-\-multiscan; use it only for scans where clamdscan is asking a local daemon to open paths directly. For quarantine, choose one action for a scan: \-\-move, \-\-copy, or \-\-remove. ++.TP ++\fBQuarantine actions with multiscan\fR ++Quarantine actions such as \-\-move, \-\-copy, and \-\-remove are performed by clamdscan, not by clamd. For directory scans with quarantine actions enabled, clamdscan resolves the action path for each file before requesting the scan. When \-\-multiscan is also enabled, clamdscan submits individual file scans so it can preserve the scan result to action-path association while still allowing clamd to process multiple files in parallel. + + .SH "EXAMPLES" + .LP +diff --git a/libclamav/libclamav.map b/libclamav/libclamav.map +index f8cf8a299..93abb14a5 100644 +--- a/libclamav/libclamav.map ++++ b/libclamav/libclamav.map +@@ -303,6 +303,7 @@ CLAMAV_PRIVATE { + cli_magic_scan_buff; + cli_checklimits; + cli_matchmeta; ++ cli_newfilepath; + + __cli_strcasestr; + __cli_strndup; +diff --git a/libclamav/others_common.c b/libclamav/others_common.c +index 60d0179d2..750b8bda7 100644 +--- a/libclamav/others_common.c ++++ b/libclamav/others_common.c +@@ -1554,10 +1554,6 @@ cl_error_t cli_realpath(const char *file_name, char **real_filename) + #ifdef _WIN32 + HANDLE hFile = INVALID_HANDLE_VALUE; + wchar_t *wpath = NULL; +- WIN32_FILE_ATTRIBUTE_DATA attrs; +- +-#elif C_DARWIN +- int fd = -1; + #endif + + cli_dbgmsg("Checking realpath of %s\n", file_name); +@@ -1590,34 +1586,18 @@ cl_error_t cli_realpath(const char *file_name, char **real_filename) + + status = cli_get_filepath_from_handle(hFile, &real_file_path); + +-#elif C_DARWIN +- +- /* Using the filepath from filedesc method on macOS because +- realpath will fail to check the realpath of a symbolic link if +- the link doesn't point to anything. +- Plus, we probably don't wan tot follow the link in this case anyways, +- so this will check the realpath of the link, and not of the thing the +- link points to. */ +- fd = open(file_name, O_RDONLY | O_SYMLINK); +- if (fd == -1) { +- char err[128]; +- cli_strerror(errno, err, sizeof(err)); +- if (errno == EACCES) { +- status = CL_EACCES; +- } else { +- status = CL_EOPEN; +- } +- cli_dbgmsg("Can't open file %s: %s\n", file_name, err); +- goto done; +- } +- +- status = cli_get_filepath_from_filedesc(fd, &real_file_path); +- + #else + ++ /* ++ * On POSIX, cli_realpath() is used for canonical resolved paths such as ++ * quarantine action_path values and the quarantine destination root. ++ * Do not use the old macOS O_SYMLINK/F_GETPATH approach here: that returns ++ * the symlink's own path, not the resolved target path, which breaks the ++ * dual-path scan/quarantine design. ++ */ + real_file_path = realpath(file_name, NULL); + if (NULL == real_file_path) { +- status = CL_EMEM; ++ status = (errno == EACCES) ? CL_EACCES : CL_EOPEN; + goto done; + } + +@@ -1636,10 +1616,6 @@ done: + if (NULL != wpath) { + free(wpath); + } +-#elif C_DARWIN +- if (fd != -1) { +- close(fd); +- } + #endif + + return status; +diff --git a/unit_tests/check_clamav.c b/unit_tests/check_clamav.c +index 1d55f1763..9c1c95a1d 100644 +--- a/unit_tests/check_clamav.c ++++ b/unit_tests/check_clamav.c +@@ -10,7 +10,11 @@ + #include + #include + #include ++#include + #include ++#if HAVE_UNISTD_H ++#include ++#endif + #ifdef HAVE_SYS_MMAN_H + #include + #endif +@@ -27,6 +31,7 @@ + #include "dsig.h" + #include "fpu.h" + #include "entconv.h" ++#include "actions.h" + + #include "checks.h" + +@@ -528,6 +533,157 @@ START_TEST(test_cl_strerror) + } + END_TEST + ++#ifndef _WIN32 ++START_TEST(test_action_setup_quarantine_lock_uses_validated_directory_handle) ++{ ++ char *parent_dir = NULL; ++ char *validated_path = NULL; ++ char *validated_saved = NULL; ++ char *replacement_lock = NULL; ++ char *lockname = NULL; ++ int validated_fd = -1; ++ struct stat lock_stat = {0}; ++ ++ parent_dir = cli_gentemp(NULL); ++ ck_assert_msg(NULL != parent_dir, "cli_gentemp failed"); ++ ck_assert_msg(0 == mkdir(parent_dir, 0700), "mkdir(%s) failed", parent_dir); ++ ++ validated_path = cli_newfilepath(parent_dir, "quarantine"); ++ validated_saved = cli_newfilepath(parent_dir, "quarantine-original"); ++ ck_assert_msg((NULL != validated_path) && (NULL != validated_saved), "Failed to allocate quarantine paths"); ++ ++ ck_assert_msg(0 == mkdir(validated_path, 0700), "mkdir(%s) failed", validated_path); ++ ++ validated_fd = open(validated_path, O_RDONLY | O_NOFOLLOW); ++ ck_assert_msg(-1 != validated_fd, "open(%s) failed: %s", validated_path, strerror(errno)); ++ ++ ck_assert_msg(0 == rename(validated_path, validated_saved), "rename(%s, %s) failed: %s", ++ validated_path, validated_saved, strerror(errno)); ++ ck_assert_msg(0 == mkdir(validated_path, 0700), "mkdir(%s) replacement failed: %s", ++ validated_path, strerror(errno)); ++ ++ ck_assert_msg(0 == action_setup_quarantine_lock_at(validated_fd, validated_path, &lockname), ++ "action_setup_quarantine_lock_at failed"); ++ ck_assert_msg(NULL != lockname, "action_setup_quarantine_lock_at did not return a lock name"); ++ ++ ck_assert_msg(0 == fstatat(validated_fd, lockname, &lock_stat, AT_SYMLINK_NOFOLLOW), ++ "fstatat(validated_fd, %s) failed: %s", lockname, strerror(errno)); ++ ck_assert_msg(S_ISREG(lock_stat.st_mode), "Expected quarantine lock to be a regular file"); ++ ++ replacement_lock = cli_newfilepath(validated_path, lockname); ++ ck_assert_msg(NULL != replacement_lock, "Failed to allocate replacement lock path"); ++ ck_assert_msg(0 != access(replacement_lock, F_OK), ++ "Lock file was created in the replacement quarantine path"); ++ ++ ck_assert_msg(0 == unlinkat(validated_fd, lockname, 0), "unlinkat(validated_fd, %s) failed: %s", ++ lockname, strerror(errno)); ++ free(lockname); ++ lockname = NULL; ++ ++ close(validated_fd); ++ validated_fd = -1; ++ ++ cli_rmdirs(parent_dir); ++ free(replacement_lock); ++ free(validated_saved); ++ free(validated_path); ++ free(parent_dir); ++} ++END_TEST ++ ++START_TEST(test_action_source_open_relative_path_stores_absolute_action_path) ++{ ++ char *parent_dir = NULL; ++ char *file_path = NULL; ++ int fd = -1; ++ action_source_t source; ++ ++ action_source_init(&source); ++ ++ parent_dir = cli_gentemp("."); ++ ck_assert_msg(NULL != parent_dir, "cli_gentemp failed"); ++ ck_assert_msg(0 == mkdir(parent_dir, 0700), "mkdir(%s) failed: %s", parent_dir, strerror(errno)); ++ ++ file_path = cli_newfilepath(parent_dir, "payload"); ++ ck_assert_msg(NULL != file_path, "Failed to allocate payload path"); ++ ++ fd = open(file_path, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, 0600); ++ ck_assert_msg(-1 != fd, "open(%s) failed: %s", file_path, strerror(errno)); ++ ck_assert_msg(4 == write(fd, "test", 4), "write(%s) failed: %s", file_path, strerror(errno)); ++ close(fd); ++ fd = -1; ++ ++ ck_assert_msg(CL_SUCCESS == action_source_open(file_path, &source), ++ "action_source_open(%s) failed", file_path); ++ ck_assert_msg(NULL != source.action_path, "action_source_open did not populate action_path"); ++ ck_assert_msg('/' == source.action_path[0], ++ "Expected absolute action_path for relative source path, got '%s'", source.action_path); ++ ++ action_source_close(&source); ++ ++ ck_assert_msg(0 == unlink(file_path), "unlink(%s) failed: %s", file_path, strerror(errno)); ++ free(file_path); ++ file_path = NULL; ++ ck_assert_msg(0 == rmdir(parent_dir), "rmdir(%s) failed: %s", parent_dir, strerror(errno)); ++ free(parent_dir); ++ parent_dir = NULL; ++} ++END_TEST ++ ++START_TEST(test_action_source_open_path_rejects_replaced_symlink) ++{ ++ char *parent_dir = NULL; ++ char *file_path = NULL; ++ char *replacement_path = NULL; ++ char *resolved_path = NULL; ++ int fd = -1; ++ action_source_t source; ++ ++ action_source_init(&source); ++ ++ parent_dir = cli_gentemp(NULL); ++ ck_assert_msg(NULL != parent_dir, "cli_gentemp failed"); ++ ck_assert_msg(0 == mkdir(parent_dir, 0700), "mkdir(%s) failed: %s", parent_dir, strerror(errno)); ++ ++ file_path = cli_newfilepath(parent_dir, "payload"); ++ replacement_path = cli_newfilepath(parent_dir, "replacement"); ++ ck_assert_msg((NULL != file_path) && (NULL != replacement_path), "Failed to allocate test paths"); ++ ++ fd = open(file_path, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, 0600); ++ ck_assert_msg(-1 != fd, "open(%s) failed: %s", file_path, strerror(errno)); ++ ck_assert_msg(7 == write(fd, "payload", 7), "write(%s) failed: %s", file_path, strerror(errno)); ++ close(fd); ++ fd = -1; ++ ++ ck_assert_msg(CL_SUCCESS == cli_realpath(file_path, &resolved_path), ++ "cli_realpath(%s) failed", file_path); ++ ++ fd = open(replacement_path, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, 0600); ++ ck_assert_msg(-1 != fd, "open(%s) failed: %s", replacement_path, strerror(errno)); ++ ck_assert_msg(11 == write(fd, "replacement", 11), "write(%s) failed: %s", replacement_path, strerror(errno)); ++ close(fd); ++ fd = -1; ++ ++ ck_assert_msg(0 == unlink(file_path), "unlink(%s) failed: %s", file_path, strerror(errno)); ++ ck_assert_msg(0 == symlink(replacement_path, file_path), "symlink(%s, %s) failed: %s", ++ replacement_path, file_path, strerror(errno)); ++ ++ ck_assert_msg(CL_SUCCESS != action_source_open_path(file_path, resolved_path, &source), ++ "action_source_open_path accepted a resolved path that had been replaced by a symlink"); ++ ++ action_source_close(&source); ++ ++ ck_assert_msg(0 == unlink(file_path), "unlink(%s) symlink failed: %s", file_path, strerror(errno)); ++ ck_assert_msg(0 == unlink(replacement_path), "unlink(%s) failed: %s", replacement_path, strerror(errno)); ++ free(resolved_path); ++ free(replacement_path); ++ free(file_path); ++ ck_assert_msg(0 == rmdir(parent_dir), "rmdir(%s) failed: %s", parent_dir, strerror(errno)); ++ free(parent_dir); ++} ++END_TEST ++#endif ++ + static char **testfiles = NULL; + static unsigned testfiles_n = 0; + +@@ -1324,6 +1480,11 @@ static Suite *test_cl_suite(void) + tcase_add_test(tc_cl, test_cl_statchkdir); + tcase_add_test(tc_cl, test_cl_settempdir); + tcase_add_test(tc_cl, test_cl_strerror); ++#ifndef _WIN32 ++ tcase_add_test(tc_cl, test_action_setup_quarantine_lock_uses_validated_directory_handle); ++ tcase_add_test(tc_cl, test_action_source_open_relative_path_stores_absolute_action_path); ++ tcase_add_test(tc_cl, test_action_source_open_path_rejects_replaced_symlink); ++#endif + + suite_add_tcase(s, tc_cl_scan); + tcase_add_checked_fixture(tc_cl_scan, engine_setup, engine_teardown); +diff --git a/unit_tests/clamd_test.py b/unit_tests/clamd_test.py +index 96623a67f..34431c917 100644 +--- a/unit_tests/clamd_test.py ++++ b/unit_tests/clamd_test.py +@@ -7,12 +7,14 @@ Run clamd (and clamdscan) tests. + import os + from pathlib import Path + import platform ++import re + import socket + import subprocess + import shutil + import sys + import time + import unittest ++import hashlib + from zipfile import ZIP_DEFLATED, ZipFile + + import testcase +@@ -254,6 +256,25 @@ class TC(testcase.TestCase): + if expected_err != [] or unexpected_err != []: + self.verify_output(output.err, expected=expected_err, unexpected=unexpected_err) + ++ @staticmethod ++ def _create_file_symlink(link_path: Path, target_path: Path): ++ try: ++ os.symlink(target_path, link_path) ++ return ++ except OSError: ++ if operating_system != 'windows': ++ raise ++ ++ completed = subprocess.run( ++ ['cmd', '/c', 'mklink', str(link_path), str(target_path)], ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=False, ++ ) ++ if completed.returncode != 0: ++ raise OSError('Failed to create file symlink: {}'.format(completed.stdout.strip())) ++ + def test_clamd_00_version(self): + ''' + verify that clamd -v returns the version +@@ -404,6 +425,194 @@ class TC(testcase.TestCase): + + time.sleep(5) + ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_clamd_05a_quarantine_copy_uses_real_source_path(self): ++ self.step_name('Testing clamdscan --copy keeps the display path while quarantining the real source path') ++ ++ payload = b'CLAM-2976 clamd symlink quarantine payload\n' ++ payload_path = TC.path_tmp / 'clamd-link-target' ++ payload_path.write_bytes(payload) ++ ++ link_path = TC.path_tmp / 'clamd-link-source' ++ try: ++ self._create_file_symlink(link_path, payload_path) ++ except OSError: ++ self.skipTest('File symlink creation is not permitted in this test environment.') ++ ++ quarantine_dir = TC.path_tmp / 'clamd-link-quarantine' ++ quarantine_dir.mkdir() ++ ++ db_dir = TC.path_tmp / 'clamd-link-db' ++ db_dir.mkdir() ++ (db_dir / 'trigger.hdb').write_text( ++ '{}:{}:{}\n'.format( ++ hashlib.sha256(payload).hexdigest(), ++ len(payload), ++ 'CLAM-2976-CLAMD-SOURCE-LINK', ++ ) ++ ) ++ ++ config = ''' ++ Foreground yes ++ PidFile {pid} ++ DatabaseDirectory {dbdir} ++ LogFileMaxSize 0 ++ LogTime yes ++ LogClean yes ++ LogVerbose yes ++ ExitOnOOM yes ++ DetectPUA yes ++ ScanPDF yes ++ CommandReadTimeout 1 ++ MaxQueue 800 ++ MaxConnectionQueueLength 1024 ++ FollowFileSymlinks yes ++ '''.format(pid=TC.clamd_pid, dbdir=db_dir) ++ if operating_system == 'windows': ++ config += ''' ++ TCPSocket {socket} ++ TCPAddr localhost ++ '''.format(socket=TC.clamd_port_num) ++ else: ++ config += ''' ++ LocalSocket {localsocket} ++ '''.format(localsocket=TC.clamd_socket) ++ ++ clamd_config = TC.path_tmp / 'clamd-follow-links.conf' ++ clamd_config.write_text(config) ++ ++ self.start_clamd(clamd_config=clamd_config) ++ ++ poll = self.proc.poll() ++ assert poll == None ++ ++ output = self.execute_command('{clamdscan} --ping 5 --wait --copy={quarantine_dir} -c {clamd_config} {link_path}'.format( ++ clamdscan=TC.clamdscan, ++ quarantine_dir=quarantine_dir, ++ clamd_config=clamd_config, ++ link_path=link_path, ++ )) ++ ++ assert output.ec == 1 ++ self.verify_output( ++ output.out, ++ expected=[ ++ re.escape('{}:'.format(link_path)), ++ 'FOUND', ++ ], ++ unexpected=[ ++ re.escape('{}:'.format(payload_path)), ++ ], ++ ) ++ self.verify_output( ++ output.err, ++ unexpected=[ ++ re.escape("Can't copy file '{}'".format(link_path)), ++ ], ++ ) ++ self.assertTrue(payload_path.exists(), 'Expected the symlink target to remain in place after the quarantine copy.') ++ self.assertTrue(link_path.exists(), 'Expected the symlink source to remain in place after the quarantine copy.') ++ quarantined_path = quarantine_dir / payload_path.name ++ self.assertFalse((quarantine_dir / link_path.name).exists(), 'Expected clamdscan quarantine copy to avoid naming the quarantine file after the symlink.') ++ self.assertTrue(quarantined_path.exists(), 'Expected clamdscan quarantine copy to use the resolved source basename.') ++ self.assertEqual(payload_path.read_bytes(), quarantined_path.read_bytes(), 'Expected the quarantined file to contain the target file bytes.') ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_clamd_05b_quarantine_copy_modes_use_real_source_path(self): ++ self.step_name('Testing clamdscan --copy source binding across scan modes') ++ ++ parent_dir = TC.path_tmp / 'clamd-mode-link' ++ parent_dir.mkdir() ++ payload = b'CLAM-2959 clamdscan quarantine mode payload\n' ++ payload_path = parent_dir / 'payload.bin' ++ payload_path.write_bytes(payload) ++ ++ link_path = parent_dir / 'payload-link' ++ try: ++ self._create_file_symlink(link_path, payload_path) ++ except OSError: ++ self.skipTest('File symlink creation is not permitted in this test environment.') ++ ++ db_dir = TC.path_tmp / 'clamd-mode-db' ++ db_dir.mkdir() ++ (db_dir / 'trigger.hdb').write_text( ++ '{}:{}:{}\n'.format( ++ hashlib.sha256(payload).hexdigest(), ++ len(payload), ++ 'CLAM-2959-CLAMDSCAN-MODE-LINK', ++ ) ++ ) ++ ++ config = ''' ++ Foreground yes ++ PidFile {pid} ++ DatabaseDirectory {dbdir} ++ LogFileMaxSize 0 ++ LogTime yes ++ LogClean yes ++ LogVerbose yes ++ ExitOnOOM yes ++ DetectPUA yes ++ ScanPDF yes ++ CommandReadTimeout 1 ++ MaxQueue 800 ++ MaxConnectionQueueLength 1024 ++ FollowFileSymlinks yes ++ '''.format(pid=TC.clamd_pid, dbdir=db_dir) ++ if operating_system == 'windows': ++ config += ''' ++ TCPSocket {socket} ++ TCPAddr localhost ++ '''.format(socket=TC.clamd_port_num) ++ else: ++ config += ''' ++ LocalSocket {localsocket} ++ '''.format(localsocket=TC.clamd_socket) ++ ++ clamd_config = TC.path_tmp / 'clamd-mode-link.conf' ++ clamd_config.write_text(config) ++ ++ self.start_clamd(clamd_config=clamd_config) ++ ++ poll = self.proc.poll() ++ assert poll == None ++ ++ mode_args = [ ++ ('default', ''), ++ ('stream', '--stream'), ++ ('multiscan', '--multiscan'), ++ ] ++ if TC.has_fdpass_support: ++ mode_args.append(('fdpass', '--fdpass')) ++ ++ for mode_name, arg_variation in mode_args: ++ quarantine_dir = TC.path_tmp / 'clamd-mode-quarantine-{}'.format(mode_name) ++ quarantine_dir.mkdir() ++ ++ output = self.execute_command('{clamdscan} --ping 5 --wait {arg_variation} --copy={quarantine_dir} -c {clamd_config} {link_path}'.format( ++ clamdscan=TC.clamdscan, ++ arg_variation=arg_variation, ++ quarantine_dir=quarantine_dir, ++ clamd_config=clamd_config, ++ link_path=link_path, ++ )) ++ ++ assert output.ec == 1 ++ self.verify_output( ++ output.out, ++ expected=[ ++ re.escape('{}:'.format(link_path)), ++ 'FOUND', ++ ], ++ unexpected=[ ++ re.escape('{}:'.format(payload_path)), ++ ], ++ ) ++ quarantined_path = quarantine_dir / payload_path.name ++ self.assertFalse((quarantine_dir / link_path.name).exists(), 'Expected {} mode to avoid naming the quarantine file after the symlink.'.format(mode_name)) ++ self.assertTrue(quarantined_path.exists(), 'Expected {} mode to quarantine using the resolved source basename.'.format(mode_name)) ++ self.assertEqual(payload_path.read_bytes(), quarantined_path.read_bytes(), 'Expected {} mode to copy the target file bytes.'.format(mode_name)) ++ + def test_clamd_06_HeuristicScanPrecedence_off(self): + ''' + Verify that HeuristicScanPrecedence off works as expected (default) +diff --git a/unit_tests/clamscan/quarantine_toctou_test.py b/unit_tests/clamscan/quarantine_toctou_test.py +new file mode 100644 +index 000000000..59078268a +--- /dev/null ++++ b/unit_tests/clamscan/quarantine_toctou_test.py +@@ -0,0 +1,824 @@ ++# Copyright (C) 2026 Cisco Systems, Inc. and/or its affiliates. All rights reserved. ++ ++"""Regression test for quarantine destination TOCTOU handling.""" ++ ++import errno ++import hashlib ++import os ++from pathlib import Path ++import platform ++import shutil ++import subprocess ++import sys ++import threading ++import time ++import unittest ++ ++sys.path.append('../unit_tests') ++import testcase ++ ++ ++operating_system = platform.platform().split('-')[0].lower() ++ ++ ++class TC(testcase.TestCase): ++ @classmethod ++ def setUpClass(cls): ++ super(TC, cls).setUpClass() ++ ++ @classmethod ++ def tearDownClass(cls): ++ super(TC, cls).tearDownClass() ++ ++ def setUp(self): ++ super(TC, self).setUp() ++ ++ def tearDown(self): ++ super(TC, self).tearDown() ++ self.verify_valgrind_log() ++ ++ @staticmethod ++ def _write_hdb_signature(sig_path: Path, payload: bytes, signature_name: str): ++ sig_path.write_text( ++ '{}:{}:{}\n'.format( ++ hashlib.sha256(payload).hexdigest(), ++ len(payload), ++ signature_name, ++ ) ++ ) ++ ++ @staticmethod ++ def _write_padding_hdb(sig_path: Path, count: int): ++ with sig_path.open('w') as handle: ++ for i in range(count): ++ pad = 'padding-entry-{:05d}'.format(i).encode('utf-8') ++ handle.write( ++ '{}:{}:Padding-{:05d}\n'.format( ++ hashlib.sha256(pad).hexdigest(), ++ len(pad), ++ i, ++ ) ++ ) ++ ++ @staticmethod ++ def _watch_debug_output(stream, milestone_lines, matched_event, collected_lines): ++ try: ++ for line in iter(stream.readline, ''): ++ collected_lines.append(line) ++ if not matched_event.is_set(): ++ for milestone in milestone_lines: ++ if milestone in line: ++ matched_event.set() ++ break ++ finally: ++ stream.close() ++ ++ @staticmethod ++ def _quarantine_lock_exists(quarantine_dir: Path): ++ try: ++ return any(entry.name.startswith('.clamav-quarantine-lock.') for entry in quarantine_dir.iterdir()) ++ except FileNotFoundError: ++ return False ++ except NotADirectoryError: ++ return False ++ ++ @staticmethod ++ def _can_create_directory_symlink(parent_dir: Path): ++ target = parent_dir / 'symlink-target' ++ link = parent_dir / 'symlink-link' ++ ++ target.mkdir() ++ try: ++ TC._create_directory_redirect(link, target) ++ except OSError: ++ return False ++ else: ++ TC._remove_directory_redirect(link) ++ return True ++ finally: ++ target.rmdir() ++ ++ @staticmethod ++ def _create_directory_redirect(link_path: Path, target_path: Path): ++ try: ++ os.symlink(target_path, link_path, target_is_directory=True) ++ return ++ except OSError: ++ if operating_system != 'windows': ++ raise ++ ++ completed = subprocess.run( ++ ['cmd', '/c', 'mklink', '/J', str(link_path), str(target_path)], ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=False, ++ ) ++ if completed.returncode != 0: ++ raise OSError('Failed to create directory redirect: {}'.format(completed.stdout.strip())) ++ ++ @staticmethod ++ def _remove_directory_redirect(link_path: Path): ++ if not link_path.exists(): ++ return ++ ++ if operating_system == 'windows' and not link_path.is_symlink(): ++ subprocess.run( ++ ['cmd', '/c', 'rmdir', str(link_path)], ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=False, ++ ) ++ return ++ ++ link_path.unlink() ++ ++ @staticmethod ++ def _can_create_file_symlink(parent_dir: Path): ++ target = parent_dir / 'symlink-file-target' ++ link = parent_dir / 'symlink-file-link' ++ ++ target.write_bytes(b'CLAM-2976 file symlink probe\n') ++ try: ++ TC._create_file_redirect(link, target) ++ except OSError: ++ return False ++ else: ++ link.unlink() ++ return True ++ finally: ++ if target.exists(): ++ target.unlink() ++ ++ @staticmethod ++ def _create_file_redirect(link_path: Path, target_path: Path): ++ try: ++ os.symlink(target_path, link_path) ++ return ++ except OSError: ++ if operating_system != 'windows': ++ raise ++ ++ completed = subprocess.run( ++ ['cmd', '/c', 'mklink', str(link_path), str(target_path)], ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=False, ++ ) ++ if completed.returncode != 0: ++ raise OSError('Failed to create file redirect: {}'.format(completed.stdout.strip())) ++ ++ @staticmethod ++ def _write_xattr(file_path: Path, attr_name: str, attr_value: str): ++ subprocess.run( ++ ['xattr', '-w', attr_name, attr_value, str(file_path)], ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=True, ++ ) ++ ++ @staticmethod ++ def _read_xattr(file_path: Path, attr_name: str): ++ completed = subprocess.run( ++ ['xattr', '-p', attr_name, str(file_path)], ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=True, ++ ) ++ return completed.stdout.rstrip('\n') ++ ++ def _exercise_quarantine_directory_replacement(self, action_mode: str): ++ assert action_mode in ('copy', 'move') ++ ++ db_dir = TC.path_tmp / ('db-{}'.format(action_mode)) ++ db_dir.mkdir() ++ ++ payload = b'CLAM-2959 quarantine TOCTOU payload\n' ++ scan_dir = TC.path_tmp / ('scanme-{}'.format(action_mode)) ++ scan_dir.mkdir() ++ payload_path = scan_dir / 'backdoor' ++ payload_path.write_bytes(payload) ++ ++ self._write_hdb_signature(db_dir / 'trigger.hdb', payload, 'CLAM-2959-TOCTOU') ++ # Pad the database a bit so we have a more reliable window to replace the ++ # quarantine directory after argument setup but before the copy/move action. ++ self._write_padding_hdb(db_dir / 'padding.hdb', 500000) ++ ++ parent_dir = TC.path_tmp / ('srv-{}'.format(action_mode)) ++ parent_dir.mkdir() ++ if not self._can_create_directory_symlink(parent_dir): ++ self.skipTest('Directory symlink creation is not permitted in this test environment.') ++ quarantine_dir = parent_dir / 'quarantine' ++ quarantine_dir.mkdir() ++ ++ redirect_dir = TC.path_tmp / ('redirect-{}'.format(action_mode)) ++ redirect_dir.mkdir() ++ ++ command = [] ++ if str(TC.valgrind): ++ command.append(str(TC.valgrind)) ++ if TC.valgrind_args: ++ command.extend(TC.valgrind_args.split()) ++ command.extend( ++ [ ++ str(TC.clamscan), ++ '--debug', ++ '-d', ++ str(db_dir), ++ '--{}={}'.format(action_mode, quarantine_dir), ++ str(scan_dir), ++ ] ++ ) ++ ++ milestone_lines = ['{} loaded'.format(db_dir / 'trigger.hdb')] ++ if operating_system == 'windows': ++ milestone_lines.append('{} loaded'.format(str(db_dir / 'trigger.hdb')).replace('/', '\\')) ++ ++ self.log.info('Starting clamscan command: %s', ' '.join(command)) ++ proc = subprocess.Popen( ++ command, ++ cwd=str(TC.path_tmp), ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ preexec_fn=os.setsid if operating_system != 'windows' else None, ++ ) ++ output_lines = [] ++ saw_db_load_event = threading.Event() ++ output_thread = threading.Thread( ++ target=self._watch_debug_output, ++ args=(proc.stdout, milestone_lines, saw_db_load_event, output_lines), ++ daemon=True, ++ ) ++ output_thread.start() ++ ++ try: ++ swapped = False ++ replacement_blocked = False ++ saw_setup_event = False ++ deadline = time.time() + 10 ++ ++ while time.time() < deadline and proc.poll() is None: ++ if operating_system == 'windows': ++ saw_setup_event = saw_db_load_event.is_set() ++ else: ++ # POSIX action setup creates this lock in the validated ++ # destination. Watching the filesystem avoids depending on ++ # debug stdout timing, which is fragile under Valgrind. ++ saw_setup_event = self._quarantine_lock_exists(quarantine_dir) ++ ++ if not saw_setup_event: ++ time.sleep(0.01) ++ continue ++ ++ try: ++ quarantine_dir.rmdir() ++ self._create_directory_redirect(quarantine_dir, redirect_dir) ++ swapped = True ++ self.log.info('Replaced quarantine directory with symlink to %s', redirect_dir) ++ break ++ except FileNotFoundError: ++ pass ++ except OSError as err: ++ if err.errno == errno.ENOTEMPTY: ++ replacement_blocked = True ++ self.log.info('Quarantine directory replacement was blocked while clamscan was running.') ++ break ++ time.sleep(0.01) ++ ++ proc.wait(timeout=60) ++ finally: ++ if proc.poll() is None: ++ proc.terminate() ++ proc.wait(timeout=10) ++ output_thread.join(timeout=10) ++ ++ stdout = ''.join(output_lines) ++ self.log.info('clamscan stdout:\n%s', stdout) ++ ++ return { ++ 'payload_path': payload_path, ++ 'quarantine_dir': quarantine_dir, ++ 'redirect_dir': redirect_dir, ++ 'saw_setup_event': saw_setup_event, ++ 'saw_db_load_event': saw_db_load_event, ++ 'swapped': swapped, ++ 'replacement_blocked': replacement_blocked, ++ 'returncode': proc.returncode, ++ 'stdout': stdout, ++ } ++ ++ def _exercise_source_link_quarantine(self, action_mode: str): ++ assert action_mode in ('copy', 'move', 'remove') ++ ++ parent_dir = TC.path_tmp / ('src-link-{}'.format(action_mode)) ++ parent_dir.mkdir() ++ if not self._can_create_file_symlink(parent_dir): ++ self.skipTest('File symlink creation is not permitted in this test environment.') ++ ++ db_dir = TC.path_tmp / ('db-src-link-{}'.format(action_mode)) ++ db_dir.mkdir() ++ ++ payload = b'CLAM-2976 quarantine source link payload\n' ++ payload_path = parent_dir / 'payload.bin' ++ payload_path.write_bytes(payload) ++ ++ link_path = parent_dir / 'payload-link' ++ self._create_file_redirect(link_path, payload_path) ++ ++ self._write_hdb_signature(db_dir / 'trigger.hdb', payload, 'CLAM-2976-SOURCE-LINK') ++ ++ quarantine_dir = None ++ command = [] ++ if str(TC.valgrind): ++ command.append(str(TC.valgrind)) ++ if TC.valgrind_args: ++ command.extend(TC.valgrind_args.split()) ++ command.extend( ++ [ ++ str(TC.clamscan), ++ '--debug', ++ '--follow-file-symlinks=2', ++ '-d', ++ str(db_dir), ++ ] ++ ) ++ ++ if action_mode == 'remove': ++ command.append('--remove=yes') ++ else: ++ quarantine_dir = TC.path_tmp / ('quarantine-src-link-{}'.format(action_mode)) ++ quarantine_dir.mkdir() ++ command.append('--{}={}'.format(action_mode, quarantine_dir)) ++ ++ command.append(str(link_path)) ++ ++ self.log.info('Starting clamscan command: %s', ' '.join(command)) ++ completed = subprocess.run( ++ command, ++ cwd=str(TC.path_tmp), ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=False, ++ ) ++ self.log.info('clamscan stdout:\n%s', completed.stdout) ++ ++ return { ++ 'payload_path': payload_path, ++ 'link_path': link_path, ++ 'quarantine_dir': quarantine_dir, ++ 'returncode': completed.returncode, ++ 'stdout': completed.stdout, ++ } ++ ++ def _exercise_source_link_replacement_quarantine(self, action_mode: str, attempt: int): ++ assert action_mode in ('copy', 'move', 'remove') ++ ++ parent_dir = TC.path_tmp / ('src-replace-{}-{}'.format(action_mode, attempt)) ++ parent_dir.mkdir() ++ if not self._can_create_file_symlink(parent_dir): ++ self.skipTest('File symlink creation is not permitted in this test environment.') ++ ++ db_dir = TC.path_tmp / ('db-src-replace-{}-{}'.format(action_mode, attempt)) ++ db_dir.mkdir() ++ ++ payload = b'CLAM-2959 quarantine source replacement payload\n' + (b'A' * (32 * 1024 * 1024)) ++ payload_path = parent_dir / 'payload.bin' ++ payload_path.write_bytes(payload) ++ ++ decoy = b'CLAM-2959 quarantine source replacement decoy\n' ++ decoy_path = parent_dir / 'decoy.bin' ++ decoy_path.write_bytes(decoy) ++ ++ link_path = parent_dir / 'payload-link' ++ self._create_file_redirect(link_path, payload_path) ++ ++ self._write_hdb_signature(db_dir / 'trigger.hdb', payload, 'CLAM-2959-SOURCE-REPLACEMENT') ++ ++ quarantine_dir = None ++ command = [] ++ if str(TC.valgrind): ++ command.append(str(TC.valgrind)) ++ if TC.valgrind_args: ++ command.extend(TC.valgrind_args.split()) ++ command.extend( ++ [ ++ str(TC.clamscan), ++ '--debug', ++ '--follow-file-symlinks=2', ++ '-d', ++ str(db_dir), ++ ] ++ ) ++ ++ if action_mode == 'remove': ++ command.append('--remove=yes') ++ else: ++ quarantine_dir = TC.path_tmp / ('quarantine-src-replace-{}-{}'.format(action_mode, attempt)) ++ quarantine_dir.mkdir() ++ command.append('--{}={}'.format(action_mode, quarantine_dir)) ++ ++ command.append(str(link_path)) ++ ++ milestone_lines = [ ++ 'cli_get_filepath_from_filedesc: File path for fd', ++ 'cli_get_filepath_from_handle: File path for handle', ++ ] ++ ++ self.log.info('Starting clamscan command: %s', ' '.join(command)) ++ proc = subprocess.Popen( ++ command, ++ cwd=str(TC.path_tmp), ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ preexec_fn=os.setsid if operating_system != 'windows' else None, ++ ) ++ output_lines = [] ++ saw_scan_event = threading.Event() ++ output_thread = threading.Thread( ++ target=self._watch_debug_output, ++ args=(proc.stdout, milestone_lines, saw_scan_event, output_lines), ++ daemon=True, ++ ) ++ output_thread.start() ++ ++ try: ++ swapped = False ++ deadline = time.time() + 10 ++ ++ while time.time() < deadline and proc.poll() is None: ++ if not saw_scan_event.is_set(): ++ time.sleep(0.01) ++ continue ++ ++ try: ++ link_path.unlink() ++ self._create_file_redirect(link_path, decoy_path) ++ swapped = True ++ self.log.info('Replaced source symlink with symlink to %s', decoy_path) ++ break ++ except FileNotFoundError: ++ pass ++ except OSError: ++ time.sleep(0.01) ++ ++ proc.wait(timeout=90) ++ finally: ++ if proc.poll() is None: ++ proc.terminate() ++ proc.wait(timeout=10) ++ output_thread.join(timeout=10) ++ ++ stdout = ''.join(output_lines) ++ self.log.info('clamscan stdout:\n%s', stdout) ++ ++ return { ++ 'payload': payload, ++ 'decoy': decoy, ++ 'payload_path': payload_path, ++ 'decoy_path': decoy_path, ++ 'link_path': link_path, ++ 'quarantine_dir': quarantine_dir, ++ 'swapped': swapped, ++ 'returncode': proc.returncode, ++ 'stdout': stdout, ++ } ++ ++ def _exercise_source_link_replacement_until_infected(self, action_mode: str): ++ result = None ++ for attempt in range(6): ++ result = self._exercise_source_link_replacement_quarantine(action_mode, attempt) ++ if result['swapped'] and result['returncode'] == 1: ++ return result ++ ++ self.fail( ++ 'Failed to hit the source replacement race window for clamscan --{}; last return code was {}.'.format( ++ action_mode, ++ None if result is None else result['returncode'], ++ ) ++ ) ++ ++ def _exercise_search_only_quarantine_directory(self, action_mode: str): ++ assert action_mode in ('copy', 'move') ++ ++ db_dir = TC.path_tmp / ('db-search-only-{}'.format(action_mode)) ++ db_dir.mkdir() ++ ++ payload = 'CLAM-2959 search-only quarantine payload {}\n'.format(action_mode).encode('utf-8') ++ scan_dir = TC.path_tmp / ('scanme-search-only-{}'.format(action_mode)) ++ scan_dir.mkdir() ++ payload_path = scan_dir / 'backdoor' ++ payload_path.write_bytes(payload) ++ ++ self._write_hdb_signature(db_dir / 'trigger.hdb', payload, 'CLAM-2959-SEARCH-ONLY-{}'.format(action_mode.upper())) ++ ++ quarantine_dir = TC.path_tmp / ('quarantine-search-only-{}'.format(action_mode)) ++ quarantine_dir.mkdir() ++ ++ command = [] ++ if str(TC.valgrind): ++ command.append(str(TC.valgrind)) ++ if TC.valgrind_args: ++ command.extend(TC.valgrind_args.split()) ++ command.extend( ++ [ ++ str(TC.clamscan), ++ '--debug', ++ '-d', ++ str(db_dir), ++ '--{}={}'.format(action_mode, quarantine_dir), ++ str(scan_dir), ++ ] ++ ) ++ ++ try: ++ quarantine_dir.chmod(0o300) ++ self.log.info('Starting clamscan command: %s', ' '.join(command)) ++ completed = subprocess.run( ++ command, ++ cwd=str(TC.path_tmp), ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=False, ++ ) ++ finally: ++ quarantine_dir.chmod(0o700) ++ ++ self.log.info('clamscan stdout:\n%s', completed.stdout) ++ ++ return { ++ 'payload': payload, ++ 'payload_path': payload_path, ++ 'quarantine_dir': quarantine_dir, ++ 'returncode': completed.returncode, ++ 'stdout': completed.stdout, ++ } ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_directory_replacement_does_not_redirect_copy_target(self): ++ self.step_name('Test quarantine destination TOCTOU resistance for clamscan --copy') ++ result = self._exercise_quarantine_directory_replacement('copy') ++ ++ payload_path = result['payload_path'] ++ quarantine_dir = result['quarantine_dir'] ++ redirect_dir = result['redirect_dir'] ++ stdout = result['stdout'] ++ ++ assert result['saw_setup_event'], 'Failed to observe clamscan setup before attempting the directory replacement.' ++ assert (result['swapped'] or result['replacement_blocked']), 'Failed to replace or pin the quarantine directory during the test window.' ++ assert result['returncode'] == 1, 'Expected a virus-found exit code from clamscan.' ++ ++ redirected_payload = redirect_dir / payload_path.name ++ quarantined_payload = quarantine_dir / payload_path.name ++ copied_to_line = "{}: copied to '{}'".format(payload_path, quarantine_dir / payload_path.name) ++ self.assertFalse( ++ redirected_payload.exists(), ++ 'Quarantine copy was redirected through the replaced directory entry.', ++ ) ++ if copied_to_line in stdout: ++ self.assertTrue( ++ quarantined_payload.exists(), ++ 'Expected a reported successful quarantine copy to create the destination file.', ++ ) ++ else: ++ self.assertIn( ++ "Can't copy file", ++ stdout, ++ 'Expected clamscan to either quarantine successfully or fail safely once the directory entry was replaced.', ++ ) ++ self.assertFalse( ++ quarantined_payload.exists(), ++ 'Expected safe failure to avoid creating a destination file after the directory entry was replaced.', ++ ) ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_directory_replacement_does_not_redirect_move_target(self): ++ self.step_name('Test quarantine destination TOCTOU resistance for clamscan --move') ++ result = self._exercise_quarantine_directory_replacement('move') ++ ++ payload_path = result['payload_path'] ++ quarantine_dir = result['quarantine_dir'] ++ redirect_dir = result['redirect_dir'] ++ stdout = result['stdout'] ++ ++ assert result['saw_setup_event'], 'Failed to observe clamscan setup before attempting the directory replacement.' ++ assert (result['swapped'] or result['replacement_blocked']), 'Failed to replace or pin the quarantine directory during the test window.' ++ assert result['returncode'] == 1, 'Expected a virus-found exit code from clamscan.' ++ ++ redirected_payload = redirect_dir / payload_path.name ++ quarantined_payload = quarantine_dir / payload_path.name ++ moved_to_line = "{}: moved to '{}'".format(payload_path, quarantine_dir / payload_path.name) ++ self.assertFalse( ++ redirected_payload.exists(), ++ 'Quarantine move was redirected through the replaced directory entry.', ++ ) ++ if moved_to_line in stdout: ++ self.assertFalse( ++ payload_path.exists(), ++ 'Expected clamscan --move to remove the source file after quarantining it.', ++ ) ++ self.assertTrue( ++ quarantined_payload.exists(), ++ 'Expected a reported successful quarantine move to create the destination file.', ++ ) ++ else: ++ self.assertIn( ++ "Can't move file", ++ stdout, ++ 'Expected clamscan to either quarantine successfully or fail safely once the directory entry was replaced.', ++ ) ++ self.assertTrue( ++ payload_path.exists(), ++ 'Expected safe failure to leave the source file in place when the quarantine destination is unavailable.', ++ ) ++ self.assertFalse( ++ quarantined_payload.exists(), ++ 'Expected safe failure to avoid creating a destination file after the directory entry was replaced.', ++ ) ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_copy_uses_real_source_path_for_symlink_source(self): ++ self.step_name('Test quarantine copy uses the real source path for symlink sources') ++ result = self._exercise_source_link_quarantine('copy') ++ ++ payload_path = result['payload_path'] ++ link_path = result['link_path'] ++ quarantined_path = result['quarantine_dir'] / payload_path.name ++ ++ self.assertEqual(1, result['returncode'], 'Expected a virus-found exit code from clamscan.') ++ self.assertIn('{}:'.format(link_path), result['stdout'], 'Expected clamscan to report the unresolved source path.') ++ self.assertNotIn('{}:'.format(payload_path), result['stdout'], 'Expected clamscan to avoid reporting the resolved target path.') ++ self.assertTrue(payload_path.exists(), 'Expected the symlink target to remain in place after the quarantine copy.') ++ self.assertTrue(link_path.exists(), 'Expected the symlink source to remain in place after the quarantine copy.') ++ self.assertFalse((result['quarantine_dir'] / link_path.name).exists(), 'Expected quarantine copy to avoid naming the destination after the symlink.') ++ self.assertTrue(quarantined_path.exists(), 'Expected quarantine copy to use the resolved source basename.') ++ self.assertEqual(payload_path.read_bytes(), quarantined_path.read_bytes(), 'Expected the quarantined file to contain the target file bytes.') ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_move_uses_real_source_path_for_symlink_source(self): ++ self.step_name('Test quarantine move uses the real source path for symlink sources') ++ result = self._exercise_source_link_quarantine('move') ++ ++ payload_path = result['payload_path'] ++ link_path = result['link_path'] ++ quarantined_path = result['quarantine_dir'] / payload_path.name ++ ++ self.assertEqual(1, result['returncode'], 'Expected a virus-found exit code from clamscan.') ++ self.assertIn('{}:'.format(link_path), result['stdout'], 'Expected clamscan to report the unresolved source path.') ++ self.assertNotIn('{}:'.format(payload_path), result['stdout'], 'Expected clamscan to avoid reporting the resolved target path.') ++ self.assertFalse(payload_path.exists(), 'Expected the quarantine move to unlink the resolved target path.') ++ self.assertTrue(link_path.is_symlink(), 'Expected the original symlink entry to remain in place after the quarantine move.') ++ self.assertTrue(quarantined_path.exists(), 'Expected quarantine move to use the resolved source basename.') ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_remove_uses_real_source_path_for_symlink_source(self): ++ self.step_name('Test quarantine remove uses the real source path for symlink sources') ++ result = self._exercise_source_link_quarantine('remove') ++ ++ payload_path = result['payload_path'] ++ link_path = result['link_path'] ++ ++ self.assertEqual(1, result['returncode'], 'Expected a virus-found exit code from clamscan.') ++ self.assertIn('{}:'.format(link_path), result['stdout'], 'Expected clamscan to report the unresolved source path.') ++ self.assertNotIn('{}:'.format(payload_path), result['stdout'], 'Expected clamscan to avoid reporting the resolved target path.') ++ self.assertFalse(payload_path.exists(), 'Expected quarantine removal to unlink the resolved target path.') ++ self.assertTrue(link_path.is_symlink(), 'Expected the original symlink entry to remain in place after the quarantine remove.') ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_copy_does_not_act_on_replaced_source_link(self): ++ self.step_name('Test quarantine copy stays bound to the opened source object') ++ result = self._exercise_source_link_replacement_until_infected('copy') ++ ++ payload_path = result['payload_path'] ++ decoy_path = result['decoy_path'] ++ link_path = result['link_path'] ++ quarantined_path = result['quarantine_dir'] / payload_path.name ++ ++ self.assertTrue(link_path.is_symlink(), 'Expected the source path to remain a symlink after replacement.') ++ self.assertTrue(decoy_path.exists(), 'Expected the replacement decoy to remain in place.') ++ self.assertEqual(result['decoy'], decoy_path.read_bytes(), 'Expected quarantine copy not to modify the replacement decoy.') ++ self.assertTrue(payload_path.exists(), 'Expected quarantine copy to leave the opened source file in place.') ++ self.assertTrue(quarantined_path.exists(), 'Expected quarantine copy to create a copy of the opened source file.') ++ self.assertEqual(result['payload'], quarantined_path.read_bytes(), 'Expected the quarantine copy to contain the opened source bytes.') ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_move_does_not_act_on_replaced_source_link(self): ++ self.step_name('Test quarantine move stays bound to the opened source object') ++ result = self._exercise_source_link_replacement_until_infected('move') ++ ++ payload_path = result['payload_path'] ++ decoy_path = result['decoy_path'] ++ link_path = result['link_path'] ++ quarantined_path = result['quarantine_dir'] / payload_path.name ++ ++ self.assertTrue(link_path.is_symlink(), 'Expected the source path to remain a symlink after replacement.') ++ self.assertTrue(decoy_path.exists(), 'Expected the replacement decoy to remain in place.') ++ self.assertEqual(result['decoy'], decoy_path.read_bytes(), 'Expected quarantine move not to modify the replacement decoy.') ++ self.assertFalse(payload_path.exists(), 'Expected quarantine move to unlink the opened source file.') ++ self.assertTrue(quarantined_path.exists(), 'Expected quarantine move to create a copy of the opened source file.') ++ self.assertEqual(result['payload'], quarantined_path.read_bytes(), 'Expected the quarantine move to preserve the opened source bytes.') ++ ++ @unittest.skipIf(not hasattr(os, 'symlink'), 'This platform does not support symlink creation in the test environment.') ++ def test_quarantine_remove_does_not_act_on_replaced_source_link(self): ++ self.step_name('Test quarantine remove stays bound to the opened source object') ++ result = self._exercise_source_link_replacement_until_infected('remove') ++ ++ payload_path = result['payload_path'] ++ decoy_path = result['decoy_path'] ++ link_path = result['link_path'] ++ ++ self.assertTrue(link_path.is_symlink(), 'Expected the source path to remain a symlink after replacement.') ++ self.assertTrue(decoy_path.exists(), 'Expected the replacement decoy to remain in place.') ++ self.assertEqual(result['decoy'], decoy_path.read_bytes(), 'Expected quarantine remove not to modify the replacement decoy.') ++ self.assertFalse(payload_path.exists(), 'Expected quarantine remove to unlink the opened source file.') ++ ++ @unittest.skipIf(operating_system == 'windows', 'This test uses POSIX directory permissions.') ++ def test_quarantine_copy_allows_search_only_destination_dir(self): ++ self.step_name('Test quarantine copy allows a write/search-only destination directory') ++ result = self._exercise_search_only_quarantine_directory('copy') ++ ++ payload_path = result['payload_path'] ++ quarantined_path = result['quarantine_dir'] / payload_path.name ++ ++ self.assertEqual(1, result['returncode'], 'Expected a virus-found exit code from clamscan.') ++ self.assertTrue(payload_path.exists(), 'Expected quarantine copy to leave the source file in place.') ++ self.assertTrue(quarantined_path.exists(), 'Expected quarantine copy to create the destination file.') ++ self.assertEqual(result['payload'], quarantined_path.read_bytes(), 'Expected the quarantined copy to contain the source bytes.') ++ ++ @unittest.skipIf(operating_system == 'windows', 'This test uses POSIX directory permissions.') ++ def test_quarantine_move_allows_search_only_destination_dir(self): ++ self.step_name('Test quarantine move allows a write/search-only destination directory') ++ result = self._exercise_search_only_quarantine_directory('move') ++ ++ payload_path = result['payload_path'] ++ quarantined_path = result['quarantine_dir'] / payload_path.name ++ ++ self.assertEqual(1, result['returncode'], 'Expected a virus-found exit code from clamscan.') ++ self.assertFalse(payload_path.exists(), 'Expected quarantine move to unlink the source file.') ++ self.assertTrue(quarantined_path.exists(), 'Expected quarantine move to create the destination file.') ++ self.assertEqual(result['payload'], quarantined_path.read_bytes(), 'Expected the quarantined copy to contain the source bytes.') ++ ++ @unittest.skipUnless(operating_system in ('darwin', 'macos'), 'This test requires macOS copyfile semantics.') ++ def test_quarantine_copy_preserves_xattr(self): ++ self.step_name('Test quarantine copy preserves macOS xattrs') ++ ++ if shutil.which('xattr') is None: ++ self.skipTest('The xattr command is not available in this test environment.') ++ ++ db_dir = TC.path_tmp / 'db-xattr' ++ db_dir.mkdir() ++ ++ payload = b'CLAM-2959 quarantine xattr payload\n' ++ scan_dir = TC.path_tmp / 'scanme-xattr' ++ scan_dir.mkdir() ++ payload_path = scan_dir / 'backdoor' ++ payload_path.write_bytes(payload) ++ self._write_xattr(payload_path, 'com.clamav.test', 'preserve-me') ++ ++ self._write_hdb_signature(db_dir / 'trigger.hdb', payload, 'CLAM-2959-XATTR') ++ ++ quarantine_dir = TC.path_tmp / 'quarantine-xattr' ++ quarantine_dir.mkdir() ++ ++ command = [] ++ if str(TC.valgrind): ++ command.append(str(TC.valgrind)) ++ if TC.valgrind_args: ++ command.extend(TC.valgrind_args.split()) ++ command.extend( ++ [ ++ str(TC.clamscan), ++ '--debug', ++ '-d', ++ str(db_dir), ++ '--copy={}'.format(quarantine_dir), ++ str(scan_dir), ++ ] ++ ) ++ ++ self.log.info('Starting clamscan command: %s', ' '.join(command)) ++ completed = subprocess.run( ++ command, ++ cwd=str(TC.path_tmp), ++ stdout=subprocess.PIPE, ++ stderr=subprocess.STDOUT, ++ universal_newlines=True, ++ check=False, ++ ) ++ self.log.info('clamscan stdout:\n%s', completed.stdout) ++ ++ quarantined_path = quarantine_dir / payload_path.name ++ self.assertEqual(1, completed.returncode, 'Expected a virus-found exit code from clamscan.') ++ self.assertTrue(quarantined_path.exists(), 'Expected clamscan to create the quarantined copy.') ++ self.assertEqual( ++ 'preserve-me', ++ self._read_xattr(quarantined_path, 'com.clamav.test'), ++ 'Expected the quarantined copy to preserve the source extended attribute.', ++ ) +-- +2.55.0