From 4bd5de7373a63949d4cec79b8fa710a403a7033a Mon Sep 17 00:00:00 2001 From: wandalen Date: Sat, 21 Dec 2024 15:28:05 +0200 Subject: [PATCH] [batch] full implementation --- Cargo.lock | 38 ++++- Cargo.toml | 1 + process/Cargo.toml | 7 + process/batch.rs | 397 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 process/batch.rs diff --git a/Cargo.lock b/Cargo.lock index ad1ee090..f58931c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chrono-tz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -585,12 +606,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1205,6 +1226,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "pest" version = "2.7.14" @@ -1456,6 +1486,8 @@ name = "posixutils-process" version = "0.2.2" dependencies = [ "bindgen", + "chrono", + "chrono-tz", "clap", "dirs 5.0.1", "gettext-rs", diff --git a/Cargo.toml b/Cargo.toml index d0140c1c..9a0dcff1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ libc = "0.2" regex = "1.10" gettext-rs = { path = "./gettext-rs" } errno = "0.3" +chrono-tz = "0.10.0" [workspace.lints] diff --git a/process/Cargo.toml b/process/Cargo.toml index 683d416b..db18ec6d 100644 --- a/process/Cargo.toml +++ b/process/Cargo.toml @@ -12,7 +12,9 @@ plib = { path = "../plib" } clap.workspace = true gettext-rs.workspace = true libc.workspace = true +chrono.workspace = true dirs = "5.0" +chrono-tz.workspace = true [build-dependencies] bindgen = { version = "0.70.0", features = ["runtime"] } @@ -23,6 +25,11 @@ workspace = true [dev-dependencies] sysinfo = "0.31" + +[[bin]] +name = "batch" +path = "./batch.rs" + [[bin]] name = "fuser" path = "./fuser.rs" diff --git a/process/batch.rs b/process/batch.rs new file mode 100644 index 00000000..4e440ae3 --- /dev/null +++ b/process/batch.rs @@ -0,0 +1,397 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use chrono::{DateTime, Local, TimeZone, Utc}; +use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; +use libc::{getlogin, getpwnam, passwd}; + +use std::{ + collections::HashSet, + env, + ffi::{CStr, CString}, + fs::{self}, + io::{BufRead, Read, Seek, Write}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process, +}; + +#[cfg(target_os = "linux")] +const SPOOL_DIRECTORIES: &[&str] = &[ + "/var/spool/cron/atjobs/", + "/var/spool/at/", + "/var/spool/atjobs/", +]; + +#[cfg(target_os = "macos")] +const MACOS_DIRECTORY: &str = "/var/at/jobs/"; + +#[cfg(target_os = "linux")] +const DEFAULT_DIRECTORY: &str = "/var/spool/atjobs/"; + +fn main() -> Result<(), Box> { + setlocale(LocaleCategory::LcAll, ""); + textdomain("posixutils-rs")?; + bind_textdomain_codeset("posixutils-rs", "UTF-8")?; + + let time = { + let datetime_utc = Utc::now(); + let datetime_local = datetime_utc.with_timezone(&Local); + Utc.from_local_datetime(&datetime_local.naive_local()) + .unwrap() + }; + + let cmd = { + let stdout = std::io::stdout(); + let mut stdout_lock = stdout.lock(); + + writeln!(&mut stdout_lock, "at {}", time.to_rfc2822())?; + write!(&mut stdout_lock, "at> ")?; + stdout_lock.flush()?; + + let stdin = std::io::stdin(); + let mut stdin_lock = stdin.lock(); + + let mut result = Vec::new(); + let mut buf = String::new(); + + while stdin_lock.read_line(&mut buf)? != 0 { + write!(&mut stdout_lock, "at> ")?; + stdout_lock.flush()?; + + result.push(buf.to_owned()); + } + + writeln!(&mut stdout_lock, "")?; + stdout_lock.flush()?; + + result.join("\n") + }; + + let _ = at(Some('b'), &time, cmd, true).inspect_err(|err| print_err_and_exit(1, err)); + + Ok(()) +} + +/// Returns the path to the jobs directory, adjusted for the operating system. +/// On Linux: checks the `AT_JOB_DIR` environment variable, then predefined directories. +/// On macOS: checks or creates the `/var/at/jobs` directory. +fn get_job_dir() -> Result { + // Check `AT_JOB_DIR` environment variable + if let Ok(env_dir) = env::var("AT_JOB_DIR") { + if Path::new(&env_dir).exists() { + return Ok(env_dir); + } + } + #[cfg(target_os = "linux")] + { + // Check the predefined spool directories + for dir in SPOOL_DIRECTORIES { + if Path::new(dir).exists() { + return Ok(dir.to_string()); + } + } + + // Create the default directory if none exist + let default_path = Path::new(DEFAULT_DIRECTORY); + if !default_path.exists() { + if let Err(err) = fs::create_dir_all(default_path) { + return Err(format!( + "Failed to create directory {}: {}", + DEFAULT_DIRECTORY, err + )); + } + } + + Ok(DEFAULT_DIRECTORY.to_string()) + } + #[cfg(target_os = "macos")] + { + let macos_path = Path::new(MACOS_DIRECTORY); + + if !macos_path.exists() { + if let Err(err) = fs::create_dir_all(macos_path) { + return Err(format!( + "Failed to create directory {}: {}", + MACOS_DIRECTORY, err + )); + } + } + + Ok(MACOS_DIRECTORY.to_string()) + } +} + +fn print_err_and_exit(exit_code: i32, err: impl std::fmt::Display) -> ! { + eprintln!("{}", err); + process::exit(exit_code) +} + +fn at( + queue: Option, + execution_time: &DateTime, + cmd: impl Into, + mail: bool, +) -> Result<(), Box> { + let jobno = next_job_id()?; + let job_filename = job_file_name(jobno, queue, execution_time) + .ok_or("Failed to generate file name for job")?; + + let user = User::new().ok_or("Failed to get current user")?; + if !is_user_allowed(&user.name) { + return Err(format!("Access denied for user: {}", &user.name).into()); + } + + let job = Job::new(&user, std::env::current_dir()?, std::env::vars(), cmd, mail).into_script(); + + let mut file_opt = std::fs::OpenOptions::new(); + file_opt.read(true).write(true).create_new(true); + + let file_path = PathBuf::from(format!("{}/{job_filename}", get_job_dir()?)); + + let mut file = file_opt + .open(&file_path) + .map_err(|e| format!("Failed to create file with job. Reason: {e}"))?; + + file.write_all(job.as_bytes())?; + + file.set_permissions(std::fs::Permissions::from_mode(0o700))?; + + println!( + "job {} at {}", + jobno, + execution_time.format("%a %b %d %H:%M:%S %Y") + ); + + Ok(()) +} + +/// Structure to represent future job or script to be saved +pub struct Job { + shell: String, + user_uid: u32, + user_gid: u32, + user_name: String, + env: std::env::Vars, + call_place: PathBuf, + cmd: String, + mail: bool, +} + +impl Job { + pub fn new( + User { + shell, + uid, + gid, + name, + }: &User, + call_place: PathBuf, + env: std::env::Vars, + cmd: impl Into, + mail: bool, + ) -> Self { + Self { + shell: shell.to_owned(), + user_uid: *uid, + user_gid: *gid, + user_name: name.to_owned(), + env, + call_place, + cmd: cmd.into(), + mail, + } + } + + pub fn into_script(self) -> String { + let Self { + shell, + user_uid, + user_gid, + user_name, + env, + call_place, + cmd, + mail, + } = self; + + let env = env + .into_iter() + .map(|(key, value)| format!("{}={}; export {}", key, value, key)) + .collect::>() + .join("\n"); + + format!( + "#!{shell}\n# atrun uid={user_uid} gid={user_gid}\n# mail {user_name} {}\numask 22\n{env}\ncd {} || {{\n\techo 'Execution directory inaccessible' >&2\n\texit 1 \n}}\n{cmd}", + if mail {1} else {0}, + call_place.to_string_lossy() + ) + } +} + +/// Return name for job number +/// +/// None if DateTime < [DateTime::UNIX_EPOCH] +fn job_file_name(next_job: u32, queue: Option, time: &DateTime) -> Option { + let duration = time.signed_duration_since(DateTime::UNIX_EPOCH); + let duration_seconds = u32::try_from(duration.num_seconds()).ok()? / 60; + let queue = queue.unwrap_or('a'); + let result = format!("{queue}{next_job:05x}{duration_seconds:08x}"); + + Some(result) +} + +#[derive(Debug)] +pub enum NextJobError { + Io(std::io::Error), + FromStr(std::num::ParseIntError), +} + +impl std::fmt::Display for NextJobError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error reading id of next job. Reason: ")?; + match self { + NextJobError::Io(err) => writeln!(f, "{err}"), + NextJobError::FromStr(err) => writeln!(f, "invalid number - {err}"), + } + } +} + +impl std::error::Error for NextJobError {} + +fn next_job_id() -> Result> { + let mut file_opt = std::fs::OpenOptions::new(); + file_opt.read(true).write(true); + + let mut buf = String::new(); + let job_file_number = format!("{}.SEQ", get_job_dir()?); + + let (next_job_id, mut file) = match file_opt.open(&job_file_number) { + Ok(mut file) => { + file.read_to_string(&mut buf) + .map_err(|e| NextJobError::Io(e))?; + file.rewind().map_err(|e| NextJobError::Io(e))?; + + ( + u32::from_str_radix(buf.trim_end_matches("\n"), 16) + .map_err(|e| NextJobError::FromStr(e))?, + file, + ) + } + Err(err) => match err.kind() { + std::io::ErrorKind::NotFound => ( + 0, + std::fs::File::create_new(job_file_number).map_err(NextJobError::Io)?, + ), + + _ => Err(NextJobError::Io(err))?, + }, + }; + + // Limit range of jobs to 2^20 jobs + let next_job_id = (1 + next_job_id) % 0xfffff; + + file.write_all(format!("{next_job_id:05x}").as_bytes()) + .map_err(NextJobError::Io)?; + + Ok(next_job_id) +} + +fn read_user_file(file_path: &str) -> std::io::Result> { + let content = std::fs::read_to_string(file_path)?; + Ok(content + .lines() + .map(|line| line.trim().to_string()) + .collect()) +} + +fn is_user_allowed(user: &str) -> bool { + let allow_file = "/etc/at.allow"; + let deny_file = "/etc/at.deny"; + + if let Ok(allowed_users) = read_user_file(allow_file) { + // If at.allow exists, only users from this file have access + return allowed_users.contains(user); + } + + if let Ok(denied_users) = read_user_file(deny_file) { + // If there is no at.allow, but there is at.deny, check if the user is blacklisted + return !denied_users.contains(user); + } + + // If there are no files, access is allowed to all + true +} + +fn login_name() -> Option { + // Try to get the login name using getlogin + unsafe { + let login_ptr = getlogin(); + if !login_ptr.is_null() { + if let Ok(c_str) = CStr::from_ptr(login_ptr).to_str() { + return Some(c_str.to_string()); + } + } + } + + // Fall back to checking the LOGNAME environment variable + env::var("LOGNAME").ok() +} + +pub struct User { + pub name: String, + pub shell: String, + pub uid: u32, + pub gid: u32, +} + +impl User { + pub fn new() -> Option { + const DEFAULT_SHELL: &str = "/bin/sh"; + + let login_name = login_name()?; + + let passwd { + pw_uid, + pw_gid, + pw_shell, + .. + } = user_info_by_name(&login_name)?; + + let pw_shell = match pw_shell.is_null() { + true => std::env::var("SHELL") + .ok() + .unwrap_or(DEFAULT_SHELL.to_owned()), + false => unsafe { + CStr::from_ptr(pw_shell) + .to_str() + .ok() + .unwrap_or(DEFAULT_SHELL) + .to_owned() + }, + }; + + Some(Self { + shell: pw_shell, + uid: pw_uid, + gid: pw_gid, + name: login_name, + }) + } +} + +fn user_info_by_name(name: &str) -> Option { + let c_name = CString::new(name).unwrap(); + let pw_ptr = unsafe { getpwnam(c_name.as_ptr()) }; + if pw_ptr.is_null() { + None + } else { + Some(unsafe { *pw_ptr }) + } +}