/* greylisting-spp - A qmail-spp plugin implementing greylisting
 *
 * db-file.c
 *
 *  Copyright (C) 2004,2011 Peter Conrad <conrad@tivano.de>
 *
 *  This program is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License (version 2) as
 *  published by the Free Software Foundation.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program; if not, write to the Free Software
 *  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */

#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <time.h>
#include "db-api.h"
#include "commonstuff.h"

static int db_file;

static struct flock my_lock;

#define ERR_CANT_OPEN_FILE	"Can't open database file \""
#define ERR_CANT_LOCK_FILE	"Can't lock database file \""
#define ERR_CANT_READ_VERSION	"Can't read version line from file \""
#define ERR_VERSION_MISMATCH	"Version mismatch in file \""
#define ERR_WRITING_TIMESTAMP	"Couldn't write timestamp: "
#define ERR_WRITING_FLAG	"Couldn't write flag: "
#define ERR_WRITING_ENTRY	"Couldn't write entry: "
#define ERR_READING_ENTRY	"Couldn't read entry: "
#define ERR_PARSING_FAILED	"Couldn't parse entry!\n"
#define ERR_WRITING		"Couldn't write: "

#define VERSION_LINE		"# Greylisting Version 1.0\n"

static off_t found_offset, longest_comment_offset = -1;
static int longest_comment_length = 0;

/* This file contains functions for manipulating the greylisting database.
 * The database is a plaintext file with one entry per line. Lines end with
 * LF. Lines starting with a '#' are regarded as comment lines and are
 * ignored. Empty lines are allowed, and are ignored as well.
 * The first line of the file is a comment line containing the string
 * "# Greylisting Version 1.0\n".
 * All other lines start with an 8 digit hex representation of the time when
 * the entry was last modified. The 9th character of each line is either
 * '+' (indicating a previous successful delivery) or '-' (indicating an
 * unsuccessful delivery attempt). The remainder of the line is a
 * comma-separated tripel of the ip number of the sending MTA, the email
 * envelope sender address and the envelope recipient address.
 */

/** Releases the lock on db_file and closes it, ignoring any errors.
 */
void closedb() {
    my_lock.l_type = F_UNLCK;
    fcntl(db_file, F_SETLK, &my_lock);
    close(db_file);
}

/** Write the read error message to stderr and exit with status 1. */
static void read_error() {
char *error = strerror(errno);

    write_error_string(progname);
    write_error_string(": ");
    write_error_string(ERR_READING_ENTRY);
    write_error_string(error);
    write_error_string("\n");
    closedb();
    exit(1);
}

/** Write the write error message to stderr and exit with status 1. */
static void write_error() {
char *error = strerror(errno);

    write_error_string(progname);
    write_error_string(": ");
    write_error_string(ERR_WRITING);
    write_error_string(error);
    write_error_string("\n");
    closedb();
    exit(1);
}

/** Write the parse error message to stderr and exit with status 1. */
static void parse_error() {
    write_error_string(progname);
    write_error_string(": ");
    write_error_string(ERR_PARSING_FAILED);
    closedb();
    exit(1);
}

/** Opens the given file in read/write mode, acquires an fcntl write lock
 *  on it and reads the version string from the file.
 */
void opendb(char *filename) {
char buf[strlen(VERSION_LINE) + 1];
int rc;

    db_file = open(filename, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR);
    if (db_file < 0) {
	char *error = strerror(errno);
	write_error_string(progname);
	write_error_string(": ");
	write_error_string(ERR_CANT_OPEN_FILE);
	write_error_string(filename);
	write_error_string("\": ");
	write_error_string(error);
	write_error_string("\n");
	exit(1);
    }

    my_lock.l_type = F_WRLCK;
    my_lock.l_whence = SEEK_SET;
    my_lock.l_start = 0;
    my_lock.l_len = 0;
    if (fcntl(db_file, F_SETLK, &my_lock) < 0) {
	char *error = strerror(errno);
	write_error_string(progname);
	write_error_string(": ");
	write_error_string(ERR_CANT_LOCK_FILE);
	write_error_string(filename);
	write_error_string("\": ");
	write_error_string(error);
	write_error_string("\n");
	close(db_file);
	exit(1);
    }

    rc = read(db_file, buf, strlen(VERSION_LINE));
    if (rc == 0) {
	/* Empty file - probably just created */
	if (write(db_file, VERSION_LINE, strlen(VERSION_LINE))
		!= strlen(VERSION_LINE)) {
	    write_error();
	}
    } else if (rc != strlen(VERSION_LINE)) {
	char *error = strerror(errno);
	write_error_string(progname);
	write_error_string(": ");
	write_error_string(ERR_CANT_READ_VERSION);
	write_error_string(filename);
	write_error_string("\": ");
	write_error_string(error);
	write_error_string("\n");
	closedb();
	exit(1);
    } else if (strncmp(VERSION_LINE, buf, strlen(VERSION_LINE))) {
	write_error_string(progname);
	write_error_string(": ");
	write_error_string(ERR_VERSION_MISMATCH);
	write_error_string(filename);
	write_error_string("\"\n");
	closedb();
	exit(1);
    }
}

/** Writes to db_file 8 hex digits representing the current time.
 *  If an error occurs during writing, an error message is sent to stderr and
 *  the program exits.
 */
static void write_timestamp() {
char ts[8];
time_t now = time(NULL);
int i = 7;

    while (now) {
	int digit = now & 0xf;
	if (digit < 10) {
	    ts[i] = '0' + digit;
	} else {
	    ts[i] = 'a' + digit - 10;
	}
	i--;
	now >>= 4;
    }
    while (i >= 0) { ts[i--] = '0'; }
    if ((i = write(db_file, ts, 8)) != 8) {
	char *error = strerror(errno);
	write_error_string(progname);
	write_error_string(": ");
	write_error_string(ERR_WRITING_TIMESTAMP);
	write_error_string(error);
	write_error_string("\n");
	closedb();
	exit(1);
    }
}

/** Writes to db_file the given 1 character flag.
 *  If an error occurs during writing, an error message is sent to stderr and
 *  the program exits.
 */
static void write_flag(char *flag) {
    if (write(db_file, flag, 1) != 1) {
	char *error = strerror(errno);
	write_error_string(progname);
	write_error_string(": ");
	write_error_string(ERR_WRITING_FLAG);
	write_error_string(error);
	write_error_string("\n");
	closedb();
	exit(1);
    }
}

/** Compares strings like strncmp, except that non-printable chars, control
 *  chars and ',' are compared as hyphens.
 *  Returns -1, 0 or 1.
 */
static int safe_strncmp(char *s1, char *s2, int maxlen) {
char c1, c2;

    while (maxlen-- > 0) {
	c1 = *s1++;
	c2 = *s2++;
	if (c1 == 0 && c2 != 0) {
	    return -1;
	}
	if (c1 != 0 && c2 == 0) {
	    return 1;
	}
	if (c1 < ' ' || c1 > 126 || c1 == ',') { c1 = '-'; }
	if (c2 < ' ' || c2 > 126 || c2 == ',') { c2 = '-'; }
	if (c1 < c2) { return -1; }
	if (c1 > c2) { return 1; }
    }

    return 0;
}

/** Searches the database for the given entry.
 *  Returns a negative number if no matching entry was found.
 *  Returns 0 if a matching entry was found but the min_reject interval has
 *  not yet expired.
 *  Otherwise, 1 is returned.
 *  As side effects, some expired entries are turned into comments. The
 *  offset of the start of the longest sequence of comments and empty lines
 *  is noted for a possible subsequent "add_entry".
 */
int find_entry(char *ip, char *sender, char *recipient) {
off_t current_offset = strlen(VERSION_LINE);
off_t current_comment_offset = -1;
int current_comment_length = 0;
int eof = 0, i;
int iplen = strlen(ip), senderlen = strlen(sender), rcptlen = strlen(recipient);
int otherbuflen = iplen + senderlen + rcptlen + 3;
char buf[9], *otherbuf;
time_t now = time(NULL), ts;

    otherbuf = (char *) malloc(otherbuflen);
    if (!otherbuf) {
	err_memory();
    }

    while (!eof) {
	off_t start_of_line = current_offset;
	int rc = read(db_file, buf, 1);
	if (rc == 0) {
	    eof = 1;
	    break;
	}
	if (rc < 0) {
	    read_error();
	}
	current_offset++;

	if (buf[0] != '\n' && buf[0] != '#') {
	    rc = read(db_file, &buf[1], 8);
	    if (rc != 8) {
		read_error();
	    }
	    current_offset += 8;
	    /* parse timestamp */
	    ts = 0;
	    for (i = 0; i < 8; i++) {
		ts <<= 4;
		if (buf[i] >= '0' && buf[i] <= '9') {
		    ts += buf[i] - '0';
		} else if (buf[i] >= 'a' && buf[i] <= 'f') {
		    ts += buf[i] - 'a' + 10;
		} else {
		    parse_error();
		}
	    }
	    /* Check expired entry */
	    if (('+' == buf[8] && now - ts > accept_good)
		    || ('-' == buf[8] && now - ts > max_wait)) {
		/* expire */
		if (lseek(db_file, -9, SEEK_CUR) < 0) {
		    read_error();
		}
		buf[0] = '#';
		if (write(db_file, buf, 1) != 1) {
		    write_error();
		}
		current_offset -= 8;
	    }
	}

	if (buf[0] == '\n' || buf[0] == '#') {
	    if (current_comment_offset < 0) {
		current_comment_offset = start_of_line;
	    } 
	    current_comment_length++;
	    while (buf[0] != '\n' && !eof) {
		rc = read(db_file, buf, 1);
		if (rc == 0) { eof = 1; }
		else if (rc < 0) {
		    read_error();
		} else {
		    current_comment_length++;
		    current_offset++;
		}
	    }
	} else {
	    if (current_comment_offset >= 0) {
		if (current_comment_length > longest_comment_length) {
		    longest_comment_offset = current_comment_offset;
		    longest_comment_length = current_comment_length;
		}
		current_comment_offset = -1;
		current_comment_length = 0;
	    }
	    if ((rc = read(db_file, otherbuf, otherbuflen)) <= 0) {
		read_error();
	    } else if (rc < otherbuflen) {
		/* Incomplete read -> the current line is shorter than what
		 * we expect -> return "no match found"*/
		return -1;
	    } else if (otherbuf[otherbuflen - 1] == '\n'
		       && otherbuf[iplen] == ','
		       && otherbuf[iplen + senderlen + 1] == ','
		       && !safe_strncmp(otherbuf, ip, iplen)
		       && !safe_strncmp(&otherbuf[iplen + 1], sender, senderlen)
		       && !safe_strncmp(&otherbuf[iplen + senderlen + 2],
					recipient, rcptlen)) {
		/* Found a match -> if min_reject has passed, return 1
		 * offset, otherwise 0. */
		if (buf[8] == '+' || now - ts >= min_reject) {
		    found_offset = start_of_line;
		    return 1;
		}
		return 0;
	    }
	    /* Current line does not match -> find LF marking the end */
	    for (i = 0; i < otherbuflen && otherbuf[i] != '\n'; i++) {
		current_offset++;
	    }
	    if (i < otherbuflen) {
		/* LF is within otherbuf -> seek back to the character following
		 * that LF */
		i++;
		current_offset++;
		if (i < otherbuflen) {
		    if (lseek(db_file, i - otherbuflen, SEEK_CUR) < 0) {
			read_error();
		    }
		}
	    } else {
		/* No LF in otherbuf -> continue reading until LF is found */
		do {
		    rc = read(db_file, buf, 1);
		    if (rc == 0) { eof = 1; }
		    else if (rc < 0) { read_error(); }
		    current_offset++;
		} while (buf[0] != '\n');
	    }
	}
    }

    free(otherbuf);

    if (current_comment_offset > 0) {
	ftruncate(db_file, current_comment_offset);
    }

    return -1;
}

/** Updates the entry at the last found offset by setting the timestamp to the
 *  current time and the flag to '+'.
 */
void update_entry() {
    lseek(db_file, found_offset, SEEK_SET);
    write_timestamp();
    write_flag("+");
}

/** "Deletes" (i. e. comments it out) the entry at the last found offset.
 */
void delete_entry() {
    lseek(db_file, found_offset, SEEK_SET);
    write_flag("#"); /* Hm, abusing write_flag here... */
}

/** Write the given string to the given file, replacing "dangerous" characters
 *  with a hyphen.
 *  Return the number of bytes written.
 */
static int safe_write(int outfile, char *str) {
int i = 0, rc;

    while (*str) {
	if (*str < ' ' || *str == ',' || *str > 126) {
	    rc = write(outfile, "-", 1);
	} else {
	    rc = write(outfile, str, 1);
	}
	if (rc != 1) { write_error(); }
	str++;
	i++;
    }

    return i;
}

/** Adds a new entry to the file.
 */
void add_entry(char *ip, char *sender, char *recipient) {
int entrylen = 8 + 1 + strlen(ip) + 1 + strlen(sender) + 1 + strlen(recipient)
	       + 1;

    if (longest_comment_offset < 0 || longest_comment_length < entrylen) {
	/* Append to EOF */
	lseek(db_file, 0, SEEK_END);
    } else {
	/* Overwrite comment at longest_comment_offset */
	lseek(db_file, longest_comment_offset, SEEK_SET);
    }
    write_timestamp();
    write_flag("-");
    safe_write(db_file, ip);
    if (write(db_file, ",", 1) != 1) { write_error(); }
    safe_write(db_file, sender);
    if (write(db_file, ",", 1) != 1) { write_error(); }
    safe_write(db_file, recipient);
    if (write(db_file, "\n", 1) != 1) { write_error(); }
    if (longest_comment_offset >= 0 && longest_comment_length > entrylen + 1) {
	if (write(db_file, "#", 1) != 1) { write_error(); }
    }
}

/* Do not change the following line:
 * arch-tag: 9f01cee2-59b9-4a4d-8790-3d7785415ed8
 */
