/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

/***************************************************************************
 * Copyright (C) 2017-2026 ZmartZone Holding BV
 * Copyright (C) 2013-2017 Ping Identity Corporation
 * All rights reserved.
 *
 * DISCLAIMER OF WARRANTIES:
 *
 * THE SOFTWARE PROVIDED HEREUNDER IS PROVIDED ON AN "AS IS" BASIS, WITHOUT
 * ANY WARRANTIES OR REPRESENTATIONS EXPRESS, IMPLIED OR STATUTORY; INCLUDING,
 * WITHOUT LIMITATION, WARRANTIES OF QUALITY, PERFORMANCE, NONINFRINGEMENT,
 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.  NOR ARE THERE ANY
 * WARRANTIES CREATED BY A COURSE OR DEALING, COURSE OF PERFORMANCE OR TRADE
 * USAGE.  FURTHERMORE, THERE ARE NO WARRANTIES THAT THE SOFTWARE WILL MEET
 * YOUR NEEDS OR BE FREE FROM ERRORS, OR THAT THE OPERATION OF THE SOFTWARE
 * WILL BE UNINTERRUPTED.  IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * caching using a file storage backend
 *
 * @Author: Hans Zandbelt - hans.zandbelt@openidc.com
 */

#include "cfg/cache.h"
#include "cfg/cfg_int.h"
#include "util/util.h"

/*
 * header structure that holds the metadata info for a cache file entry
 */
typedef struct {
	/* length of the cached data */
	apr_size_t len;
	/* cache expiry timestamp */
	apr_time_t expire;
} oidc_cache_file_info_t;

/*
 * prefix that distinguishes mod_auth_openidc cache files from other files in the same directory (/tmp)
 */
#define OIDC_CACHE_FILE_PREFIX "mod-auth-openidc-"

/* post config routine */
int oidc_cache_file_post_config(apr_pool_t *pool, server_rec *s) {
	apr_status_t rv = APR_SUCCESS;
	oidc_cfg_t *cfg = (oidc_cfg_t *)ap_get_module_config(s->module_config, &auth_openidc_module);
	if (cfg->cache.file_dir == NULL) {
		/* by default we'll use the OS specified /tmp dir for cache files */
		rv = apr_temp_dir_get((const char **)&cfg->cache.file_dir, pool);
		if (rv != APR_SUCCESS) {
			oidc_serror(s, "apr_temp_dir_get failed: could not find a temp dir: %s",
				    oidc_cache_status2str(pool, rv));
			return HTTP_INTERNAL_SERVER_ERROR;
		}
	}
	return OK;
}

/*
 * return the cache file name for a specified key
 */
static const char *oidc_cache_file_name(request_rec *r, const char *section, const char *key) {
	return apr_psprintf(r->pool, "%s%s-%s", OIDC_CACHE_FILE_PREFIX, section, oidc_http_url_encode(r, key));
}

/*
 * return the fully qualified path name to a cache file for a specified key
 */
static const char *oidc_cache_file_path(request_rec *r, const char *section, const char *key) {
	oidc_cfg_t *cfg = ap_get_module_config(r->server->module_config, &auth_openidc_module);
	return apr_psprintf(r->pool, "%s/%s", cfg->cache.file_dir, oidc_cache_file_name(r, section, key));
}

/*
 * read a specified number of bytes from a cache file in to a preallocated buffer
 */
static apr_status_t oidc_cache_file_read(request_rec *r, const char *path, apr_file_t *fd, void *buf,
					 const apr_size_t len) {

	apr_status_t rc = APR_SUCCESS;
	apr_size_t bytes_read = 0;
	char s_err[128];

	/* (blocking) read the requested number of bytes */
	rc = apr_file_read_full(fd, buf, len, &bytes_read);

	/* test for system errors */
	if (rc != APR_SUCCESS) {
		oidc_error(r, "could not read from: %s (%s)", path, apr_strerror(rc, s_err, sizeof(s_err)));
	}

	/* ensure that we've got the requested number of bytes */
	if (bytes_read != len) {
		oidc_error(r,
			   "could not read enough bytes from: \"%s\", bytes_read (%" APR_SIZE_T_FMT
			   ") != len (%" APR_SIZE_T_FMT ")",
			   path, bytes_read, len);
		rc = APR_EGENERAL;
	}

	return rc;
}

/*
 * write a specified number of bytes from a buffer to a cache file
 */
static apr_status_t oidc_cache_file_write(request_rec *r, const char *path, apr_file_t *fd, const void *buf,
					  const apr_size_t len) {

	apr_status_t rc = APR_SUCCESS;
	apr_size_t bytes_written = 0;
	char s_err[128];

	/* (blocking) write the number of bytes in the buffer */
	rc = apr_file_write_full(fd, buf, len, &bytes_written);

	/* check for a system error */
	if (rc != APR_SUCCESS) {
		oidc_error(r, "could not write to: \"%s\" (%s)", path, apr_strerror(rc, s_err, sizeof(s_err)));
		return rc;
	}

	/* check that all bytes from the header were written */
	if (bytes_written != len) {
		oidc_error(r,
			   "could not write enough bytes to: \"%s\", bytes_written (%" APR_SIZE_T_FMT
			   ") != len (%" APR_SIZE_T_FMT ")",
			   path, bytes_written, len);
		return APR_EGENERAL;
	}

	return rc;
}

/*
 * get a value for the specified key from the cache
 */
static apr_byte_t oidc_cache_file_get(request_rec *r, const char *section, const char *key, char **value) {
	apr_file_t *fd = NULL;
	apr_status_t rc = APR_SUCCESS;
	apr_finfo_t finfo;
	char s_err[128];

	/* get the fully qualified path to the cache file based on the key name */
	const char *path = oidc_cache_file_path(r, section, key);

	/* reject links and non-regular files before opening the cache entry (no following here,
	 * unlike the metadata/template reads: this module writes this directory itself and never
	 * creates symlinks in it, so one can only have been planted) */
	if (oidc_util_file_is_regular(r->pool, path, FALSE, &finfo) == FALSE) {
		if (finfo.filetype == APR_NOFILE) {
			oidc_debug(r, "cache miss for key \"%s\"", key);
		} else {
			oidc_warn(r, "ignoring non-regular cache file \"%s\"", path);
		}
		return TRUE;
	}

	/* open the cache file if it exists, otherwise we just have a "regular" cache miss */
	if (apr_file_open(&fd, path, APR_FOPEN_READ | APR_FOPEN_BUFFERED, APR_OS_DEFAULT, r->pool) != APR_SUCCESS) {
		oidc_debug(r, "cache miss for key \"%s\"", key);
		return TRUE;
	}

	/* Shared locks allow concurrent reads while still excluding the expiry cleaner. */
	apr_file_lock(fd, APR_FLOCK_SHARED);

	/* move the read pointer to the very start of the cache file */
	apr_off_t begin = 0;
	apr_file_seek(fd, APR_SET, &begin);
	if ((rc = apr_file_info_get(&finfo, APR_FINFO_SIZE, fd)) != APR_SUCCESS)
		goto error_close;
	if ((finfo.size < (apr_off_t)sizeof(oidc_cache_file_info_t)) ||
	    (finfo.size - (apr_off_t)sizeof(oidc_cache_file_info_t) > (apr_off_t)OIDC_UTIL_FILE_SIZE_MAX)) {
		oidc_error(r, "cache file \"%s\" has an invalid size (%" APR_OFF_T_FMT ")", path, finfo.size);
		rc = APR_EGENERAL;
		goto error_close;
	}

	/* read a header with metadata */
	oidc_cache_file_info_t info;
	if ((rc = oidc_cache_file_read(r, path, fd, &info, sizeof(oidc_cache_file_info_t))) != APR_SUCCESS)
		goto error_close;

	/* check if this cache entry has already expired */
	if (apr_time_now() >= info.expire) {

		/*
		 * Leave expired files for the cleaner. Unlinking here could race a writer's atomic rename
		 * and remove the replacement entry.
		 */
		apr_file_unlock(fd);
		apr_file_close(fd);

		oidc_debug(r, "cache entry \"%s\" expired, leaving file \"%s\" to the cleaning cycle", key, path);

		/* nothing strange happened really */
		return TRUE;
	}
	if ((info.len == 0) || (info.len > OIDC_UTIL_FILE_SIZE_MAX) ||
	    ((apr_off_t)info.len > finfo.size - (apr_off_t)sizeof(oidc_cache_file_info_t))) {
		oidc_error(r, "cache file \"%s\" has an invalid value length (%" APR_SIZE_T_FMT ")", path, info.len);
		rc = APR_EGENERAL;
		goto error_close;
	}

	/* allocate space for the actual value based on the data size info in the header (+1 for \0 termination) */
	*value = apr_palloc(r->pool, info.len + 1);

	/* (blocking) read the requested data in to the buffer */
	rc = oidc_cache_file_read(r, path, fd, (void *)(*value), info.len);

	/* barf on failure */
	if (rc != APR_SUCCESS) {
		oidc_error(r, "could not read cache value from \"%s\"", path);
		goto error_close;
	}

	/* NUL-terminate: an entry this module wrote already ends in \0 within info.len, but a corrupt or
	 * externally-planted cache file may not, and every caller treats the value as a C string */
	(*value)[info.len] = '\0';

	/* we're done, unlock and close the file */
	apr_file_unlock(fd);
	apr_file_close(fd);

	return TRUE;

error_close:

	apr_file_unlock(fd);
	apr_file_close(fd);

	oidc_error(r, "return error status %d (%s)", rc, apr_strerror(rc, s_err, sizeof(s_err)));

	return FALSE;
}

// NB: the file-cache housekeeping parameters are compile-time constants (not configurable)
#define OIDC_CACHE_FILE_LAST_CLEANED "last-cleaned"

/*
 * decide whether a cache cleaning cycle should run and bump the "last cleaned"
 * timestamp; creates the metadata file if it does not yet exist
 * returns TRUE if cleaning should proceed; *rc holds the status to return when FALSE
 */
static apr_byte_t oidc_cache_file_clean_due(request_rec *r, const oidc_cfg_t *cfg, const char *metadata_path,
					    apr_status_t *rc) {
	apr_file_t *fd = NULL;
	apr_finfo_t fi;
	char s_err[128];

	*rc = APR_SUCCESS;

	/* no metadata file yet: create it and proceed with a first cleaning cycle */
	if (apr_stat(&fi, metadata_path, APR_FINFO_MTIME, r->pool) != APR_SUCCESS) {
		if ((*rc = apr_file_open(&fd, metadata_path, (APR_FOPEN_WRITE | APR_FOPEN_CREATE),
					 (APR_FPROT_UREAD | APR_FPROT_UWRITE), r->pool)) != APR_SUCCESS) {
			oidc_error(r, "error creating cache timestamp file '%s' (%s)", metadata_path,
				   apr_strerror(*rc, s_err, sizeof(s_err)));
			return FALSE;
		}
		if ((*rc = apr_file_close(fd)) != APR_SUCCESS) {
			oidc_error(r, "error closing cache timestamp file '%s' (%s)", metadata_path,
				   apr_strerror(*rc, s_err, sizeof(s_err)));
		}
		*rc = APR_SUCCESS;
		return TRUE;
	}

	/* really only clean once per so much time, check that we have not recently run */
	if (apr_time_now() < fi.mtime + apr_time_from_sec(oidc_cfg_cache_file_clean_interval_get(cfg))) {
		oidc_debug(r,
			   "last cleanup call was less than %d seconds ago (next one as early as in %" APR_TIME_T_FMT
			   " secs)",
			   oidc_cfg_cache_file_clean_interval_get(cfg),
			   apr_time_sec(fi.mtime + apr_time_from_sec(oidc_cfg_cache_file_clean_interval_get(cfg)) -
					apr_time_now()));
		return FALSE;
	}

	/* time to clean, reset the mtime of the metadata file to reflect this cleaning cycle */
	apr_file_mtime_set(metadata_path, apr_time_now(), r->pool);

	oidc_debug(r, "start cleaning cycle");

	return TRUE;
}

/*
 * Recheck the device/inode before unlinking so a concurrent atomic replacement is not removed.
 * Platforms without that identity information retain the unconditional cleanup behavior.
 */
static apr_byte_t oidc_cache_file_is_same(request_rec *r, const char *path, const apr_finfo_t *ident,
					  apr_byte_t have_ident) {
	apr_finfo_t current;

	if (have_ident == FALSE)
		return TRUE;

	/* gone already (another cleaner, or a delete): nothing of ours left to remove */
	if (apr_stat(&current, path, APR_FINFO_IDENT, r->pool) != APR_SUCCESS)
		return FALSE;

	if ((current.valid & APR_FINFO_IDENT) != APR_FINFO_IDENT)
		return TRUE;

	return ((current.device == ident->device) && (current.inode == ident->inode)) ? TRUE : FALSE;
}

/*
 * process a single cache directory entry: remove the file if it expired or its header is corrupt
 */
static void oidc_cache_file_clean_entry(request_rec *r, oidc_cfg_t *cfg, const char *metadata_filename,
					const apr_finfo_t *fi) {
	apr_file_t *fd = NULL;
	apr_status_t rc;
	oidc_cache_file_info_t info;
	apr_finfo_t ident;
	apr_byte_t have_ident = FALSE;
	char s_err[128];

	/* skip non-cache entries, cq. the ".", ".." and the metadata file */
	if ((fi->name[0] == OIDC_CHAR_DOT) || (_oidc_strstr(fi->name, OIDC_CACHE_FILE_PREFIX) != fi->name) ||
	    (_oidc_strcmp(fi->name, metadata_filename) == 0))
		return;

	/* get the fully qualified path to the cache file and open it */
	const char *path = apr_psprintf(r->pool, "%s/%s", cfg->cache.file_dir, fi->name);
	if ((rc = apr_file_open(&fd, path, APR_FOPEN_READ, APR_OS_DEFAULT, r->pool)) != APR_SUCCESS) {
		oidc_error(r, "unable to open cache entry \"%s\" (%s)", path, apr_strerror(rc, s_err, sizeof(s_err)));
		return;
	}

	/* note which file this actually is, so that only this one can be removed below */
	have_ident = ((apr_file_info_get(&ident, APR_FINFO_IDENT, fd) == APR_SUCCESS) &&
		      ((ident.valid & APR_FINFO_IDENT) == APR_FINFO_IDENT))
			 ? TRUE
			 : FALSE;

	/* read the header with cache metadata info */
	apr_file_lock(fd, APR_FLOCK_EXCLUSIVE);
	rc = oidc_cache_file_read(r, path, fd, &info, sizeof(oidc_cache_file_info_t));
	apr_file_unlock(fd);
	/* close before removing: Windows will not unlink a file this process still holds open */
	apr_file_close(fd);

	if (rc != APR_SUCCESS) {
		oidc_error(r, "cache entry (%s) corrupted (%s), removing file \"%s\"", fi->name,
			   apr_strerror(rc, s_err, sizeof(s_err)), path);
	} else if (apr_time_now() < info.expire) {
		/* entry is still valid, keep it */
		return;
	} else {
		oidc_debug(r, "cache entry (%s) expired, removing file \"%s\")", fi->name, path);
	}

	/* a writer may have replaced the entry since it was read; only remove the file inspected above */
	if (oidc_cache_file_is_same(r, path, &ident, have_ident) == FALSE) {
		oidc_debug(r, "cache entry (%s) is no longer the file that was inspected, leaving it alone", fi->name);
		return;
	}

	/* delete the cache file */
	if ((rc = apr_file_remove(path, r->pool)) != APR_SUCCESS) {
		/* hrm, this will most probably happen again on the next run... */
		oidc_error(r, "could not delete cache file \"%s\" (%s)", path, apr_strerror(rc, s_err, sizeof(s_err)));
	}
}

/*
 * delete all expired entries from the cache directory
 */
static apr_status_t oidc_cache_file_clean(request_rec *r) {
	apr_status_t rc = APR_SUCCESS;
	apr_dir_t *dir = NULL;
	apr_status_t i;
	apr_finfo_t fi;
	char s_err[128];

	oidc_cfg_t *cfg = ap_get_module_config(r->server->module_config, &auth_openidc_module);

	/* get the path to the metadata file that holds "last cleaned" metadata info */
	const char *metadata_path = oidc_cache_file_path(r, "cache-file", OIDC_CACHE_FILE_LAST_CLEANED);

	/* decide whether we should run a cleaning cycle (and bump the timestamp if so) */
	if (oidc_cache_file_clean_due(r, cfg, metadata_path, &rc) == FALSE)
		return rc;

	/* time to clean, open the cache directory */
	if ((rc = apr_dir_open(&dir, cfg->cache.file_dir, r->pool)) != APR_SUCCESS) {
		oidc_error(r, "error opening cache directory '%s' for cleaning (%s)", cfg->cache.file_dir,
			   apr_strerror(rc, s_err, sizeof(s_err)));
		return rc;
	}

	const char *metadata_filename = oidc_cache_file_name(r, "cache-file", OIDC_CACHE_FILE_LAST_CLEANED);

	/* loop trough the cache file entries */
	do {
		i = apr_dir_read(&fi, APR_FINFO_NAME, dir);
		if (i == APR_SUCCESS)
			oidc_cache_file_clean_entry(r, cfg, metadata_filename, &fi);
	} while (i == APR_SUCCESS);

	apr_dir_close(dir);

	return APR_SUCCESS;
}

/*
 * write a value for the specified key to the cache
 */
static apr_byte_t oidc_cache_file_set(request_rec *r, const char *section, const char *key, const char *value,
				      apr_time_t expiry) {
	apr_file_t *fd = NULL;
	apr_status_t rc = APR_SUCCESS;
	char s_err[128];
	char *rnd = NULL;

	if (oidc_util_rand_str(r, &rnd, 12) == FALSE)
		return FALSE;

	/* get the fully qualified path to the cache file based on the key name */
	const char *target = oidc_cache_file_path(r, section, key);
	const char *path = apr_psprintf(r->pool, "%s.%s.tmp", target, rnd);

	/* only on writes (not on reads) we clean the cache first (if not done recently) */
	oidc_cache_file_clean(r);

	/* just remove cache file if value is NULL */
	if (value == NULL) {
		if ((rc = apr_file_remove(target, r->pool)) != APR_SUCCESS) {
			oidc_error(r, "could not delete cache file \"%s\" (%s)", path,
				   apr_strerror(rc, s_err, sizeof(s_err)));
		}
		return TRUE;
	}

	/* try to open the cache file for writing, creating it if it does not exist; cache entries can
	 * hold session/state secrets, so restrict permissions to the owner only */
	if ((rc = apr_file_open(&fd, path, (APR_FOPEN_WRITE | APR_FOPEN_CREATE), (APR_FPROT_UREAD | APR_FPROT_UWRITE),
				r->pool)) != APR_SUCCESS) {
		oidc_error(r, "cache file \"%s\" could not be opened (%s)", path,
			   apr_strerror(rc, s_err, sizeof(s_err)));
		return FALSE;
	}

	/* lock the file and move the write pointer to the start of it */
	apr_file_lock(fd, APR_FLOCK_EXCLUSIVE);
	apr_off_t begin = 0;
	apr_file_trunc(fd, begin);
	apr_file_seek(fd, APR_SET, &begin);

	/* construct the metadata for this cache entry in the header info */
	oidc_cache_file_info_t info;
	info.expire = expiry;
	info.len = _oidc_strlen(value) + 1;

	/* write the header, then the value; both returns must be checked, since a short
	 * write (a full disk, a quota) would otherwise be reported as a stored entry and
	 * only surface later as an unreadable one */
	if (oidc_cache_file_write(r, path, fd, &info, sizeof(oidc_cache_file_info_t)) != APR_SUCCESS)
		goto error;

	if (oidc_cache_file_write(r, path, fd, (const void *)value, info.len) != APR_SUCCESS)
		goto error;

	/* unlock and close the written file */
	apr_file_unlock(fd);
	apr_file_close(fd);

	if ((rc = apr_file_rename(path, target, r->pool)) != APR_SUCCESS) {
		oidc_error(r, "cache file: %s could not be renamed to: %s (%s)", path, target,
			   apr_strerror(rc, s_err, sizeof(s_err)));
		apr_file_remove(path, r->pool);
		return FALSE;
	}

	oidc_debug(r, "successfully stored entry for key \"%s\" in file of %" APR_SIZE_T_FMT " bytes", key, info.len);

	return TRUE;

error:

	/* drop the partially written temporary file rather than leaving it behind */
	apr_file_unlock(fd);
	apr_file_close(fd);
	apr_file_remove(path, r->pool);

	return FALSE;
}

// clang-format off

oidc_cache_t oidc_cache_file = {
	"file",
	1,
	oidc_cache_file_post_config,
	NULL,
	oidc_cache_file_get,
	oidc_cache_file_set,
	NULL
};

// clang-format on
