#!/bin/sh

# NAME:
#	newlog - rotate log files
#
# SYNOPSIS:
#	newlog.sh [options] "log"[:"num"] ...
#
# DESCRIPTION:
#	This script saves multiple generations of each "log".
#	The "logs" are kept compressed except for the current and
#	previous ones.
#
#	Options:
#
#	-C "compress"
#		Compact old logs (other than .0) with "compress"
#		(default is "$NEWLOG_COMPRESS" 'gzip' or 'compress' if
#		no 'gzip').
#
#	-E "ext"
#		If "compress" produces a file extention other than
#		'.Z' or '.gz' we need to know ("$NEWLOG_EXT").
#
#	-G "gens"
#		"gens" is a comma separated list of "log":"num" pairs
#		that allows certain logs to handled differently.
#
#	-N	Don't actually do anything, just show us.
#
#	-R	Rotate rather than save logs by default.
#		This is the default anyway.
#
#	-S	Save rather than rotate logs by default.
#		Each log is saved to a unique name that remains
#		unchanged.  This results in far less churn.
#
#	-f "fmt"
#		Format ('%Y%m%d.%H%M%S') for suffix added to "log" to
#		uniquely name it when using the '-S' option.
#		If a "log" is saved more than once per second we add
#		an extra suffix of our process-id.
#		The default can be set in the env via "$NEWLOG_FMT".
#
#	-d	The "log" to be rotated/saved is a directory.
#		We leave the mode of old directories alone.
#
#	-e	Normally logs are only cycled if non-empty, this
#		option forces empty logs to be cycled as well.
#
#	-g "group"
#		Set the group of "log" to "group".
#
#	-m "mode"
#		Set the mode of "log" ("$NEWLOG_MODE").
#
#	-M "mode"
#		Set the mode of old logs (default "$NEWLOG_OLD_MODE"
#		or 444).
#
#	-n "num"
#		Keep "num" generations of "log" ("$NEWLOG_NUM").
#
#	-o "owner"
#		Set the owner of "log".
#
#	The default method for dealing with logs can be set via
#	"$NEWLOG_METHOD" ('save' or 'rotate').
#	Regardless of "$NEWLOG_METHOD" or whether '-R' or '-S' is
#	provided, we attempt to choose the correct behavior based on
#	observation of "log.0" if it exists; if it is a symbolic link,
#	we 'save', otherwise we 'rotate'.
#
# BUGS:
#	'Newlog.sh' tries to avoid being fooled by symbolic links, but
#	multiply indirect symlinks are only handled on machines where
#	test(1) supports a check for symlinks.
#
# AUTHOR:
#	Simon J. Gerraty <sjg@crufty.net>
#

# RCSid:
#	$Id: newlog.sh,v 1.31 2025/08/07 22:07:13 sjg Exp $
#
#	@(#) Copyright (c) 1993-2025 Simon J. Gerraty
#
#	SPDX-License-Identifier: BSD-2-Clause
#
#	Please send copies of changes and bug-fixes to:
#	sjg@crufty.net
#

Mydir=`dirname $0`
case $Mydir in
/*) ;;
*) Mydir=`cd $Mydir; pwd`;;
esac

# places to find chown (and setopts.sh)
PATH=$PATH:/usr/etc:/sbin:/usr/sbin:/usr/local/share/bin:/share/bin:$Mydir

# linux doesn't necessarily have compress,
# and gzip appears in various locations...
Which() {
	case "$1" in
	-*) t=$1; shift;;
	*) t=-x;;
	esac
	case "$1" in
	/*)	test $t $1 && echo $1;;
	*)
		for d in `IFS=:; echo ${2:-$PATH}`
		do
			test $t $d/$1 && { echo $d/$1; break; }
		done
		;;
	esac
}

# shell's typically have test(1) as built-in
# and not all support all options.
test_opt() {
    _o=$1
    _a=$2
    _t=${3:-/}
    
    case `test -$_o $_t 2>&1` in
    *:*) eval test_$_o=$_a;;
    *) eval test_$_o=-$_o;;
    esac
}

# convert find/ls mode to octal
fmode() {
	eval `echo $1 |
		sed 's,\(.\)\(...\)\(...\)\(...\),ft=\1 um=\2 gm=\3 om=\4,'`
	sm=
	case "$um" in
	*s*)	sm=r
		um=`echo $um | sed 's,s,x,'`
		;;
	*)	sm=-;;
	esac
	case "$gm" in
	*[Ss]*)
		sm=${sm}w
		gm=`echo $gm | sed 's,s,x,;s,S,-,'`
		;;
	*)	sm=${sm}-;;
	esac
	case "$om" in
	*t)
		sm=${sm}x
		om=`echo $om | sed 's,t,x,'`
		;;
	*)	sm=${sm}-;;
	esac
	echo $sm $um $gm $om |
	sed 's,rwx,7,g;s,rw-,6,g;s,r-x,5,g;s,r--,4,g;s,-wx,3,g;s,-w-,2,g;s,--x,1,g;s,---,0,g;s, ,,g'
}

get_mode() {
	case "$OS,$STAT" in
	FreeBSD,*)
		$STAT -f %Op $1 | sed 's,.*\(....\),\1,'
		return
		;;
	Linux,$STAT)		# works on Ubuntu
		$STAT -c %a $1 2> /dev/null &&
		return
		;;
	esac
	# fallback to find
	fmode `find $1 -ls -prune | awk '{ print $3 }'`
}

get_mtime_suffix() {
	case "$OS,$STAT" in
	FreeBSD,*)
		$STAT -t "${2:-$opt_f}" -f %Sm $1
		return
		;;
	Linux,*)		# works on Ubuntu
		mtime=`$STAT --format=%Y $1 2> /dev/null`
		if [ ${mtime:-0} -gt 1 ]; then
			date --date=@$mtime "+${2:-$opt_f}" 2> /dev/null &&
			return
		fi
		;;
	esac
	# this will have to do
	date "+${2:-$opt_f}"
}

case /$0 in
*/newlog*) rotate_func=${NEWLOG_METHOD:-rotate_log};;
*/save*) rotate_func=save_log;;
*) rotate_func=${NEWLOG_METHOD:-rotate_log};;
esac
case "$rotate_func" in
save|rotate) rotate_func=${rotate_func}_log;;
esac

opt_C=${NEWLOG_COMPRESS}
opt_E=${NEWLOG_EXT}
opt_n=${NEWLOG_NUM:-7}
opt_m=${NEWLOG_MODE}
opt_M=${NEWLOG_OLD_MODE:-444}
opt_f=${NEWLOG_FMT:-%Y-%m-%dT%T} # rfc3339
opt_str=dNn:o:g:G:C:M:m:eE:f:RS

. setopts.sh

test $# -gt 0 || exit 0	# nothing to do.

OS=${OS:-`uname`}
STAT=${STAT:-`Which stat`}

# sorry, setops semantics for booleans changed.
case "${opt_d:-0}" in
0)	rm_f=-f
	opt_d=-f
	for x in $opt_C gzip compress
	do
		opt_C=`Which $x "/bin:/usr/bin:$PATH"`
		test -x $opt_C && break
	done
	empty() { test ! -s $1; }
	;;
*)	rm_f=-rf
	opt_d=-d
	opt_M=
	opt_C=:
	empty() { 
	    if [ -d $1 ]; then
		n=`'ls' -a1 $1/. | wc -l`
		[ $n -gt 2 ] && return 1
	    fi
	    return 0
	}
	;;
esac
case "${opt_N:-0}" in
0)	ECHO=;;
*)	ECHO=echo;;
esac
case "${opt_e:-0}" in
0)	force=;;
*)	force=yes;;
esac
case "${opt_R:-0}" in
0) ;;
*) rotate_func=rotate_log;;
esac
case "${opt_S:-0}" in
0) ;;
*) rotate_func=save_log;;
esac

# see whether test handles -h or -L
test_opt L -h
test_opt h ""
case "$test_L,$test_h" in
-h,) test_L= ;;			# we don't support either!
esac

case "$test_L" in
"")	# No, so this is about all we can do...
	logs=`'ls' -ld $* | awk '{ print $NF }'`
	;;
*)	# it does
	logs="$*"
	;;
esac

read_link() {
	case "$test_L" in
	"")	'ls' -ld $1 | awk '{ print $NF }'; return;;
	esac
	if test $test_L $1; then
		'ls' -ld $1 | sed 's,.*> ,,'
	else
		echo $1
	fi
}

# create the new log
new_log() {
	log=$1
	mode=$2
	if test "x$opt_M" != x; then
		$ECHO chmod $opt_M $log.0 2> /dev/null
	fi
	# someone may have managed to write to it already
	# so don't truncate it.
	case "$opt_d" in
	-d) $ECHO mkdir -p $log;;
	*) $ECHO touch $log;;
	esac
	# the order here matters
	test "x$opt_o" = x || $ECHO chown $opt_o $log
	test "x$opt_g" = x || $ECHO chgrp $opt_g $log
	test "x$mode" = x || $ECHO chmod $mode $log
}

rotate_log() {
	log=$1
	n=${2:-$opt_n}

	# make sure excess generations are trimmed
	$ECHO rm $rm_f `echo $log.$n | sed 's/\([0-9]\)$/[\1-9]*/'`

	mode=${opt_m:-`get_mode $log`}
	while test $n -gt 0
	do
		p=`expr $n - 1`
		if test -s $log.$p; then
			$ECHO rm $rm_f $log.$p.*
			$ECHO $opt_C $log.$p
			if test "x$opt_M" != x; then
				$ECHO chmod $opt_M $log.$p.* 2> /dev/null
			fi
		fi
		for ext in $opt_E .gz .Z ""
		do
			test $opt_d $log.$p$ext || continue
			$ECHO mv $log.$p$ext $log.$n$ext
		done
		n=$p
	done
	# leave $log.0 uncompressed incase some one still has it open.
	$ECHO mv $log $log.0
	new_log $log $mode
}

# unlike rotate_log we do not rotate files,
# but give each log a unique (but stable name).
# This avoids churn for folk who rsync things.
# We make log.0 a symlink to the most recent log
# so it can be found and compressed next time around.
save_log() {
	log=$1
	n=${2:-$opt_n}
	fmt=$3

	last=`read_link $log.0`
	case "$last" in
	$log.0) # should never happen
		test -s $last && $ECHO mv $last $log.$$;;
	$log.*)
		$ECHO $opt_C $last
		;;
	*.*)	$ECHO $opt_C `dirname $log`/$last
		;;
	esac
	$ECHO rm -f $log.0
	# remove excess logs - we rely on mtime!
	$ECHO rm $rm_f `'ls' -1td $log.* 2> /dev/null | sed "1,${n}d"`

	mode=${opt_m:-`get_mode $log`}
	suffix=`get_mtime_suffix $log $fmt`

	# find a unique name to save current log as
	for nlog in $log.$suffix $log.$suffix.$$
	do
		for f in $nlog*
		do
			break
		done
		test $opt_d $f || break
	done
	# leave $log.0 uncompressed incase some one still has it open.
	$ECHO mv $log $nlog
	test "x$opt_M" = x || $ECHO chmod $opt_M $nlog 2> /dev/null
	$ECHO ln -s `basename $nlog` $log.0
	new_log $log $mode
}

for f in $logs
do
	n=$opt_n
	save=
	case "$f" in
	*:[1-9]*)
		set -- `IFS=:; echo $f`; f=$1; n=$2;;
	*:n=*|*:save=*)
		eval `echo "f=$f" | tr ':' ' '`;;
	esac
	# try and pick the right function to use
	rfunc=$rotate_func	# default
	if test $opt_d $f.0; then
		case `read_link $f.0` in
		$f.0) rfunc=rotate_log;;
		*) rfunc=save_log;;
		esac
	fi
	case "$test_L" in
	-?)
		while test $test_L $f	# it is [still] a symlink
		do
			f=`read_link $f`
		done
		;;
	esac
	case ",${opt_G}," in
	*,${f}:n=*|,${f}:save=*)
		eval `echo ",${opt_G}," | sed "s!.*,${f}:\([^,]*\),.*!\1!;s,:, ,g"`
		;;
	*,${f}:*)
		# opt_G is a , separated list of log:n pairs
		n=`echo ,$opt_G, | sed -e "s,.*${f}:\([0-9][0-9]*\).*,\1,"`
		;;
	esac

	if empty $f; then
		test "$force" || continue
	fi

	test "$save" && rfunc=save_log

	$rfunc $f $n $save
done
