diff --git a/Cargo.lock b/Cargo.lock index d2edf7c..5cbea54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,21 @@ dependencies = [ "once_cell", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -242,6 +257,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "displaydoc" version = "0.2.5" @@ -271,6 +292,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -325,15 +352,19 @@ name = "findutils" version = "0.8.0" dependencies = [ "argmax", + "assert_cmd", "chrono", "clap", "ctor", "faccess", "filetime", + "itertools", "nix 0.31.3", "onig", "regex", + "rstest", "tempfile", + "thiserror", "uucore", "uutests", "walkdir", @@ -390,6 +421,48 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -498,6 +571,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -688,6 +770,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.32" @@ -715,6 +803,33 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -735,6 +850,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -805,6 +929,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "rlimit" version = "0.11.0" @@ -814,12 +944,51 @@ dependencies = [ "libc", ] +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -909,6 +1078,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -965,6 +1140,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -1029,6 +1210,36 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "type-map" version = "0.5.1" @@ -1135,6 +1346,15 @@ dependencies = [ "xattr", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1424,6 +1644,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index fbb0a86..ee957bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,18 +12,22 @@ authors = ["uutils developers"] [dependencies] argmax = "0.4.0" chrono = "0.4.45" -clap = "4.6" +clap = { version = "4.6", features = ["env"] } faccess = "0.2.4" nix = { version = "0.31", features = ["fs", "user"] } onig = { version = "6.5", default-features = false } regex = "1.12" uucore = { version = "0.9.0", features = ["entries", "fs", "fsext", "mode"] } walkdir = "2.5" +itertools = "0.14.0" +thiserror = "2.0.12" [dev-dependencies] +assert_cmd = "2" ctor = "1.0" filetime = "0.2" nix = { version = "0.31", features = ["fs"] } +rstest = "0.25.0" tempfile = "3" uutests = "0.9.0" @@ -31,6 +35,14 @@ uutests = "0.9.0" name = "find" path = "src/find/main.rs" +[[bin]] +name = "locate" +path = "src/locate/main.rs" + +[[bin]] +name = "updatedb" +path = "src/updatedb/main.rs" + [[bin]] name = "xargs" path = "src/xargs/main.rs" diff --git a/src/find/matchers/mod.rs b/src/find/matchers/mod.rs index 79d1a8f..af8c15f 100644 --- a/src/find/matchers/mod.rs +++ b/src/find/matchers/mod.rs @@ -74,6 +74,7 @@ use std::{ use super::{Config, Dependencies}; pub use entry::{FileType, WalkEntry, WalkError}; +pub use regex::RegexType; /// Symlink following mode. #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/src/lib.rs b/src/lib.rs index 9f959d0..44e37ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,4 +5,7 @@ // https://opensource.org/licenses/MIT. pub mod find; +#[cfg(unix)] +pub mod locate; +pub mod updatedb; pub mod xargs; diff --git a/src/locate/main.rs b/src/locate/main.rs new file mode 100644 index 0000000..ca9f3b5 --- /dev/null +++ b/src/locate/main.rs @@ -0,0 +1,18 @@ +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#[cfg(not(windows))] +fn main() { + let args = std::env::args().collect::>(); + let strs: Vec<&str> = args.iter().map(std::convert::AsRef::as_ref).collect(); + std::process::exit(findutils::locate::locate_main(strs.as_slice())); +} + +#[cfg(windows)] +fn main() { + // TODO: locate currently uses UNIX-specific OsString APIs. If those can be worked around, locate + // should function normally on Windows. If and when that happens, make sure to make a separate + // windows test database with \ instead of /. + println!("locate is unsupported on Windows"); +} diff --git a/src/locate/mod.rs b/src/locate/mod.rs new file mode 100644 index 0000000..4dc2ef4 --- /dev/null +++ b/src/locate/mod.rs @@ -0,0 +1,713 @@ +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::{ + borrow::Cow, + env, + ffi::{CStr, CString, OsStr}, + fs::{self, File}, + io::{self, stderr, BufRead, BufReader, Read, Write}, + os::unix::{ffi::OsStrExt, fs::MetadataExt}, + path::{Path, PathBuf}, + str::FromStr, +}; + +use chrono::{DateTime, Local, TimeDelta}; +use clap::{self, crate_version, value_parser, Arg, ArgAction, ArgMatches, Command, Id}; +use itertools::Itertools; +use onig::{Regex, RegexOptions, Syntax}; +use thiserror::Error; +use uucore::error::{ClapErrorWrapper, UClapError, UError, UResult}; + +use crate::{find::matchers::RegexType, updatedb::DbFormat}; + +#[derive(Debug)] +pub struct Config { + all: bool, + basename: bool, + mode: Mode, + db: Vec, + existing: ExistenceMode, + follow_symlinks: bool, + ignore_case: bool, + limit: Option, + max_age: usize, + null_bytes: bool, + print: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Mode { + #[default] + Normal, + Count, + Statistics, +} + +#[derive(Debug, Clone, Copy, Default)] +pub enum ExistenceMode { + #[default] + Any, + Present, + NotPresent, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("no matches found")] + NoMatches, + #[error("Unknown database type")] + InvalidDbType, + #[error("locate database {0} is corrupt or invalid")] + InvalidDb(String), + #[error("invalid regular expression: {0}")] + InvalidRegex(String), + #[error("{0}")] + IoErr(#[from] io::Error), + #[error("{0}")] + ClapErr(#[from] ClapErrorWrapper), + /// General copy error + #[error("{0}")] + Error(String), +} + +type LocateResult = Result; + +impl UError for Error { + fn code(&self) -> i32 { + 1 + } +} + +pub struct Statistics { + matches: usize, + total_length: usize, + whitespace: usize, + newlines: usize, + high_bit: usize, +} + +impl Statistics { + fn new() -> Self { + Self { + matches: 0, + total_length: 0, + whitespace: 0, + newlines: 0, + high_bit: 0, + } + } + + fn add_match(&mut self, mat: &CStr) { + let s = mat.to_string_lossy(); + self.matches += 1; + self.total_length += s.len(); + if s.chars().any(char::is_whitespace) { + self.whitespace += 1; + } + if s.chars().any(|c| c == '\n') { + self.newlines += 1; + } + if !s.is_ascii() { + self.high_bit += 1; + } + } + + fn print_header(&self, dbreader: &DbReader) { + println!( + "Database {} is in the {} format.", + dbreader.path.to_string_lossy(), + dbreader.format, + ); + } + + fn print(&self, dbreader: &DbReader) { + if let Ok(metadata) = fs::metadata(&dbreader.path) { + if let Ok(time) = metadata.modified() { + let time: DateTime = time.into(); + println!("Database was last modified at {}", time); + } + println!("Locate database size: {} bytes", metadata.size()); + } + println!("Matching Filenames: {}", self.matches); + println!( + "File names have a cumulative length of {} bytes", + self.total_length + ); + println!("Of those file names,\n"); + println!(" {} contain whitespace,", self.whitespace); + println!(" {} contain newline characters,", self.newlines); + println!( + " and {} contain characters with the high bit set.", + self.high_bit + ); + println!(); + } +} + +#[derive(Debug)] +enum Patterns { + String(Vec), + Regex(Vec), +} + +impl Patterns { + fn any_match(&self, entry: &str) -> bool { + match self { + Self::String(v) => v.iter().any(|s| entry.contains(s)), + Self::Regex(v) => v.iter().any(|r| r.find(entry).is_some()), + } + } + + fn all_match(&self, entry: &str) -> bool { + match self { + Self::String(v) => v.iter().all(|s| entry.contains(s)), + Self::Regex(v) => v.iter().all(|r| r.find(entry).is_some()), + } + } +} + +pub struct ParsedInfo { + patterns: Patterns, + config: Config, +} + +fn make_regex(ty: RegexType, config: &Config, pattern: &str) -> Result { + let syntax = match ty { + RegexType::Emacs => Syntax::emacs(), + RegexType::Grep => Syntax::grep(), + RegexType::PosixBasic => Syntax::posix_basic(), + RegexType::PosixExtended => Syntax::posix_extended(), + }; + + Regex::with_options( + pattern, + if config.ignore_case { + RegexOptions::REGEX_OPTION_IGNORECASE + } else { + RegexOptions::REGEX_OPTION_NONE + }, + syntax, + ) +} + +impl TryFrom for ParsedInfo { + type Error = Error; + + fn try_from(value: ArgMatches) -> Result { + let config = Config { + all: value.get_flag("all"), + basename: value.get_flag("basename"), + db: value + .get_one::("database") + .map(String::as_str) + // `database` has a default value, so this is only reached if clap + // somehow omits it; fall back to an empty (non-openable) path. + .unwrap_or_default() + .split(':') + .map(PathBuf::from) + .collect(), + mode: value + .get_many::("mode") + .unwrap_or_default() + .next_back() + .map(|s| match s.as_str() { + "count" => Mode::Count, + "statistics" => Mode::Statistics, + // the "mode" group only contains the "count" and "statistics" args + _ => unreachable!(), + }) + .unwrap_or_default(), + existing: value + .get_many::("exist") + .unwrap_or_default() + .next_back() + .map(|s| match s.as_str() { + "existing" => ExistenceMode::Present, + "non-existing" => ExistenceMode::NotPresent, + // the "exist" group only contains the "existing" and "non-existing" args + s => unreachable!("{s}"), + }) + .unwrap_or_default(), + follow_symlinks: value.get_flag("follow") || !value.get_flag("nofollow"), + ignore_case: value.get_flag("ignore-case"), + limit: value.get_one::("limit").copied(), + // `max-database-age` has a default value of 8 + max_age: value + .get_one::("max-database-age") + .copied() + .unwrap_or(8), + null_bytes: value.get_flag("null"), + print: value.get_flag("print"), + }; + // `patterns` is a required argument, so this is normally non-empty + let patterns: Vec = value + .get_many::("patterns") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + let patterns = if let Some(ty) = value.get_flag("regex").then(|| { + value + .get_one::("regextype") + .and_then(|s| RegexType::from_str(s.as_str()).ok()) + .unwrap_or(RegexType::Emacs) + }) { + let mut compiled = Vec::with_capacity(patterns.len()); + for pattern in patterns { + let regex = make_regex(ty, &config, &pattern) + .map_err(|e| Error::InvalidRegex(format!("{pattern}: {e}")))?; + compiled.push(regex); + } + Patterns::Regex(compiled) + } else { + Patterns::String(patterns) + }; + Ok(Self { patterns, config }) + } +} + +fn uu_app() -> Command { + Command::new("locate") + .version(crate_version!()) + .args_override_self(true) + .arg( + Arg::new("all") + .short('a') + .long("all") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("basename") + .short('b') + .long("basename") + .action(ArgAction::SetTrue) + .group("name"), + ) + .arg( + Arg::new("count") + .short('c') + .long("count") + .action(ArgAction::SetTrue) + .group("mode"), + ) + .arg( + Arg::new("database") + .short('d') + .long("database") + .env("LOCATE_PATH") + .default_value("/usr/local/var/locatedb") + .action(ArgAction::Set), + ) + .arg( + Arg::new("existing") + .short('e') + .long("existing") + .action(ArgAction::SetTrue) + .group("exist"), + ) + .arg( + Arg::new("non-existing") + .short('E') + .long("non-existing") + .action(ArgAction::SetTrue) + .group("exist"), + ) + .arg( + Arg::new("follow") + .short('L') + .action(ArgAction::SetTrue) + .overrides_with("nofollow"), + ) + .arg( + Arg::new("nofollow") + .short('P') + .short_alias('H') + .action(ArgAction::SetTrue) + .overrides_with("follow"), + ) + .arg( + Arg::new("ignore-case") + .short('i') + .long("ignore-case") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("limit") + .short('l') + .long("limit") + .value_parser(value_parser!(usize)) + .action(ArgAction::Set), + ) + .arg( + Arg::new("max-database-age") + .long("max-database-age") + .value_parser(value_parser!(usize)) + .default_value("8") + .action(ArgAction::Set), + ) + .arg( + Arg::new("mmap") + .short('m') + .long("mmap") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("null") + .short('0') + .long("null") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("print") + .short('p') + .long("print") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("wholename") + .short('w') + .long("wholename") + .action(ArgAction::SetFalse) + .group("name"), + ) + .arg( + Arg::new("regex") + .short('r') + .long("regex") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("regextype") + .long("regextype") + .value_parser([ + "findutils-default", + "emacs", + "gnu-awk", + "grep", + "posix-awk", + "awk", + "posix-basic", + "posix-egrep", + "egrep", + "posix-extended", + ]) + .action(ArgAction::Set), + ) + .arg( + Arg::new("stdio") + .short('s') + .long("stdio") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("statistics") + .short('S') + .long("statistics") + .action(ArgAction::SetTrue) + .group("mode"), + ) + .arg( + Arg::new("patterns") + .num_args(1..) + .action(ArgAction::Append) + .value_parser(value_parser!(String)) + .required(true), + ) +} + +struct DbReader { + reader: BufReader, + prev: Option, + prefix: isize, + format: DbFormat, + path: PathBuf, +} + +impl Iterator for DbReader { + type Item = LocateResult; + + fn next(&mut self) -> Option { + // 1 byte for the prefix delta + let mut buf = [0]; + self.reader.read_exact(&mut buf).ok()?; + // 0x80 - the prefix delta takes the next two bytes + let size = if buf[0] == 0x80 { + let mut buf = [0; 2]; + self.reader.read_exact(&mut buf).ok()?; + i16::from_be_bytes(buf) as isize + } else { + // u8 as isize directly doesn't sign-extend + buf[0] as i8 as isize + }; + self.prefix += size; + // read the actual path fragment + let mut buf = Vec::new(); + self.reader.read_until(b'\0', &mut buf).ok()?; + let prefix = self.prev.as_ref().map(|s| { + s.to_bytes() + .iter() + .take(self.prefix as usize) + .collect::>() + }); + if (prefix.as_ref().map(|v| v.len()).unwrap_or(0) as isize) < size { + return Some(Err(Error::InvalidDb( + self.path.to_string_lossy().to_string(), + ))); + } + let res = CString::from_vec_with_nul( + prefix + .unwrap_or_default() + .into_iter() + .copied() + .chain(buf) + .collect(), + ) + .ok()?; + self.prev = Some(res.clone()); + Some(Ok(res)) + } +} + +impl DbReader { + fn new(path: impl AsRef) -> UResult { + let mut reader = BufReader::new(File::open(path.as_ref())?); + let format = Self::check_db(&mut reader).ok_or(Error::InvalidDbType)?; + Ok(Self { + reader, + prev: None, + prefix: 0, + format, + path: path.as_ref().to_path_buf(), + }) + } + + fn check_db(reader: &mut BufReader) -> Option { + let mut buf = [0]; + let Ok(_) = reader.read_exact(&mut buf) else { + return None; + }; + let mut buf = Vec::new(); + let Ok(_) = reader.read_until(b'\0', &mut buf) else { + return None; + }; + + // drop nul byte when matching + match String::from_utf8_lossy(&buf[..buf.len() - 1]).as_ref() { + "LOCATE02" => Some(DbFormat::Locate02), + _ => None, + } + } +} + +/// Whether `path` currently exists on disk. +/// +/// With `follow_symlinks` (the `-L`/`--follow` default) a symlink is resolved, so a broken +/// symlink counts as non-existent. Without it (`-P`/`--nofollow`) the link itself is checked, +/// so a broken symlink counts as existing. +fn path_exists(path: &Path, follow_symlinks: bool) -> bool { + if follow_symlinks { + fs::metadata(path).is_ok() + } else { + fs::symlink_metadata(path).is_ok() + } +} + +fn match_entry(entry: &CStr, config: &Config, patterns: &Patterns) -> bool { + let buf = Path::new(OsStr::from_bytes(entry.to_bytes())); + let name = if config.basename { + let Some(path) = buf.file_name() else { + return false; + }; + + let Ok(c) = CString::from_vec_with_nul( + path.as_encoded_bytes() + .iter() + .copied() + .chain([b'\0']) + .collect(), + ) else { + // a file name can't contain an interior nul byte, so this only fails on + // genuinely malformed input, which we treat as a non-match + return false; + }; + + Cow::Owned(c) + } else { + Cow::Borrowed(entry) + }; + let entry = name.to_string_lossy(); + + let patterns_match = match config.all { + false => { + if entry.chars().any(|c| r"*?[]\".contains(c)) { + // TODO: parse metacharacters + false + } else { + patterns.any_match(entry.as_ref()) + } + } + true => { + if entry.chars().any(|c| r"*?[]\".contains(c)) { + // TODO: parse metacharacters + false + } else { + patterns.all_match(entry.as_ref()) + } + } + }; + + // existence is always checked against the full path, even in `--basename` mode + let existence_matches = match config.existing { + ExistenceMode::Any => true, + ExistenceMode::Present => path_exists(buf, config.follow_symlinks), + ExistenceMode::NotPresent => !path_exists(buf, config.follow_symlinks), + }; + + patterns_match && existence_matches +} + +fn do_locate(args: &[&str]) -> LocateResult<()> { + let matches = uu_app().try_get_matches_from(args); + match matches { + Err(e) => { + let mut app = uu_app(); + + match e.kind() { + clap::error::ErrorKind::DisplayHelp => { + app.print_help()?; + } + clap::error::ErrorKind::DisplayVersion => print!("{}", app.render_version()), + _ => return Err(e.with_exit_code(1).into()), + } + } + Ok(matches) => { + let ParsedInfo { patterns, config } = ParsedInfo::try_from(matches)?; + let mut stats = Statistics::new(); + + // iterate over each given database + let count = config + .db + .iter() + .filter_map(|p| DbReader::new(p.as_path()).ok()) + .map(|mut dbreader| { + // if we can get the mtime of the file, check it against the current time + if let Ok(metadata) = fs::metadata(&dbreader.path) { + if let Ok(time) = metadata.modified() { + let modified: DateTime = time.into(); + let now = Local::now(); + let delta = now - modified; + if delta + > TimeDelta::days(config.max_age as i64) + { + eprintln!( + "{}: warning: database ‘{}’ is more than {} days old (actual age is {:.1} days)", + args[0], + dbreader.path.to_string_lossy(), + config.max_age, + delta.num_seconds() as f64 / (60 * 60 * 24) as f64 + ); + } + } + } + + // the first line of the statistics description is printed before matches + // (given --print) + if config.mode == Mode::Statistics { + stats.print_header(&dbreader); + } + + // find matches + let count = dbreader + .by_ref() + .process_results(|iter| + iter + .filter(|s| match_entry(s.as_c_str(), &config, &patterns)) + .take(config.limit.unwrap_or(usize::MAX)) + .inspect(|s| { + if config.mode == Mode::Normal || config.print { + if config.null_bytes { + print!("{}\0", s.to_string_lossy()); + } else { + println!("{}", s.to_string_lossy()); + } + } + if config.mode == Mode::Statistics { + stats.add_match(s); + } + }) + .count() + ); + + // print the rest of the statistics description + if config.mode == Mode::Statistics && count.is_ok() { + stats.print(&dbreader); + } + + count + }) + .try_fold(0, |acc, e| e.map(|e| acc + e))?; + + if config.mode == Mode::Count { + println!("{count}"); + } + + // zero matches isn't an error if --statistics is passed + if count == 0 && config.mode != Mode::Statistics { + return Err(Error::NoMatches); + } + } + } + + Ok(()) +} + +pub fn locate_main(args: &[&str]) -> i32 { + match do_locate(args) { + Ok(()) => 0, + Err(e) => { + match e { + Error::NoMatches => {} + _ => { + let _ = writeln!(&mut stderr(), "Error: {e}"); + } + } + e.code() + } + } +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::path_exists; + + #[test] + fn path_exists_regular_file() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("file"); + std::fs::write(&file, b"").unwrap(); + // a real file exists regardless of whether symlinks are followed + assert!(path_exists(&file, true)); + assert!(path_exists(&file, false)); + } + + #[test] + fn path_exists_missing() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing"); + assert!(!path_exists(&missing, true)); + assert!(!path_exists(&missing, false)); + } + + #[test] + fn path_exists_broken_symlink() { + let dir = tempfile::tempdir().unwrap(); + let link = dir.path().join("dangling"); + symlink(dir.path().join("nonexistent-target"), &link).unwrap(); + // following the link resolves to the missing target -> non-existent + assert!(!path_exists(&link, true)); + // not following the link checks the link itself, which exists + assert!(path_exists(&link, false)); + } +} diff --git a/src/updatedb/main.rs b/src/updatedb/main.rs new file mode 100644 index 0000000..d77802a --- /dev/null +++ b/src/updatedb/main.rs @@ -0,0 +1,15 @@ +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#[cfg(not(windows))] +fn main() { + let args = std::env::args().collect::>(); + let strs: Vec<&str> = args.iter().map(std::convert::AsRef::as_ref).collect(); + std::process::exit(findutils::updatedb::updatedb_main(strs.as_slice())); +} + +#[cfg(windows)] +fn main() { + println!("updatedb is unsupported on Windows"); +} diff --git a/src/updatedb/mod.rs b/src/updatedb/mod.rs new file mode 100644 index 0000000..2728c14 --- /dev/null +++ b/src/updatedb/mod.rs @@ -0,0 +1,346 @@ +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::{ + cell::RefCell, + fmt::Display, + fs::OpenOptions, + io::{stderr, BufRead, BufReader, BufWriter, Write}, + path::PathBuf, + rc::Rc, + str::FromStr, + time::SystemTime, +}; + +use clap::{crate_version, value_parser, Arg, ArgAction, ArgMatches, Command}; +use uucore::error::UResult; + +use crate::find::{find_main, Dependencies}; + +// TODO: the `localuser` and `netuser` arguments are accepted but not yet honored; handling them +// will likely involve splitting the find logic into two calls. +pub struct Config { + find_options: String, + local_paths: Vec, + net_paths: Vec, + prune_paths: Vec, + prune_fs: Vec, + output: PathBuf, + db_format: DbFormat, +} + +impl From for Config { + fn from(value: ArgMatches) -> Self { + Self { + find_options: value + .get_one::("findoptions") + .cloned() + .unwrap_or_else(String::new), + local_paths: value + .get_one::("localpaths") + .map(|s| { + s.split_whitespace() + .filter_map(|s| PathBuf::from_str(s).ok()) + .collect() + }) + .unwrap_or_else(|| vec![PathBuf::from("/")]), + net_paths: value + .get_one::("netpaths") + .map(|s| s.split_whitespace().map(|s| s.to_owned()).collect()) + .unwrap_or_default(), + prune_paths: value + .get_one::("prunepaths") + .map(|s| s.split_whitespace().map(PathBuf::from).collect()) + .unwrap_or_else(|| { + ["/tmp", "/usr/tmp", "/var/tmp", "/afs"] + .into_iter() + .map(PathBuf::from) + .collect() + }), + prune_fs: value + .get_one::("prunefs") + .map(|s| s.split_whitespace().map(|s| s.to_owned()).collect()) + .unwrap_or_else(|| { + ["nfs", "NFS", "proc"] + .into_iter() + .map(str::to_string) + .collect() + }), + db_format: value + .get_one::("dbformat") + .copied() + .unwrap_or_default(), + output: value + .get_one::("output") + .cloned() + // FIXME: the default should be platform-dependent + .unwrap_or_else(|| PathBuf::from("/usr/local/var/locatedb")), + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub enum DbFormat { + #[default] + Locate02, +} + +// used for locate's --statistics +impl Display for DbFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Locate02 => f.write_str("GNU LOCATE02"), + } + } +} + +fn uu_app() -> Command { + Command::new("updatedb") + .version(crate_version!()) + .arg( + Arg::new("findoptions") + .long("findoptions") + .require_equals(true) + .env("FINDOPTIONS") + .action(ArgAction::Append), + ) + .arg( + Arg::new("localpaths") + .long("localpaths") + .require_equals(true) + .action(ArgAction::Set), + ) + .arg( + Arg::new("netpaths") + .long("netpaths") + .require_equals(true) + .env("NETPATHS") + .action(ArgAction::Set), + ) + .arg( + Arg::new("prunepaths") + .long("prunepaths") + .require_equals(true) + .env("PRUNEPATHS") + .action(ArgAction::Set), + ) + .arg( + Arg::new("prunefs") + .long("prunefs") + .require_equals(true) + .env("PRUNEFS") + .action(ArgAction::Set), + ) + .arg( + Arg::new("output") + .long("output") + .require_equals(true) + .value_parser(value_parser!(PathBuf)) + .action(ArgAction::Set), + ) + .arg( + Arg::new("localuser") + .long("localuser") + .require_equals(true) + .env("LOCALUSER") + .action(ArgAction::Set), + ) + .arg( + Arg::new("netuser") + .long("netuser") + .require_equals(true) + .env("NETUSER") + .action(ArgAction::Set), + ) + .arg( + Arg::new("dbformat") + .long("dbformat") + .require_equals(true) + .value_parser(["LOCATE02"]) + .action(ArgAction::Set), + ) +} + +// The LOCATE02 format elides bytes from the path until the first byte that differs from the +// previous entry. It keeps a running total of the prefix length, and uses 1 or 3 bytes to write +// the difference from the previous prefix length. Paths are provided in sorted order by find. +struct Frcoder<'a> { + reader: BufReader<&'a [u8]>, + prev: Option>, + prefix: usize, + ty: DbFormat, +} + +impl<'a> Frcoder<'a> { + fn new(v: &'a [u8], ty: DbFormat) -> Self { + Self { + reader: BufReader::new(v), + prev: None, + prefix: 0, + ty, + } + } + + fn generate_header(&self) -> Vec { + match self.ty { + DbFormat::Locate02 => "\0LOCATE02\0".as_bytes().to_vec(), + } + } +} + +impl Iterator for Frcoder<'_> { + type Item = Vec; + + fn next(&mut self) -> Option { + let mut path = Vec::new(); + // find prints nul bytes after each path + if self.reader.read_until(b'\0', &mut path).ok()? == 0 { + return None; + } + + let prefix = path + .iter() + .zip(self.prev.as_deref().unwrap_or_default()) + .take_while(|(a, b)| a == b) + .count(); + + let diff = prefix as i32 - self.prefix as i32; + + // if the prefix delta exceeds 0x7f, we use 0x80 to signal that the next two bytes comprise + // the delta + let mut out = Vec::new(); + if diff.abs() > 0x7f { + out.push(0x80); + out.extend((diff as i16).to_be_bytes()); + } else { + out.push(diff as u8); + } + + out.extend(path.iter().skip(prefix)); + + self.prefix = prefix; + self.prev = Some(path); + + Some(out) + } +} + +// capture find's stdout +struct CapturedDependencies { + output: Rc>, + now: SystemTime, +} + +impl CapturedDependencies { + fn new(output: Rc>) -> Self { + Self { + output, + now: SystemTime::now(), + } + } +} + +impl Dependencies for CapturedDependencies { + fn get_output(&self) -> &RefCell { + self.output.as_ref() + } + + fn now(&self) -> SystemTime { + self.now + } + + fn confirm(&self, _prompt: &str) -> bool { + // updatedb runs non-interactively; there is nothing to confirm. + false + } +} + +fn do_updatedb(args: &[&str]) -> UResult<()> { + let matches = uu_app().try_get_matches_from(args)?; + let config = Config::from(matches); + + // offload most of the logic to find. Build the argument list as discrete tokens rather than + // a single string so that paths and options aren't re-split on whitespace and so that an empty + // `prunefs`/`prunepaths` can't produce an invalid expression (e.g. an empty `( )` group). + let mut tokens: Vec = Vec::new(); + tokens.extend( + config + .local_paths + .iter() + .filter_map(|p| p.to_str()) + .map(String::from), + ); + tokens.extend(config.net_paths.iter().cloned()); + // FINDOPTIONS is a single string of options, split on whitespace as GNU updatedb does + tokens.extend(config.find_options.split_whitespace().map(String::from)); + + // Each clause prunes a subtree (by filesystem type or by path); the final clause prints the + // entry. Clauses are OR-ed together so a path that matches no prune clause gets printed. + let mut clauses: Vec> = Vec::new(); + if !config.prune_fs.is_empty() { + let mut clause = vec![String::from("(")]; + for (i, fs) in config.prune_fs.iter().enumerate() { + if i > 0 { + clause.push(String::from("-or")); + } + clause.push(String::from("-fstype")); + clause.push(fs.clone()); + } + clause.push(String::from(")")); + clause.push(String::from("-prune")); + clauses.push(clause); + } + for path in config.prune_paths.iter().filter_map(|p| p.to_str()) { + clauses.push(vec![ + String::from("-regex"), + path.to_string(), + String::from("-prune"), + ]); + } + clauses.push(vec![String::from("-print0")]); + + for (i, clause) in clauses.into_iter().enumerate() { + if i > 0 { + tokens.push(String::from("-or")); + } + tokens.extend(clause); + } + tokens.push(String::from("-sorted")); + + let mut find_args = vec!["find"]; + find_args.extend(tokens.iter().map(String::as_str)); + + let output = Rc::new(RefCell::new(Vec::new())); + let deps = CapturedDependencies::new(output.clone()); + find_main(find_args.as_slice(), &deps); + + let mut writer = BufWriter::new( + OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(config.output)?, + ); + + let output = output.borrow(); + let frcoder = Frcoder::new(output.as_slice(), config.db_format); + writer.write_all(&frcoder.generate_header())?; + for v in frcoder { + writer.write_all(v.as_slice())?; + } + + writer.flush()?; + + Ok(()) +} + +pub fn updatedb_main(args: &[&str]) -> i32 { + match do_updatedb(args) { + Ok(()) => 0, + Err(e) => { + let _ = writeln!(&mut stderr(), "Error: {e}"); + 1 + } + } +} diff --git a/test_data/db/depth/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/f0 b/test_data/db/depth/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/f0 new file mode 100644 index 0000000..e69de29 diff --git a/test_data/db/depth/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/f1 b/test_data/db/depth/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/1234567890123456789012345678901234567890/f1 new file mode 100644 index 0000000..e69de29 diff --git a/test_data/db/invalid_db b/test_data/db/invalid_db new file mode 100644 index 0000000..f62ba7e Binary files /dev/null and b/test_data/db/invalid_db differ diff --git a/test_data/db/old_db b/test_data/db/old_db new file mode 100644 index 0000000..3fafa8e Binary files /dev/null and b/test_data/db/old_db differ diff --git a/test_data/db/test_data_db b/test_data/db/test_data_db new file mode 100644 index 0000000..3fafa8e Binary files /dev/null and b/test_data/db/test_data_db differ diff --git a/tests/db_tests.rs b/tests/db_tests.rs new file mode 100644 index 0000000..97ca95c --- /dev/null +++ b/tests/db_tests.rs @@ -0,0 +1,213 @@ +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +mod common; + +use std::process::Command; + +use assert_cmd::{assert::OutputAssertExt, cargo::CommandCargoExt}; +use rstest::rstest; + +#[cfg(not(windows))] +const DB_FLAG: &str = "--database=test_data/db/test_data_db"; +#[cfg(not(windows))] +const INVALID_DB_FLAG: &str = "--database=test_data/db/invalid_db"; +#[cfg(not(windows))] +const OLD_DB_FLAG: &str = "--database=test_data/db/old_db"; + +#[test] +#[cfg(not(windows))] +fn test_locate_no_matches() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["usr", DB_FLAG]) + .assert() + .failure(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_match() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["test_data", DB_FLAG]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_no_matches_basename() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["test_data1234567890", "--basename", DB_FLAG]) + .assert() + .failure(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_match_basename() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["abbbc", "--basename", DB_FLAG]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_existing() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["abbbc", "--existing", DB_FLAG]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_non_existing() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["abbbc", "--non-existing", DB_FLAG]) + .assert() + .failure(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_statistics() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["", "--statistics", DB_FLAG]) + .assert() + .success(); +} + +#[rstest] +#[case("emacs")] +#[case("grep")] +#[case("posix-basic")] +#[case("posix-extended")] +#[cfg(not(windows))] +fn test_locate_regex(#[case] input: &str) { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args([ + "abbbc", + "--regex", + format!("--regextype={input}").as_str(), + DB_FLAG, + ]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_all() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["abb", "bbc", "--all", DB_FLAG]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_all_regex() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["abb", "b*c", "--all", "--regex", DB_FLAG]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_invalid_db() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["test_data", INVALID_DB_FLAG]) + .assert() + .failure(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_outdated_db() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["test_data", OLD_DB_FLAG]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_print_help() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .arg("--help") + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_locate_invalid_flag() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .arg("--unknown") + .assert() + .failure(); +} + +// an un-compilable regex should be reported as an error rather than silently matching nothing +#[test] +#[cfg(not(windows))] +fn test_locate_invalid_regex() { + Command::cargo_bin("locate") + .expect("couldn't find locate binary") + .args(["[", "--regex", DB_FLAG]) + .assert() + .failure(); +} + +#[test] +#[cfg(not(windows))] +fn test_updatedb() { + Command::cargo_bin("updatedb") + .expect("couldn't find updatedb binary") + .args(["--localpaths=./test_data", "--output=/dev/null"]) + .assert() + .success(); +} + +#[test] +#[cfg(not(windows))] +fn test_updatedb_invalid_flag() { + Command::cargo_bin("updatedb") + .expect("couldn't find updatedb binary") + .args(["--unknown"]) + .assert() + .failure(); +} + +// empty prunefs/prunepaths must not produce an invalid find expression (e.g. an empty `( )` group) +#[test] +#[cfg(not(windows))] +fn test_updatedb_empty_prune() { + Command::cargo_bin("updatedb") + .expect("couldn't find updatedb binary") + .args([ + "--localpaths=./test_data", + "--output=/dev/null", + "--prunefs=", + "--prunepaths=", + ]) + .assert() + .success(); +}