From 3930c6de1ac879fcb41e1e00407ea9094c7baf4f Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 03:45:36 +0000 Subject: [PATCH 1/2] Add Git inspection forms, find pruning, and WASI tail stdin (T76) --- host/tests/it/p1.rs | 40 + userland/Cargo.lock | 44 +- userland/coreutils/Cargo.toml | 2 +- userland/coreutils/vendor/PATCHES.md | 12 + userland/coreutils/vendor/uu_tail/Cargo.toml | 159 ++++ userland/coreutils/vendor/uu_tail/LICENSE | 18 + .../vendor/uu_tail/locales/en-US.ftl | 77 ++ .../vendor/uu_tail/locales/fr-FR.ftl | 72 ++ userland/coreutils/vendor/uu_tail/src/args.rs | 688 +++++++++++++++++ .../coreutils/vendor/uu_tail/src/chunks.rs | 700 ++++++++++++++++++ .../vendor/uu_tail/src/follow/files.rs | 212 ++++++ .../vendor/uu_tail/src/follow/mod.rs | 72 ++ .../vendor/uu_tail/src/follow/watch.rs | 675 +++++++++++++++++ userland/coreutils/vendor/uu_tail/src/main.rs | 6 + .../coreutils/vendor/uu_tail/src/parse.rs | 178 +++++ .../coreutils/vendor/uu_tail/src/paths.rs | 249 +++++++ .../vendor/uu_tail/src/platform/mod.rs | 30 + .../vendor/uu_tail/src/platform/unix.rs | 34 + .../vendor/uu_tail/src/platform/windows.rs | 53 ++ userland/coreutils/vendor/uu_tail/src/tail.rs | 626 ++++++++++++++++ userland/coreutils/vendor/uu_tail/src/text.rs | 22 + userland/findutils/src/find.rs | 188 ++++- userland/findutils/test.nu | 18 +- userland/git/README.md | 3 + userland/git/src/lib.rs | 93 +-- userland/git/src/output.rs | 5 +- userland/git/src/pretty.rs | 157 ++++ userland/git/test.nu | 22 +- 28 files changed, 4363 insertions(+), 92 deletions(-) create mode 100644 userland/coreutils/vendor/PATCHES.md create mode 100644 userland/coreutils/vendor/uu_tail/Cargo.toml create mode 100644 userland/coreutils/vendor/uu_tail/LICENSE create mode 100644 userland/coreutils/vendor/uu_tail/locales/en-US.ftl create mode 100644 userland/coreutils/vendor/uu_tail/locales/fr-FR.ftl create mode 100644 userland/coreutils/vendor/uu_tail/src/args.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/chunks.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/follow/files.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/follow/mod.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/follow/watch.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/main.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/parse.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/paths.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/platform/mod.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/platform/unix.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/platform/windows.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/tail.rs create mode 100644 userland/coreutils/vendor/uu_tail/src/text.rs create mode 100644 userland/git/src/pretty.rs diff --git a/host/tests/it/p1.rs b/host/tests/it/p1.rs index 120801d..2b2a070 100644 --- a/host/tests/it/p1.rs +++ b/host/tests/it/p1.rs @@ -59,6 +59,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { ("printf 'a\\n' | xargs echo", "a\n"), ("env FOO=1 printenv FOO", "1\n"), ] { + eprintln!("P1 command: {script}"); let mut out = Vec::new(); let code = exec .exec( @@ -84,6 +85,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { ("head -c 4 /dev/urandom | wc -c", "4\n"), ("echo x > /dev/null 2>&1; cat < /dev/null", ""), ] { + eprintln!("P1 command: {script}"); let mut out = Vec::new(); let code = exec .exec( @@ -99,6 +101,44 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { assert_eq!(code, 0, "{script}"); assert_eq!(out, expected.as_bytes(), "{script}"); } + for utility in [ + "tail -n 5", + "head -n 2", + "wc -l", + "sort", + "uniq", + "cut -c 1", + ] { + for script in [ + format!("printf 'g\\nb\\nb\\na\\nf\\ne\\nd\\n' | {utility}"), + format!("printf '' | {utility}"), + format!("printf 'g\\nb\\n' | {utility} | head -n 0"), + ] { + eprintln!("P1 pipe: {script}"); + let native = std::process::Command::new("bash") + .args(["-c", &script]) + .output()?; + assert!(native.status.success(), "{script}: {:?}", native.stderr); + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = exec + .exec( + Command::Shell(script.clone()), + Options { + timeout: Some(std::time::Duration::from_secs(15)), + ..Options::default() + }, + &mut |stream, bytes| match stream { + Stream::Stdout => out.extend_from_slice(bytes), + Stream::Stderr => err.extend_from_slice(bytes), + }, + ) + .await?; + assert_eq!(code, 0, "{script}: {}", String::from_utf8_lossy(&err)); + assert_eq!(out, native.stdout, "{script}"); + } + } + eprintln!("P1 Git history"); let mut history = Vec::new(); assert_eq!( exec.exec( diff --git a/userland/Cargo.lock b/userland/Cargo.lock index c3d1c0c..b1081b2 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -1448,6 +1448,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[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.34" @@ -4221,6 +4227,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "rfc6979" version = "0.4.0" @@ -4277,6 +4289,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rstest" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948203e6d13b83e51a90d7cf236dd58a0065f23f4b902f00f306e7eb2bd09b9c" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d92eaf7b6e51e12471d71ddc72608e7151573de44099aacfbb1128177c0da4" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -6072,14 +6113,13 @@ dependencies = [ [[package]] name = "uu_tail" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04ff141dd3fab47b991b2063fb8c1c9ff5ca4f9f23f5bcba75c691a247e1aca4" dependencies = [ "clap", "fluent", "libc", "memchr", "notify", + "rstest", "rustix", "uucore", "windows-sys 0.61.2", diff --git a/userland/coreutils/Cargo.toml b/userland/coreutils/Cargo.toml index 4f75d6f..8471e58 100644 --- a/userland/coreutils/Cargo.toml +++ b/userland/coreutils/Cargo.toml @@ -67,7 +67,7 @@ uu_sleep = "=0.12.0" uu_sort = "=0.12.0" uu_split = "=0.12.0" uu_sum = "=0.12.0" -uu_tail = "=0.12.0" +uu_tail = { path = "vendor/uu_tail" } uu_tee = "=0.12.0" uu_test = "=0.12.0" uu_touch = "=0.12.0" diff --git a/userland/coreutils/vendor/PATCHES.md b/userland/coreutils/vendor/PATCHES.md new file mode 100644 index 0000000..d4bcd2b --- /dev/null +++ b/userland/coreutils/vendor/PATCHES.md @@ -0,0 +1,12 @@ +# uu_tail 0.12.0 + +This directory contains the upstream crate's manifest, source, locales, and MIT license. +The dependency uses a local path so the patch stays inside the coreutils package. +The manifest omits the standalone binary because the coreutils wrapper owns that binary. + +`src/paths.rs`: treat WASI stdin as a stream, like macOS stdin. +WASI has no filesystem path for standard input. +The upstream resolution path otherwise attempts to open `standard input` as a file. +The patch selects the existing streaming implementation without temporary files or another tail implementation. + +The real-world P1 test compares pipes through tail, head, wc, sort, uniq, and cut with native tools. diff --git a/userland/coreutils/vendor/uu_tail/Cargo.toml b/userland/coreutils/vendor/uu_tail/Cargo.toml new file mode 100644 index 0000000..b4e0b0a --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/Cargo.toml @@ -0,0 +1,159 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.88.0" +name = "uu_tail" +version = "0.12.0" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "tail ~ (uutils) display the last lines of input" +homepage = "https://github.com/uutils/coreutils" +readme = "README.package.md" +keywords = [ + "coreutils", + "uutils", + "cross-platform", + "cli", + "utility", +] +categories = ["command-line-utilities"] +license = "MIT" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/tail" + +[features] +diagnostics = ["uucore/diagnostics"] + +[lib] +name = "uu_tail" +path = "src/tail.rs" +doctest = false + +# The coreutils wrapper owns the tail binary. + +[dependencies.clap] +version = "4.5" +features = [ + "wrap_help", + "cargo", + "color", +] + +[dependencies.fluent] +version = "0.17.0" + +[dependencies.memchr] +version = "2.7.2" + +[dependencies.uucore] +version = "0.12.0" +features = [ + "fs", + "parser-size", + "signals", +] + +[dev-dependencies.rstest] +version = "0.27.0" + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies.uucore] +version = "0.12.0" +features = ["pipes"] + +[target.'cfg(not(target_os = "wasi"))'.dependencies.libc] +version = "0.2.186" + +[target.'cfg(not(target_os = "wasi"))'.dependencies.notify] +version = "8.2.0" +features = ["macos_kqueue"] + +[target."cfg(unix)".dependencies.rustix] +version = "1.1.4" +features = [ + "fs", + "process", +] +default-features = false + +[target."cfg(windows)".dependencies.windows-sys] +version = "0.61.0" +features = [ + "Win32_System_Threading", + "Win32_Foundation", +] +default-features = false + +[lints.clippy] +cargo_common_metadata = "allow" +cast_lossless = "allow" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +doc_link_with_quotes = "allow" +doc_markdown = "allow" +enum_glob_use = "allow" +flat_map_option = "allow" +float_cmp = "allow" +fn_params_excessive_bools = "allow" +format_push_string = "allow" +from_iter_instead_of_collect = "allow" +ignored_unit_patterns = "allow" +implicit_hasher = "allow" +inline_always = "allow" +large_types_passed_by_value = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +multiple_crate_versions = "allow" +must_use_candidate = "allow" +needless_pass_by_value = "allow" +return_self_not_must_use = "allow" +should_panic_without_expect = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +too_many_lines = "allow" +unnested_or_patterns = "allow" +unused_self = "allow" +use_self = "warn" + +[lints.clippy.all] +level = "warn" +priority = -1 + +[lints.clippy.cargo] +level = "warn" +priority = -1 + +[lints.clippy.pedantic] +level = "warn" +priority = -1 + +[lints.rust] +unused_qualifications = "warn" + +[lints.rust.renamed_and_removed_lints] +level = "allow" +priority = -1 + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = [ + "cfg(fuzzing)", + "cfg(pgo_training)", + 'cfg(target_os, values("cygwin"))', + "cfg(wasi_runner)", +] diff --git a/userland/coreutils/vendor/uu_tail/LICENSE b/userland/coreutils/vendor/uu_tail/LICENSE new file mode 100644 index 0000000..21bd444 --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/userland/coreutils/vendor/uu_tail/locales/en-US.ftl b/userland/coreutils/vendor/uu_tail/locales/en-US.ftl new file mode 100644 index 0000000..c74e8a0 --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/locales/en-US.ftl @@ -0,0 +1,77 @@ +tail-about = Print the last 10 lines of each FILE to standard output. + With more than one FILE, precede each with a header giving the file name. + With no FILE, or when FILE is -, read standard input. + + Mandatory arguments to long options are mandatory for short options too. +tail-usage = tail [OPTION]... [FILE]... + +# Help messages +tail-help-bytes = Number of bytes to print +tail-help-debug = indicate which --follow implementation is used +tail-help-follow = Print the file as it grows +tail-help-lines = Number of lines to print +tail-help-pid = With -f, terminate after process ID, PID dies +tail-help-quiet = Never output headers giving file names +tail-help-sleep-interval = Number of seconds to sleep between polling the file when running with -f +tail-help-max-unchanged-stats = Reopen a FILE which has not changed size after N (default 5) iterations to see if it has been unlinked or renamed (this is the usual case of rotated log files); This option is meaningful only when polling (i.e., with --use-polling) and when --follow=name +tail-help-verbose = Always output headers giving file names +tail-help-zero-terminated = Line delimiter is NUL, not newline +tail-help-retry = Keep trying to open a file if it is inaccessible +tail-help-follow-retry = Same as --follow=name --retry +tail-help-polling-linux = Disable 'inotify' support and use polling instead +tail-help-polling-unix = Disable 'kqueue' support and use polling instead +tail-help-polling-windows = Disable 'ReadDirectoryChanges' support and use polling instead + +# Error messages +tail-error-cannot-follow-stdin-by-name = cannot follow { $stdin } by name +tail-error-cannot-open-no-such-file = cannot open '{ $file }' for reading: { $error } +tail-error-reading-file = error reading '{ $file }': { $error } +tail-error-cannot-follow-file-type = { $file }: cannot follow end of this type of file{ $msg } +tail-error-cannot-open-for-reading = cannot open '{ $file }' for reading +tail-error-cannot-fstat = cannot fstat { $file }: { $error } +tail-error-invalid-number-of-bytes = invalid number of bytes: { $arg } +tail-error-invalid-number-of-lines = invalid number of lines: { $arg } +tail-error-invalid-number-of-seconds = invalid number of seconds: '{ $source }' +tail-error-invalid-max-unchanged-stats = invalid maximum number of unchanged stats between opens: { $value } +tail-error-invalid-pid = invalid PID: { $pid } +tail-error-invalid-pid-with-error = invalid PID: { $pid }: { $error } +tail-error-invalid-number-out-of-range = invalid number: { $arg }: Numerical result out of range +tail-error-invalid-number-overflow = invalid number: { $arg } +tail-error-option-used-in-invalid-context = option used in invalid context -- { $option } +tail-error-bad-argument-encoding = bad argument encoding: { $arg } +tail-error-cannot-watch-parent-directory = cannot watch parent directory of { $path } +tail-error-backend-cannot-be-used-too-many-files = { $backend } cannot be used, reverting to polling: Too many open files +tail-error-backend-resources-exhausted = { $backend } resources exhausted +tail-error-notify-error = NotifyError: { $error } +tail-error-recv-timeout-error = RecvTimeoutError: { $error } + +# Warning messages +tail-warning-retry-ignored = --retry ignored; --retry is useful only when following +tail-warning-retry-only-effective = --retry only effective for the initial open +tail-warning-pid-ignored = PID ignored; --pid=PID is useful only when following +tail-warning-pid-not-supported = --pid=PID is not supported on this system +tail-warning-following-stdin-ineffective = following standard input indefinitely is ineffective + +# Status messages +tail-status-has-become-accessible = { $file } has become accessible +tail-status-has-appeared-following-new-file = { $file } has appeared; following new file +tail-status-has-been-replaced-following-new-file = { $file } has been replaced; following new file +tail-status-file-truncated = { $file }: file truncated +tail-status-replaced-with-untailable-file = { $file } has been replaced with an untailable file +tail-status-replaced-with-untailable-symlink = { $file } has been replaced with an untailable symbolic link +tail-status-replaced-with-untailable-file-giving-up = { $file } has been replaced with an untailable file; giving up on this name +tail-status-directory-containing-watched-file-removed = directory containing watched file was removed +tail-status-backend-cannot-be-used-reverting-to-polling = { $backend } cannot be used, reverting to polling + +# Text constants +tail-bad-fd = Bad file descriptor +tail-no-such-file-or-directory = No such file or directory +tail-is-a-directory = Is a directory +tail-giving-up-on-this-name = ; giving up on this name +tail-stdin-header = standard input +tail-no-files-remaining = no files remaining +tail-become-inaccessible = has become inaccessible + +# Debug messages +tail-debug-using-notification-mode = using notification mode +tail-debug-using-polling-mode = using polling mode diff --git a/userland/coreutils/vendor/uu_tail/locales/fr-FR.ftl b/userland/coreutils/vendor/uu_tail/locales/fr-FR.ftl new file mode 100644 index 0000000..00d202d --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/locales/fr-FR.ftl @@ -0,0 +1,72 @@ +tail-about = Afficher les 10 dernières lignes de chaque FICHIER sur la sortie standard. + Avec plus d'un FICHIER, précéder chacun d'un en-tête donnant le nom du fichier. + Sans FICHIER, ou quand FICHIER est -, lire l'entrée standard. + + Les arguments obligatoires pour les options longues sont également obligatoires pour les options courtes. +tail-usage = tail [OPTION]... [FICHIER]... + +# Messages d'aide +tail-help-bytes = Nombre d'octets à afficher +tail-help-follow = Afficher le fichier au fur et à mesure de sa croissance +tail-help-lines = Nombre de lignes à afficher +tail-help-pid = Avec -f, terminer après que l'ID de processus, PID meure +tail-help-quiet = Ne jamais afficher d'en-têtes donnant les noms de fichiers +tail-help-sleep-interval = Nombre de secondes à attendre entre les sondages du fichier lors de l'exécution avec -f +tail-help-max-unchanged-stats = Rouvrir un FICHIER qui n'a pas changé de taille après N (par défaut 5) itérations pour voir s'il a été supprimé ou renommé (c'est le cas habituel des fichiers journaux pivotés) ; Cette option n'a de sens que lors du sondage (c'est-à-dire avec --use-polling) et quand --follow=name +tail-help-verbose = Toujours afficher des en-têtes donnant les noms de fichiers +tail-help-zero-terminated = Le délimiteur de ligne est NUL, pas newline +tail-help-retry = Continuer d'essayer d'ouvrir un fichier s'il est inaccessible +tail-help-follow-retry = Identique à --follow=name --retry +tail-help-polling-linux = Désactiver le support 'inotify' et utiliser le sondage à la place +tail-help-polling-unix = Désactiver le support 'kqueue' et utiliser le sondage à la place +tail-help-polling-windows = Désactiver le support 'ReadDirectoryChanges' et utiliser le sondage à la place + +# Messages d'erreur +tail-error-cannot-follow-stdin-by-name = impossible de suivre { $stdin } par nom +tail-error-cannot-open-no-such-file = impossible d'ouvrir '{ $file }' en lecture : { $error } +tail-error-reading-file = erreur de lecture de '{ $file }' : { $error } +tail-error-cannot-follow-file-type = { $file } : impossible de suivre la fin de ce type de fichier{ $msg } +tail-error-cannot-open-for-reading = impossible d'ouvrir '{ $file }' en lecture +tail-error-cannot-fstat = impossible de faire fstat { $file } : { $error } +tail-error-invalid-number-of-bytes = nombre d'octets invalide : { $arg } +tail-error-invalid-number-of-lines = nombre de lignes invalide : { $arg } +tail-error-invalid-number-of-seconds = nombre de secondes invalide : '{ $source }' +tail-error-invalid-max-unchanged-stats = nombre maximum invalide de statistiques inchangées entre les ouvertures : { $value } +tail-error-invalid-pid = PID invalide : { $pid } +tail-error-invalid-pid-with-error = PID invalide : { $pid } : { $error } +tail-error-invalid-number-out-of-range = nombre invalide : { $arg } : Résultat numérique hors limites +tail-error-invalid-number-overflow = nombre invalide : { $arg } +tail-error-option-used-in-invalid-context = option utilisée dans un contexte invalide -- { $option } +tail-error-bad-argument-encoding = encodage d'argument incorrect : { $arg } +tail-error-cannot-watch-parent-directory = impossible de surveiller le répertoire parent de { $path } +tail-error-backend-cannot-be-used-too-many-files = { $backend } ne peut pas être utilisé, retour au sondage : Trop de fichiers ouverts +tail-error-backend-resources-exhausted = ressources { $backend } épuisées +tail-error-notify-error = Erreur de notification : { $error } +tail-error-recv-timeout-error = Erreur de délai de réception : { $error } + +# Messages d'avertissement +tail-warning-retry-ignored = --retry ignoré ; --retry n'est utile que lors du suivi +tail-warning-retry-only-effective = --retry n'est effectif que pour l'ouverture initiale +tail-warning-pid-ignored = PID ignoré ; --pid=PID n'est utile que lors du suivi +tail-warning-pid-not-supported = --pid=PID n'est pas pris en charge sur ce système +tail-warning-following-stdin-ineffective = suivre l'entrée standard indéfiniment est inefficace + +# Messages de statut +tail-status-has-become-accessible = { $file } est devenu accessible +tail-status-has-appeared-following-new-file = { $file } est apparu ; suivi du nouveau fichier +tail-status-has-been-replaced-following-new-file = { $file } a été remplacé ; suivi du nouveau fichier +tail-status-file-truncated = { $file } : fichier tronqué +tail-status-replaced-with-untailable-file = { $file } a été remplacé par un fichier non suivable +tail-status-replaced-with-untailable-symlink = { $file } a été remplacé par un lien symbolique non suivable +tail-status-replaced-with-untailable-file-giving-up = { $file } a été remplacé par un fichier non suivable ; abandon de ce nom +tail-status-directory-containing-watched-file-removed = le répertoire contenant le fichier surveillé a été supprimé +tail-status-backend-cannot-be-used-reverting-to-polling = { $backend } ne peut pas être utilisé, retour au sondage + +# Constantes de texte +tail-bad-fd = Descripteur de fichier incorrect +tail-no-such-file-or-directory = Aucun fichier ou répertoire de ce type +tail-is-a-directory = Est un répertoire +tail-giving-up-on-this-name = ; abandon de ce nom +tail-stdin-header = entrée standard +tail-no-files-remaining = aucun fichier restant +tail-become-inaccessible = est devenu inaccessible diff --git a/userland/coreutils/vendor/uu_tail/src/args.rs b/userland/coreutils/vendor/uu_tail/src/args.rs new file mode 100644 index 0000000..95724cf --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/args.rs @@ -0,0 +1,688 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) kqueue Signum + +use crate::paths::Input; +use crate::{Quotable, parse, platform}; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; +use std::ffi::OsString; +use std::io::{IsTerminal, Write}; +use std::time::Duration; +use uucore::diagnostics::OptionValue; +use uucore::error::{UResult, USimpleError, UUsageError}; +use uucore::parser::parse_signed_num::{SignPrefix, number_offset, parse_signed_num_max}; +use uucore::parser::parse_size::ParseSizeError; +use uucore::parser::parse_time; +use uucore::parser::shortcut_value_parser::ShortcutValueParser; +use uucore::translate; +use uucore::{format_usage, show_warning}; + +pub mod options { + pub mod verbosity { + pub const QUIET: &str = "quiet"; + pub const VERBOSE: &str = "verbose"; + } + pub const BYTES: &str = "bytes"; + pub const FOLLOW: &str = "follow"; + pub const LINES: &str = "lines"; + pub const PID: &str = "pid"; + pub const SLEEP_INT: &str = "sleep-interval"; + pub const ZERO_TERM: &str = "zero-terminated"; + pub const DISABLE_INOTIFY_TERM: &str = "-disable-inotify"; // NOTE: three hyphens is correct + pub const USE_POLLING: &str = "use-polling"; + pub const RETRY: &str = "retry"; + pub const FOLLOW_RETRY: &str = "F"; + pub const MAX_UNCHANGED_STATS: &str = "max-unchanged-stats"; + pub const ARG_FILES: &str = "files"; + pub const PRESUME_INPUT_PIPE: &str = "-presume-input-pipe"; // NOTE: three hyphens is correct + pub const DEBUG: &str = "debug"; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Signum { + Negative(u64), + Positive(u64), + PlusZero, + MinusZero, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum FilterMode { + Bytes(Signum), + + /// Mode for lines delimited by delimiter as u8 + Lines(Signum, u8), +} + +impl FilterMode { + fn from_obsolete_args(args: &parse::ObsoleteArgs) -> Self { + let signum = if args.plus { + Signum::Positive(args.num) + } else { + Signum::Negative(args.num) + }; + if args.lines { + Self::Lines(signum, b'\n') + } else { + Self::Bytes(signum) + } + } + + fn from(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + // The plain message, or a caret under the part of the size at fault. + let raise = |message: String, arg: &str, short, long, error: &ParseSizeError| { + error.size_value_error( + diag_args, + &OptionValue::new(arg, short, long), + // The parser never saw the sign; the caret has to count it back in. + number_offset(arg), + &message, + USimpleError::new(1, message.clone()), + ) + }; + + let zero_term = matches.get_flag(options::ZERO_TERM); + let mode = if let Some(arg) = matches.get_one::(options::BYTES) { + match parse_num(arg) { + Ok(signum) => Self::Bytes(signum), + Err(e) => { + let message = + translate!("tail-error-invalid-number-of-bytes", "arg" => e.to_string()); + return Err(raise(message, arg, 'c', "bytes", &e)); + } + } + } else if let Some(arg) = matches.get_one::(options::LINES) { + match parse_num(arg) { + Ok(signum) => { + let delimiter = if zero_term { 0 } else { b'\n' }; + Self::Lines(signum, delimiter) + } + Err(e) => { + let message = + translate!("tail-error-invalid-number-of-lines", "arg" => arg.quote()); + return Err(raise(message, arg, 'n', "lines", &e)); + } + } + } else if zero_term { + Self::default_zero() + } else { + Self::default() + }; + + Ok(mode) + } + + fn default_zero() -> Self { + Self::Lines(Signum::Negative(10), 0) + } +} + +impl Default for FilterMode { + fn default() -> Self { + Self::Lines(Signum::Negative(10), b'\n') + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum FollowMode { + Descriptor, + Name, +} + +#[derive(Debug)] +pub enum VerificationResult { + Ok, + CannotFollowStdinByName, + NoOutput, +} + +#[derive(Debug)] +pub struct Settings { + pub follow: Option, + pub max_unchanged_stats: u32, + pub mode: FilterMode, + pub pid: Option, + pub retry: bool, + pub sleep_sec: Duration, + pub use_polling: bool, + pub verbose: bool, + pub presume_input_pipe: bool, + pub debug: bool, + /// `FILE(s)` positional arguments + pub inputs: Vec, +} + +impl Default for Settings { + fn default() -> Self { + Self { + max_unchanged_stats: 5, + sleep_sec: Duration::from_secs_f32(1.0), + follow: Option::default(), + mode: FilterMode::default(), + pid: Option::default(), + retry: Default::default(), + use_polling: Default::default(), + verbose: Default::default(), + presume_input_pipe: Default::default(), + debug: Default::default(), + inputs: Vec::default(), + } + } +} + +impl Settings { + pub fn from_obsolete_args(args: &parse::ObsoleteArgs, name: Option<&OsString>) -> Self { + let mut settings = Self::default(); + if args.follow { + settings.follow = if name.is_some() { + Some(FollowMode::Name) + } else { + Some(FollowMode::Descriptor) + }; + } + settings.mode = FilterMode::from_obsolete_args(args); + let input = if let Some(name) = name { + Input::from(name) + } else { + Input::default() + }; + settings.inputs.push(input); + settings + } + + pub fn from(matches: &ArgMatches) -> UResult { + Self::from_with_diagnostics(matches, None) + } + + fn from_with_diagnostics( + matches: &ArgMatches, + diag_args: Option<&[OsString]>, + ) -> UResult { + // We're parsing --follow, -F and --retry under the following conditions: + // * -F sets --retry and --follow=name + // * plain --follow or short -f is the same like specifying --follow=descriptor + // * All these options and flags can occur multiple times as command line arguments + let follow_retry = matches.get_flag(options::FOLLOW_RETRY); + // We don't need to check for occurrences of --retry if -F was specified which already sets + // retry + let retry = follow_retry || matches.get_flag(options::RETRY); + let follow = match ( + follow_retry, + matches + .get_one::(options::FOLLOW) + .map(String::as_str), + ) { + // -F and --follow if -F is specified after --follow. We don't need to care about the + // value of --follow. + (true, Some(_)) + // It's ok to use `index_of` instead of `indices_of` since -F and --follow + // overwrite themselves (not only the value but also the index). + if matches.index_of(options::FOLLOW_RETRY) > matches.index_of(options::FOLLOW) => + { + Some(FollowMode::Name) + } + // * -F and --follow=name if --follow=name is specified after -F + // * No occurrences of -F but --follow=name + // * -F and no occurrences of --follow + (_, Some("name")) | (true, None) => Some(FollowMode::Name), + // * -F and --follow=descriptor (or plain --follow, -f) if --follow=descriptor is + // specified after -F + // * No occurrences of -F but --follow=descriptor, --follow, -f + (_, Some(_)) => Some(FollowMode::Descriptor), + // The default for no occurrences of -F or --follow + (false, None) => None, + }; + + let mut settings: Self = Self { + follow, + retry, + use_polling: matches.get_flag(options::USE_POLLING), + mode: FilterMode::from(matches, diag_args)?, + verbose: matches.get_flag(options::verbosity::VERBOSE), + presume_input_pipe: matches.get_flag(options::PRESUME_INPUT_PIPE), + debug: matches.get_flag(options::DEBUG), + ..Default::default() + }; + + if let Some(source) = matches.get_one::(options::SLEEP_INT) { + settings.sleep_sec = parse_time::from_str(source, false).map_err(|_| { + UUsageError::new( + 1, + translate!("tail-error-invalid-number-of-seconds", "source" => source), + ) + })?; + } + + if let Some(s) = matches.get_one::(options::MAX_UNCHANGED_STATS) { + settings.max_unchanged_stats = match s.parse::() { + Ok(s) => s, + Err(_) => { + return Err(UUsageError::new( + 1, + translate!("tail-error-invalid-max-unchanged-stats", "value" => s.quote()), + )); + } + } + } + + if let Some(pid_str) = matches.get_one::(options::PID) { + match pid_str.parse() { + Ok(pid) => { + // NOTE: on unix platform::Pid is i32, on windows platform::Pid is u32 + #[cfg(unix)] + if pid < 0 { + // NOTE: tail only accepts an unsigned pid + return Err(USimpleError::new( + 1, + translate!("tail-error-invalid-pid", "pid" => pid_str.quote()), + )); + } + + settings.pid = Some(pid); + } + Err(e) => { + return Err(USimpleError::new( + 1, + translate!("tail-error-invalid-pid-with-error", "pid" => pid_str.quote(), "error" => e), + )); + } + } + } + + settings.inputs = matches + .get_many::(options::ARG_FILES) + .map_or_else(|| vec![Input::default()], |v| v.map(Input::from).collect()); + + settings.verbose = (matches.get_flag(options::verbosity::VERBOSE) + || settings.inputs.len() > 1) + && !matches.get_flag(options::verbosity::QUIET); + + Ok(settings) + } + + pub fn has_only_stdin(&self) -> bool { + self.inputs.iter().all(Input::is_stdin) + } + + pub fn has_stdin(&self) -> bool { + self.inputs.iter().any(Input::is_stdin) + } + + pub fn num_inputs(&self) -> usize { + self.inputs.len() + } + + /// Check [`Settings`] for problematic configurations of tail originating from user provided + /// command line arguments and print appropriate warnings. + pub fn check_warnings(&self) -> std::io::Result<()> { + if self.retry { + if self.follow.is_none() { + show_warning!("{}", translate!("tail-warning-retry-ignored")); + } else if self.follow == Some(FollowMode::Descriptor) { + show_warning!("{}", translate!("tail-warning-retry-only-effective")); + } + } + + if let Some(pid) = self.pid { + if self.follow.is_none() { + writeln!( + std::io::stderr().lock(), + "{}: warning: {}", + uucore::util_name(), + translate!("tail-warning-pid-ignored") + )?; + } else if pid != 0 && !platform::supports_pid_checks(pid) { + show_warning!("{}", translate!("tail-warning-pid-not-supported")); + } + } + + // This warning originates from gnu's tail implementation of the equivalent warning. If the + // user wants to follow stdin, but tail is blocking indefinitely anyways, because of stdin + // as `tty` (but no otherwise blocking stdin), then we print a warning that `--follow` + // cannot be applied under these circumstances and is therefore ineffective. + if self.follow.is_some() && self.has_stdin() { + #[cfg(unix)] + let stdin_is_regular = rustix::fs::fstat(std::io::stdin()) + .is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFREG); + #[cfg(not(unix))] + let stdin_is_regular = true; + let blocking_stdin = self.pid.unwrap_or_default() == 0 + && self.follow == Some(FollowMode::Descriptor) + && self.num_inputs() == 1 + && !stdin_is_regular; + + if !blocking_stdin && std::io::stdin().is_terminal() { + show_warning!("{}", translate!("tail-warning-following-stdin-ineffective")); + } + } + + Ok(()) + } + + /// Verify [`Settings`] and try to find unsolvable misconfigurations of tail originating from + /// user provided command line arguments. In contrast to [`Settings::check_warnings`] these + /// misconfigurations usually lead to the immediate exit or abortion of the running `tail` + /// process. + pub fn verify(&self) -> VerificationResult { + // Mimic GNU's tail for `tail -F` + if self.inputs.iter().any(Input::is_stdin) && self.follow == Some(FollowMode::Name) { + return VerificationResult::CannotFollowStdinByName; + } + + // Mimic GNU's tail for -[nc]0 without -f and exit immediately + if self.follow.is_none() + && matches!( + self.mode, + FilterMode::Lines(Signum::MinusZero, _) | FilterMode::Bytes(Signum::MinusZero) + ) + { + return VerificationResult::NoOutput; + } + + VerificationResult::Ok + } +} + +pub fn parse_obsolete(arg: &OsString, input: Option<&OsString>) -> UResult> { + match parse::parse_obsolete(arg) { + Some(Ok(args)) => Ok(Some(Settings::from_obsolete_args(&args, input))), + None => Ok(None), + Some(Err(e)) => { + Err(USimpleError::new( + 1, + match e { + // this ensures compatibility to GNU's error message (as tested in misc/tail) + parse::ParseError::Context => { + translate!( + "tail-error-option-used-in-invalid-context", + "option" => arg.to_string_lossy().chars().nth(1).unwrap_or_default(), + ) + } + parse::ParseError::InvalidEncoding => { + translate!("tail-error-bad-argument-encoding", "arg" => arg.quote()) + } + }, + )) + } + } +} + +fn parse_num(src: &str) -> Result { + let result = parse_signed_num_max(src)?; + // tail: '+' means "starting from line/byte N", default/'-' means "last N" + let is_plus = result.sign == Some(SignPrefix::Plus); + + match (result.value, is_plus) { + (0, true) => Ok(Signum::PlusZero), + (0, false) => Ok(Signum::MinusZero), + (n, true) => Ok(Signum::Positive(n)), + (n, false) => Ok(Signum::Negative(n)), + } +} + +pub fn parse_args(args: impl uucore::Args) -> UResult { + let args_vec: Vec = args.collect(); + let clap_args = uu_app().try_get_matches_from(args_vec.clone()); + let clap_result = match clap_args { + // Kept for the caret in size diagnostics, which needs the value as + // typed. + Ok(matches) => Ok(Settings::from_with_diagnostics( + &matches, + uucore::diagnostics::capture(&args_vec).as_deref(), + )?), + Err(err) => Err(err.into()), + }; + + // clap isn't able to handle obsolete syntax. + // therefore, we want to check further for obsolete arguments. + // argv[0] is always present, argv[1] might be obsolete arguments + // argv[2] might contain an input file, argv[3] isn't allowed in obsolete mode + if args_vec.len() != 2 && args_vec.len() != 3 { + return clap_result; + } + + // At this point, there are a few possible cases: + // + // 1. clap has succeeded and the arguments would be invalid for the obsolete syntax. + // 2. The case of `tail -c 5` is ambiguous. clap parses this as `tail -c5`, + // but it could also be interpreted as valid obsolete syntax (tail -c on file '5'). + // GNU chooses to interpret this as `tail -c5`, like clap. + // 3. `tail -f foo` is also ambiguous, but has the same effect in both cases. We can safely + // use the clap result here. + // 4. clap succeeded for obsolete arguments starting with '+', but misinterprets them as + // input files (e.g. 'tail +f'). + // 5. clap failed because of unknown flags, but possibly valid obsolete arguments + // (e.g. tail -l; tail -10c). + // + // In cases 4 & 5, we want to try parsing the obsolete arguments, which corresponds to + // checking whether clap succeeded or the first argument starts with '+'. + let possible_obsolete_args = &args_vec[1]; + if clap_result.is_ok() && possible_obsolete_args.as_encoded_bytes().first() != Some(&b'+') { + return clap_result; + } + match parse_obsolete(possible_obsolete_args, args_vec.get(2))? { + Some(settings) => Ok(settings), + None => clap_result, + } +} + +pub fn uu_app() -> Command { + #[cfg(target_os = "linux")] + let polling_help = translate!("tail-help-polling-linux"); + #[cfg(all(unix, not(target_os = "linux")))] + let polling_help = translate!("tail-help-polling-unix"); + #[cfg(windows)] + let polling_help = translate!("tail-help-polling-windows"); + #[cfg(not(any(unix, windows)))] + let polling_help = translate!("tail-help-polling-unix"); + + Command::new("tail") + .version(uucore::crate_version!()) + .help_template(uucore::localized_help_template("tail")) + .about(translate!("tail-about")) + .override_usage(format_usage(&translate!("tail-usage"))) + .infer_long_args(true) + .arg( + Arg::new(options::BYTES) + .short('c') + .long(options::BYTES) + .allow_hyphen_values(true) + .overrides_with_all([options::BYTES, options::LINES]) + .help(translate!("tail-help-bytes")), + ) + .arg( + Arg::new(options::FOLLOW) + .short('f') + .long(options::FOLLOW) + .default_missing_value("descriptor") + .num_args(0..=1) + .require_equals(true) + .value_parser(ShortcutValueParser::new(["descriptor", "name"])) + .overrides_with(options::FOLLOW) + .help(translate!("tail-help-follow")), + ) + .arg( + Arg::new(options::LINES) + .short('n') + .long(options::LINES) + .allow_hyphen_values(true) + .overrides_with_all([options::BYTES, options::LINES]) + .help(translate!("tail-help-lines")), + ) + .arg( + Arg::new(options::PID) + .long(options::PID) + .value_name("PID") + .help(translate!("tail-help-pid")) + .overrides_with(options::PID), + ) + .arg( + Arg::new(options::verbosity::QUIET) + .short('q') + .long(options::verbosity::QUIET) + .visible_alias("silent") + .overrides_with_all([options::verbosity::QUIET, options::verbosity::VERBOSE]) + .help(translate!("tail-help-quiet")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::SLEEP_INT) + .short('s') + .value_name("N") + .long(options::SLEEP_INT) + .help(translate!("tail-help-sleep-interval")), + ) + .arg( + Arg::new(options::MAX_UNCHANGED_STATS) + .value_name("N") + .long(options::MAX_UNCHANGED_STATS) + .help(translate!("tail-help-max-unchanged-stats")), + ) + .arg( + Arg::new(options::verbosity::VERBOSE) + .short('v') + .long(options::verbosity::VERBOSE) + .overrides_with_all([options::verbosity::QUIET, options::verbosity::VERBOSE]) + .help(translate!("tail-help-verbose")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::ZERO_TERM) + .short('z') + .long(options::ZERO_TERM) + .help(translate!("tail-help-zero-terminated")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::USE_POLLING) + .alias(options::DISABLE_INOTIFY_TERM) // NOTE: Used by GNU's test suite + .alias("dis") // NOTE: Used by GNU's test suite + .long(options::USE_POLLING) + .help(polling_help) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::RETRY) + .long(options::RETRY) + .help(translate!("tail-help-retry")) + .overrides_with(options::RETRY) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::FOLLOW_RETRY) + .short('F') + .help(translate!("tail-help-follow-retry")) + .overrides_with(options::FOLLOW_RETRY) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::DEBUG) + .long(options::DEBUG) + .help(translate!("tail-help-debug")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::PRESUME_INPUT_PIPE) + .long("presume-input-pipe") + .alias(options::PRESUME_INPUT_PIPE) + .hide(true) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::ARG_FILES) + .action(ArgAction::Append) + .num_args(1..) + .value_parser(value_parser!(OsString)) + .value_hint(clap::ValueHint::FilePath), + ) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use crate::parse::ObsoleteArgs; + + use super::*; + + #[test] + fn test_parse_num_when_sign_is_given() { + let result = parse_num("+0"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Signum::PlusZero); + + let result = parse_num("+1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Signum::Positive(1)); + + let result = parse_num("-0"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Signum::MinusZero); + + let result = parse_num("-1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Signum::Negative(1)); + } + + #[test] + fn test_parse_num_when_no_sign_is_given() { + let result = parse_num("0"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Signum::MinusZero); + + let result = parse_num("1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Signum::Negative(1)); + } + + #[test] + fn test_parse_obsolete_settings_f() { + let args = ObsoleteArgs { + follow: true, + ..Default::default() + }; + let result = Settings::from_obsolete_args(&args, None); + assert_eq!(result.follow, Some(FollowMode::Descriptor)); + + let result = Settings::from_obsolete_args(&args, Some(&"file".into())); + assert_eq!(result.follow, Some(FollowMode::Name)); + } + + #[rstest] + #[case::default(vec![], None, false)] + #[case::retry(vec!["--retry"], None, true)] + #[case::multiple_retry(vec!["--retry", "--retry"], None, true)] + #[case::follow_long(vec!["--follow"], Some(FollowMode::Descriptor), false)] + #[case::follow_short(vec!["-f"], Some(FollowMode::Descriptor), false)] + #[case::follow_long_with_retry(vec!["--follow", "--retry"], Some(FollowMode::Descriptor), true)] + #[case::follow_short_with_retry(vec!["-f", "--retry"], Some(FollowMode::Descriptor), true)] + #[case::follow_overwrites_previous_selection_1(vec!["--follow=name", "--follow=descriptor"], Some(FollowMode::Descriptor), false)] + #[case::follow_overwrites_previous_selection_2(vec!["--follow=descriptor", "--follow=name"], Some(FollowMode::Name), false)] + #[case::big_f(vec!["-F"], Some(FollowMode::Name), true)] + #[case::multiple_big_f(vec!["-F", "-F"], Some(FollowMode::Name), true)] + #[case::big_f_with_retry_then_does_not_change(vec!["-F", "--retry"], Some(FollowMode::Name), true)] + #[case::big_f_with_follow_descriptor_then_change(vec!["-F", "--follow=descriptor"], Some(FollowMode::Descriptor), true)] + #[case::multiple_big_f_with_follow_descriptor_then_no_change(vec!["-F", "--follow=descriptor", "-F"], Some(FollowMode::Name), true)] + #[case::big_f_with_follow_short_then_change(vec!["-F", "-f"], Some(FollowMode::Descriptor), true)] + #[case::follow_descriptor_with_big_f_then_change(vec!["--follow=descriptor", "-F"], Some(FollowMode::Name), true)] + #[case::follow_short_with_big_f_then_change(vec!["-f", "-F"], Some(FollowMode::Name), true)] + #[case::big_f_with_follow_name_then_not_change(vec!["-F", "--follow=name"], Some(FollowMode::Name), true)] + #[case::follow_name_with_big_f_then_not_change(vec!["--follow=name", "-F"], Some(FollowMode::Name), true)] + #[case::big_f_with_multiple_long_follow(vec!["--follow=name", "-F", "--follow=descriptor"], Some(FollowMode::Descriptor), true)] + #[case::big_f_with_multiple_long_follow_name(vec!["--follow=name", "-F", "--follow=name"], Some(FollowMode::Name), true)] + #[case::big_f_with_multiple_short_follow(vec!["-f", "-F", "-f"], Some(FollowMode::Descriptor), true)] + #[case::multiple_big_f_with_multiple_short_follow(vec!["-f", "-F", "-f", "-F"], Some(FollowMode::Name), true)] + fn test_parse_settings_follow_mode_and_retry( + #[case] args: Vec<&str>, + #[case] expected_follow_mode: Option, + #[case] expected_retry: bool, + ) { + let settings = + Settings::from(&uu_app().no_binary_name(true).get_matches_from(args)).unwrap(); + assert_eq!(settings.follow, expected_follow_mode); + assert_eq!(settings.retry, expected_retry); + } +} diff --git a/userland/coreutils/vendor/uu_tail/src/chunks.rs b/userland/coreutils/vendor/uu_tail/src/chunks.rs new file mode 100644 index 0000000..acf6b0a --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/chunks.rs @@ -0,0 +1,700 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Iterating over a file by chunks, either starting at the end of the file with [`ReverseChunks`] +//! or at the end of piped stdin with [`LinesChunk`] or [`BytesChunk`]. +//! +//! Use [`ReverseChunks::new`] to create a new iterator over chunks of bytes from the file. + +// spell-checker:ignore (ToDO) filehandle BUFSIZ + +use std::collections::VecDeque; +use std::fs::File; +use std::io::{BufRead, Read, Seek, SeekFrom, Write}; +use uucore::error::UResult; + +/// When reading files in reverse in `bounded_tail`, this is the size of each +/// block read at a time. +pub const BLOCK_SIZE: u64 = 1 << 16; + +/// The size of the backing buffer of a [`LinesChunk`] or [`BytesChunk`] in bytes. The value of `BUFFER_SIZE` +/// originates from the BUFSIZ constant in stdio.h and the libc crate to make stream IO efficient. +/// In the latter the value is constantly set to 8192 on all platforms, where the value in stdio.h +/// is determined on each platform differently. Since libc chose 8192 as a reasonable default the +/// value here is set to this value, too. +pub const BUFFER_SIZE: usize = 8192; + +/// An iterator over a file in non-overlapping chunks from the end of the file. +/// +/// Each chunk is a [`Vec`]<[`u8`]> of size [`BLOCK_SIZE`] (except +/// possibly the last chunk, which might be smaller). Each call to +/// [`ReverseChunks::next`] will seek backwards through the given file. +pub struct ReverseChunks<'a> { + /// The file to iterate over, by blocks, from the end to the beginning. + file: &'a File, + + /// The total number of bytes in the file. + size: u64, + + /// The total number of blocks to read. + max_blocks_to_read: usize, + + /// The index of the next block to read. + block_idx: usize, +} + +impl<'a> ReverseChunks<'a> { + pub fn new(file: &'a mut File) -> Self { + let current = if cfg!(unix) { + file.stream_position().unwrap() + } else { + 0 + }; + let size = file.seek(SeekFrom::End(0)).unwrap() - current; + let max_blocks_to_read = (size as f64 / BLOCK_SIZE as f64).ceil() as usize; + let block_idx = 0; + ReverseChunks { + file, + size, + max_blocks_to_read, + block_idx, + } + } +} + +impl Iterator for ReverseChunks<'_> { + type Item = Vec; + + fn next(&mut self) -> Option { + // If there are no more chunks to read, terminate the iterator. + if self.block_idx >= self.max_blocks_to_read { + return None; + } + + // The chunk size is `BLOCK_SIZE` for all but the last chunk + // (that is, the chunk closest to the beginning of the file), + // which contains the remainder of the bytes. + let block_size = if self.block_idx == self.max_blocks_to_read - 1 { + self.size % BLOCK_SIZE + } else { + BLOCK_SIZE + }; + + // Seek backwards by the next chunk, read the full chunk into + // `buf`, and then seek back to the start of the chunk again. + let mut buf = vec![0; block_size as usize]; + let pos = self + .file + .seek(SeekFrom::Current(-(block_size as i64))) + .unwrap(); + self.file.read_exact(&mut buf).unwrap(); + let pos2 = self + .file + .seek(SeekFrom::Current(-(block_size as i64))) + .unwrap(); + assert_eq!(pos, pos2); + + self.block_idx += 1; + + Some(buf) + } +} + +/// The type of the backing buffer of [`BytesChunk`] and [`LinesChunk`] which can hold +/// [`BUFFER_SIZE`] elements at max. +type ChunkBuffer = [u8; BUFFER_SIZE]; + +/// A [`BytesChunk`] storing a fixed size number of bytes in a buffer. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct BytesChunk { + /// The [`ChunkBuffer`], an array storing the bytes, for example filled by + /// [`BytesChunk::fill`] + buffer: ChunkBuffer, + + /// Stores the number of bytes, this buffer holds. This is not equal to `buffer.len()`, since the + /// [`BytesChunk`] may store less bytes than the internal buffer can hold. In addition, + /// [`BytesChunk`] may be reused, what makes it necessary to track the number of stored bytes. + /// The choice of usize is sufficient here, since the number of bytes max value is + /// [`BUFFER_SIZE`], which is a usize. + bytes: usize, +} + +impl BytesChunk { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + buffer: [0; BUFFER_SIZE], + bytes: 0, + } + } + + /// Create a new chunk from an existing chunk. The new chunk's buffer will be copied from the + /// old chunk's buffer, copying the slice `[offset..old_chunk.bytes]` into the new chunk's + /// buffer but starting at 0 instead of offset. If the offset is larger or equal to + /// `chunk.lines` then a new empty `BytesChunk` is returned. + /// + /// # Arguments + /// + /// * `chunk`: The chunk to create a new `BytesChunk` chunk from + /// * `offset`: Start to copy the old chunk's buffer from this position. May not be larger + /// than `chunk.bytes`. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = BytesChunk::new(); + /// chunk.buffer[1] = 1; + /// chunk.bytes = 2; + /// let new_chunk = BytesChunk::from_chunk(&chunk, 0); + /// assert_eq!(2, new_chunk.get_buffer().len()); + /// assert_eq!(&[0, 1], new_chunk.get_buffer()); + /// + /// let new_chunk = BytesChunk::from_chunk(&chunk, 1); + /// assert_eq!(1, new_chunk.get_buffer().len()); + /// assert_eq!(&[1], new_chunk.get_buffer()); + /// ``` + fn from_chunk(chunk: &Self, offset: usize) -> Self { + if offset >= chunk.bytes { + return Self::new(); + } + + let mut buffer: ChunkBuffer = [0; BUFFER_SIZE]; + let slice = chunk.get_buffer_with(offset); + buffer[..slice.len()].copy_from_slice(slice); + Self { + buffer, + bytes: chunk.bytes - offset, + } + } + + /// Receive the internal buffer safely, so it returns a slice only containing as many bytes as + /// large the `self.bytes` value is. + /// + /// returns: a slice containing the bytes of the internal buffer from `[0..self.bytes]` + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = BytesChunk::new(); + /// chunk.bytes = 1; + /// assert_eq!(&[0], chunk.get_buffer()); + /// ``` + pub fn get_buffer(&self) -> &[u8] { + &self.buffer[..self.bytes] + } + + /// Like [`BytesChunk::get_buffer`], but returning a slice from `[offset.self.bytes]`. + /// + /// returns: a slice containing the bytes of the internal buffer from `[offset..self.bytes]` + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = BytesChunk::new(); + /// chunk.bytes = 2; + /// assert_eq!(&[0], chunk.get_buffer_with(1)); + /// ``` + pub fn get_buffer_with(&self, offset: usize) -> &[u8] { + &self.buffer[offset..self.bytes] + } + + pub fn has_data(&self) -> bool { + self.bytes > 0 + } + + /// Fills `self.buffer` with maximal [`BUFFER_SIZE`] number of bytes, draining the reader by + /// that number of bytes. If EOF is reached (so 0 bytes are read), it returns + /// [`UResult`]; otherwise, it returns [`UResult`], where bytes is the + /// number of bytes read from the source. + pub fn fill(&mut self, filehandle: &mut impl BufRead) -> UResult> { + let num_bytes = filehandle.read(&mut self.buffer)?; + self.bytes = num_bytes; + if num_bytes == 0 { + return Ok(None); + } + + Ok(Some(self.bytes)) + } +} + +/// An abstraction layer on top of [`BytesChunk`] mainly to simplify filling only the needed amount +/// of chunks. See also [`Self::fill`]. +pub struct BytesChunkBuffer { + /// The number of bytes to print + num_print: u64, + /// The current number of bytes summed over all stored chunks in [`Self::chunks`]. Use u64 here + /// to support files > 4GB on 32-bit systems. Note, this differs from `BytesChunk::bytes` which + /// is a usize. The choice of u64 is based on `tail::FilterMode::Bytes`. + bytes: u64, + /// The buffer to store [`BytesChunk`] in + chunks: VecDeque>, +} + +impl BytesChunkBuffer { + /// Creates a new [`BytesChunkBuffer`]. + /// + /// # Arguments + /// + /// * `num_print`: The number of bytes to print + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = BytesChunk::new(); + /// chunk.buffer[1] = 1; + /// chunk.bytes = 2; + /// let new_chunk = BytesChunk::from_chunk(&chunk, 0); + /// assert_eq!(2, new_chunk.get_buffer().len()); + /// assert_eq!(&[0, 1], new_chunk.get_buffer()); + /// + /// let new_chunk = BytesChunk::from_chunk(&chunk, 1); + /// assert_eq!(1, new_chunk.get_buffer().len()); + /// assert_eq!(&[1], new_chunk.get_buffer()); + /// ``` + pub fn new(num_print: u64) -> Self { + Self { + bytes: 0, + num_print, + chunks: VecDeque::new(), + } + } + + /// Fills this buffer with chunks and consumes the reader completely. This method ensures that + /// there are exactly as many chunks as needed to match `self.num_print` bytes, so there are + /// in sum exactly `self.num_print` bytes stored in all chunks. The method returns an iterator + /// over these chunks. If there are no chunks, for example because the piped stdin contained no + /// bytes, or `num_print = 0` then `iterator.next` returns None. + /// + /// # Examples + /// + /// ```rust,ignore + /// use crate::chunks::BytesChunkBuffer; + /// use std::io::{BufReader, Cursor}; + /// + /// let mut reader = BufReader::new(Cursor::new("")); + /// let num_print = 0; + /// let mut chunks = BytesChunkBuffer::new(num_print); + /// chunks.fill(&mut reader).unwrap(); + /// + /// let mut reader = BufReader::new(Cursor::new("a")); + /// let num_print = 1; + /// let mut chunks = BytesChunkBuffer::new(num_print); + /// chunks.fill(&mut reader).unwrap(); + /// ``` + pub fn fill(&mut self, reader: &mut impl BufRead) -> UResult<()> { + let mut chunk = Box::new(BytesChunk::new()); + + // fill chunks with all bytes from reader and reuse already instantiated chunks if possible + while chunk.fill(reader)?.is_some() { + self.bytes += chunk.bytes as u64; + self.chunks.push_back(chunk.clone()); + + let first = &self.chunks[0]; + if self.bytes - first.bytes as u64 > self.num_print { + chunk = self.chunks.pop_front().unwrap(); + self.bytes -= chunk.bytes as u64; + } else { + *chunk = BytesChunk::new(); + } + } + + let Some(chunk) = self.chunks.pop_front() else { + // quit if there are no chunks for example in case the pipe was empty + return Ok(()); + }; + + // calculate the offset in the first chunk and put the calculated chunk as first element in + // the self.chunks collection. The calculated offset must be in the range 0 to BUFFER_SIZE + // and is therefore safely convertible to a usize without losses. + let offset = self.bytes.saturating_sub(self.num_print) as usize; + self.chunks + .push_front(Box::new(BytesChunk::from_chunk(&chunk, offset))); + + Ok(()) + } + + pub fn write(&self, writer: &mut impl Write) -> UResult<()> { + for chunk in &self.chunks { + writer.write_all(chunk.get_buffer())?; + } + Ok(()) + } + + pub fn has_data(&self) -> bool { + !self.chunks.is_empty() + } +} + +/// Works similar to a [`BytesChunk`] but also stores the number of lines encountered in the current +/// buffer. The size of the buffer is limited to a fixed size number of bytes. +#[derive(Clone, Debug)] +pub struct LinesChunk { + /// Work on top of a [`BytesChunk`] + chunk: BytesChunk, + /// The number of lines delimited by `delimiter`. The choice of usize is sufficient here, + /// because lines max value is the number of bytes contained in this chunk's buffer, and the + /// number of bytes max value is [`BUFFER_SIZE`], which is a usize. + lines: usize, + /// The delimiter to use, to count the lines + delimiter: u8, +} + +impl LinesChunk { + pub fn new(delimiter: u8) -> Self { + Self { + chunk: BytesChunk::new(), + lines: 0, + delimiter, + } + } + + /// Count the number of lines delimited with [`Self::delimiter`] contained in the buffer. + /// Currently [`memchr`] is used because performance is better than using an iterator or for + /// loop. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = LinesChunk::new(b'\n'); + /// chunk.buffer[0..12].copy_from_slice("hello\nworld\n".as_bytes()); + /// chunk.bytes = 12; + /// assert_eq!(2, chunk.count_lines()); + /// + /// chunk.buffer[0..14].copy_from_slice("hello\r\nworld\r\n".as_bytes()); + /// chunk.bytes = 14; + /// assert_eq!(2, chunk.count_lines()); + /// ``` + fn count_lines(&self) -> usize { + memchr::memchr_iter(self.delimiter, self.get_buffer()).count() + } + + /// Creates a new [`LinesChunk`] from an existing one with an offset in lines. The new chunk + /// contains exactly `chunk.lines - offset` lines. The offset in bytes is calculated and applied + /// to the new chunk, so the new chunk contains only the bytes encountered after the offset in + /// number of lines and the `delimiter`. If the offset is larger than `chunk.lines` then a new + /// empty `LinesChunk` is returned. + /// + /// # Arguments + /// + /// * `chunk`: The chunk to create the new chunk from + /// * `offset`: The offset in number of lines (not bytes) + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = LinesChunk::new(b'\n'); + /// // manually filling the buffer and setting the correct values for bytes and lines + /// chunk.buffer[0..12].copy_from_slice("hello\nworld\n".as_bytes()); + /// chunk.bytes = 12; + /// chunk.lines = 2; + /// + /// let offset = 1; // offset in number of lines + /// let new_chunk = LinesChunk::from(&chunk, offset); + /// assert_eq!("world\n".as_bytes(), new_chunk.get_buffer()); + /// assert_eq!(6, new_chunk.bytes); + /// assert_eq!(1, new_chunk.lines); + /// ``` + fn from_chunk(chunk: &Self, offset: usize) -> Self { + if offset > chunk.lines { + return Self::new(chunk.delimiter); + } + + let bytes_offset = chunk.calculate_bytes_offset_from(offset); + let new_chunk = BytesChunk::from_chunk(&chunk.chunk, bytes_offset); + + Self { + chunk: new_chunk, + lines: chunk.lines - offset, + delimiter: chunk.delimiter, + } + } + + /// Returns true if this buffer has stored any bytes. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = LinesChunk::new(b'\n'); + /// assert!(!chunk.has_data()); + /// + /// chunk.buffer[0] = 1; + /// assert!(!chunk.has_data()); + /// + /// chunk.bytes = 1; + /// assert!(chunk.has_data()); + /// ``` + pub fn has_data(&self) -> bool { + self.chunk.has_data() + } + + /// Returns this buffer safely. See [`BytesChunk::get_buffer`] + /// + /// returns: &[u8] with length `self.bytes` + pub fn get_buffer(&self) -> &[u8] { + self.chunk.get_buffer() + } + + /// Returns this buffer safely with an offset applied. See [`BytesChunk::get_buffer_with`]. + /// + /// returns: &[u8] with length `self.bytes - offset` + pub fn get_buffer_with(&self, offset: usize) -> &[u8] { + self.chunk.get_buffer_with(offset) + } + + /// Return the number of lines the buffer contains. `self.lines` needs to be set before the call + /// to this function returns the correct value. If the calculation of lines is needed then + /// use `self.count_lines`. + pub fn get_lines(&self) -> usize { + self.lines + } + + /// Fills `self.buffer` with maximal [`BUFFER_SIZE`] number of bytes, draining the reader by + /// that number of bytes. This function works like the [`BytesChunk::fill`] function besides + /// that this function also counts and stores the number of lines encountered while reading from + /// the `filehandle`. + pub fn fill(&mut self, filehandle: &mut impl BufRead) -> UResult> { + match self.chunk.fill(filehandle)? { + None => { + self.lines = 0; + Ok(None) + } + Some(bytes) => { + self.lines = self.count_lines(); + Ok(Some(bytes)) + } + } + } + + /// Calculates the offset in bytes within this buffer from the offset in number of lines. The + /// resulting offset is 0-based and points to the byte after the delimiter. + /// + /// # Arguments + /// + /// * `offset`: the offset in number of lines. If offset is 0 then 0 is returned, if larger than + /// the contained lines then self.bytes is returned. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut chunk = LinesChunk::new(b'\n'); + /// chunk.buffer[0..12].copy_from_slice("hello\nworld\n".as_bytes()); + /// chunk.bytes = 12; + /// chunk.lines = 2; // note that if not setting lines the result might not be what is expected + /// let bytes_offset = chunk.calculate_bytes_offset_from(1); + /// assert_eq!(6, bytes_offset); + /// assert_eq!( + /// "world\n", + /// String::from_utf8_lossy(chunk.get_buffer_with(bytes_offset))); + /// ``` + fn calculate_bytes_offset_from(&self, offset: usize) -> usize { + let mut lines_offset = offset; + let mut bytes_offset = 0; + for byte in self.get_buffer() { + if lines_offset == 0 { + break; + } + if byte == &self.delimiter { + lines_offset -= 1; + } + bytes_offset += 1; + } + bytes_offset + } + + /// Write the bytes contained in this buffer calculated with the given offset in number of + /// lines. + /// + /// # Arguments + /// + /// * `writer`: must implement [`Write`] + /// * `offset`: An offset in number of lines. + pub fn write_lines(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { + self.write_bytes(writer, self.calculate_bytes_offset_from(offset)) + } + + /// Write the bytes contained in this buffer beginning from the given offset in number of bytes. + /// + /// # Arguments + /// + /// * `writer`: must implement [`Write`] + /// * `offset`: An offset in number of bytes. + pub fn write_bytes(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { + writer.write_all(self.get_buffer_with(offset))?; + Ok(()) + } +} + +/// An abstraction layer on top of [`LinesChunk`] mainly to simplify filling only the needed amount +/// of chunks. See also [`Self::fill`]. Works similar like [`BytesChunkBuffer`], but works on top +/// of lines delimited by `self.delimiter` instead of bytes. +pub struct LinesChunkBuffer { + /// The delimiter to recognize a line. Any [`u8`] is allowed. + delimiter: u8, + /// The amount of lines occurring in all currently stored [`LinesChunk`]s. Use u64 here to + /// support files > 4GB on 32-bit systems. Note, this differs from [`LinesChunk::lines`] which + /// is a usize. The choice of u64 is based on `tail::FilterMode::Lines`. + lines: u64, + /// The amount of lines to print. + num_print: u64, + /// Stores the [`LinesChunk`] + chunks: VecDeque>, +} + +impl LinesChunkBuffer { + /// Create a new [`LinesChunkBuffer`] + pub fn new(delimiter: u8, num_print: u64) -> Self { + Self { + delimiter, + num_print, + lines: 0, + chunks: VecDeque::new(), + } + } + + /// Fills this buffer with chunks and consumes the reader completely. This method ensures that + /// there are exactly as many chunks as needed to match `self.num_print` lines, so there are + /// in sum exactly `self.num_print` lines stored in all chunks. The method returns an iterator + /// over these chunks. If there are no chunks, for example because the piped stdin contained no + /// lines, or `num_print = 0` then `iterator.next` will return None. + pub fn fill(&mut self, reader: &mut impl BufRead) -> UResult<()> { + let mut chunk = Box::new(LinesChunk::new(self.delimiter)); + + while chunk.fill(reader)?.is_some() { + self.lines += chunk.lines as u64; + self.chunks.push_back(chunk.clone()); + + let first = &self.chunks[0]; + if self.lines - first.lines as u64 > self.num_print { + chunk = self.chunks.pop_front().unwrap(); + + self.lines -= chunk.lines as u64; + } else { + *chunk = LinesChunk::new(self.delimiter); + } + } + + if self.chunks.is_empty() { + // chunks is empty when a file is empty so quitting early here + return Ok(()); + } + + let length = &self.chunks.len(); + let last = &mut self.chunks[length - 1]; + if !last.get_buffer().ends_with(&[self.delimiter]) { + last.lines += 1; + self.lines += 1; + } + + // skip unnecessary chunks and save the first chunk which may hold some lines we have to + // print + let chunk = loop { + // it's safe to call unwrap here because there is at least one chunk and sorting out + // more chunks than exist shouldn't be possible. + let chunk = self.chunks.pop_front().unwrap(); + + // skip is true as long there are enough lines left in the other stored chunks. + let skip = self.lines - chunk.lines as u64 > self.num_print; + if skip { + self.lines -= chunk.lines as u64; + } else { + break chunk; + } + }; + + // Calculate the number of lines to skip in the current chunk. The calculated value must be + // in the range 0 to BUFFER_SIZE and is therefore safely convertible to a usize without + // losses. + let skip_lines = self.lines.saturating_sub(self.num_print) as usize; + let chunk = LinesChunk::from_chunk(&chunk, skip_lines); + self.chunks.push_front(Box::new(chunk)); + + Ok(()) + } + + pub fn write(&self, mut writer: impl Write) -> UResult<()> { + for chunk in &self.chunks { + chunk.write_bytes(&mut writer, 0)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::chunks::{BUFFER_SIZE, BytesChunk}; + + #[test] + fn test_bytes_chunk_from_when_offset_is_zero() { + let mut chunk = BytesChunk::new(); + chunk.bytes = BUFFER_SIZE; + chunk.buffer[1] = 1; + let other = BytesChunk::from_chunk(&chunk, 0); + assert_eq!(other, chunk); + + chunk.bytes = 2; + let other = BytesChunk::from_chunk(&chunk, 0); + assert_eq!(other, chunk); + + chunk.bytes = 1; + let other = BytesChunk::from_chunk(&chunk, 0); + assert_eq!(other.buffer, [0; BUFFER_SIZE]); + assert_eq!(other.bytes, chunk.bytes); + + chunk.bytes = BUFFER_SIZE; + let other = BytesChunk::from_chunk(&chunk, 2); + assert_eq!(other.buffer, [0; BUFFER_SIZE]); + assert_eq!(other.bytes, BUFFER_SIZE - 2); + } + + #[test] + fn test_bytes_chunk_from_when_offset_is_not_zero() { + let mut chunk = BytesChunk::new(); + chunk.bytes = BUFFER_SIZE; + chunk.buffer[1] = 1; + + let other = BytesChunk::from_chunk(&chunk, 1); + let mut expected_buffer = [0; BUFFER_SIZE]; + expected_buffer[0] = 1; + assert_eq!(other.buffer, expected_buffer); + assert_eq!(other.bytes, BUFFER_SIZE - 1); + + let other = BytesChunk::from_chunk(&chunk, 2); + assert_eq!(other.buffer, [0; BUFFER_SIZE]); + assert_eq!(other.bytes, BUFFER_SIZE - 2); + } + + #[test] + fn test_bytes_chunk_from_when_offset_is_larger_than_chunk_size_1() { + let mut chunk = BytesChunk::new(); + chunk.bytes = BUFFER_SIZE; + let new_chunk = BytesChunk::from_chunk(&chunk, BUFFER_SIZE + 1); + assert_eq!(0, new_chunk.bytes); + } + + #[test] + fn test_bytes_chunk_from_when_offset_is_larger_than_chunk_size_2() { + let mut chunk = BytesChunk::new(); + chunk.bytes = 0; + let new_chunk = BytesChunk::from_chunk(&chunk, 1); + assert_eq!(0, new_chunk.bytes); + } + + #[test] + fn test_bytes_chunk_from_when_offset_is_larger_than_chunk_size_3() { + let mut chunk = BytesChunk::new(); + chunk.bytes = 1; + let new_chunk = BytesChunk::from_chunk(&chunk, 2); + assert_eq!(0, new_chunk.bytes); + } + + #[test] + fn test_bytes_chunk_from_when_offset_is_equal_to_chunk_size() { + let mut chunk = BytesChunk::new(); + chunk.buffer[0] = 1; + chunk.bytes = 1; + let new_chunk = BytesChunk::from_chunk(&chunk, 1); + assert_eq!(0, new_chunk.bytes); + } +} diff --git a/userland/coreutils/vendor/uu_tail/src/follow/files.rs b/userland/coreutils/vendor/uu_tail/src/follow/files.rs new file mode 100644 index 0000000..dfe90bd --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/follow/files.rs @@ -0,0 +1,212 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore tailable stdlib (stdlib) + +use crate::args::Settings; +use crate::chunks::BytesChunkBuffer; +use crate::paths::{HeaderPrinter, PathExtTail}; +use crate::text; +use std::collections::HashMap; +use std::collections::hash_map::Keys; +use std::fs::{File, Metadata}; +use std::io::{BufRead, BufReader, BufWriter, Write, stdout}; +use std::path::{Path, PathBuf}; +use uucore::error::UResult; + +/// Data structure to keep a handle on files to follow. +/// `last` always holds the path/key of the last file that was printed from. +/// The keys of the [`HashMap`] can point to an existing file path (normal case), +/// or stdin ("-"), or to a non-existing path (--retry). +/// For existing files, all keys in the [`HashMap`] are absolute Paths. +pub struct FileHandling { + map: HashMap, + last: Option, + header_printer: HeaderPrinter, +} + +impl FileHandling { + pub fn from(settings: &Settings) -> Self { + Self { + map: HashMap::with_capacity(settings.inputs.len()), + last: None, + header_printer: HeaderPrinter::new(settings.verbose, false), + } + } + + /// Wrapper for [`HashMap::insert`] using [`Path::canonicalize`] + pub fn insert(&mut self, k: &Path, v: PathData, update_last: bool) { + let k = Self::canonicalize_path(k); + if update_last { + self.last = Some(k.clone()); + } + let _ = self.map.insert(k, v); + } + + /// Wrapper for [`HashMap::remove`] using [`Path::canonicalize`] + pub fn remove(&mut self, k: &Path) -> PathData { + self.map.remove(&Self::canonicalize_path(k)).unwrap() + } + + /// Wrapper for [`HashMap::get`] using [`Path::canonicalize`] + pub fn get(&self, k: &Path) -> &PathData { + self.map.get(&Self::canonicalize_path(k)).unwrap() + } + + /// Wrapper for [`HashMap::get_mut`] using [`Path::canonicalize`] + pub fn get_mut(&mut self, k: &Path) -> &mut PathData { + self.map.get_mut(&Self::canonicalize_path(k)).unwrap() + } + + /// Canonicalize `path` if it is not already an absolute path + fn canonicalize_path(path: &Path) -> PathBuf { + if path.is_relative() + && !path.is_stdin() + && let Ok(p) = path.canonicalize() + { + return p; + } + path.to_owned() + } + + pub fn get_mut_metadata(&mut self, path: &Path) -> Option<&Metadata> { + self.get_mut(path).metadata.as_ref() + } + + pub fn keys(&self) -> Keys<'_, PathBuf, PathData> { + self.map.keys() + } + + pub fn contains_key(&self, k: &Path) -> bool { + self.map.contains_key(k) + } + + pub fn get_last(&self) -> Option<&PathBuf> { + self.last.as_ref() + } + + /// Return true if there is only stdin remaining + pub fn only_stdin_remaining(&self) -> bool { + self.map.len() == 1 && (self.map.contains_key(Path::new(text::DASH))) + } + + /// Return true if there is at least one "tailable" path (or stdin) remaining + pub fn files_remaining(&self) -> bool { + for path in self.map.keys() { + if path.is_tailable() || path.is_stdin() { + return true; + } + } + false + } + + /// Returns true if there are no files remaining + pub fn no_files_remaining(&self, settings: &Settings) -> bool { + self.map.is_empty() || !self.files_remaining() && !settings.retry + } + + /// Set `reader` to None to indicate that `path` is not an existing file anymore. + pub fn reset_reader(&mut self, path: &Path) { + self.get_mut(path).reader = None; + } + + /// Reopen the file at the monitored `path` + pub fn update_reader(&mut self, path: &Path) -> UResult<()> { + /* + BUG: If it's not necessary to reopen a file, GNU's tail calls seek to offset 0. + However, we can't call seek here because `BufRead` does not implement `Seek`. + As a workaround, we always reopen the file even though this might not always + be necessary. + */ + self.get_mut(path) + .reader + .replace(Box::new(BufReader::new(File::open(path)?))); + Ok(()) + } + + /// Reload metadata from `path`, or `metadata` + pub fn update_metadata(&mut self, path: &Path, metadata: Option) { + self.get_mut(path).metadata = if metadata.is_some() { + metadata + } else { + path.metadata().ok() + }; + } + + /// Read new data from `path` and print it to stdout + pub fn tail_file(&mut self, path: &Path, verbose: bool) -> UResult { + let mut chunks = BytesChunkBuffer::new(u64::MAX); + if let Some(reader) = self.get_mut(path).reader.as_mut() { + chunks.fill(reader)?; + } + if chunks.has_data() { + if self.needs_header(path, verbose) { + let display_name = self.get(path).display_name.clone(); + self.header_printer.print(display_name.as_str()); + } + + let mut writer = BufWriter::new(stdout().lock()); + chunks.write(&mut writer)?; + writer.flush()?; + + self.last.replace(path.to_owned()); + self.update_metadata(path, None); + Ok(true) + } else { + Ok(false) + } + } + + /// Decide if printing `path` needs a header based on when it was last printed + pub fn needs_header(&self, path: &Path, verbose: bool) -> bool { + if verbose { + if let Some(ref last) = self.last { + !last.eq(&path) + } else { + true + } + } else { + false + } + } +} + +/// Data structure to keep a handle on the [`BufReader`], [`Metadata`] +/// and the `display_name` (`header_name`) of files that are being followed. +pub struct PathData { + pub reader: Option>, + pub metadata: Option, + pub display_name: String, +} + +impl PathData { + pub fn new( + reader: Option>, + metadata: Option, + display_name: &str, + ) -> Self { + Self { + reader, + metadata, + display_name: display_name.to_owned(), + } + } + pub fn from_other_with_path(data: Self, path: &Path) -> Self { + // Remove old reader + let old_reader = data.reader; + let reader = if old_reader.is_some() { + // Use old reader with the same file descriptor if there is one + old_reader + } else if let Ok(file) = File::open(path) { + // Open new file tail from start + Some(Box::new(BufReader::new(file)) as Box) + } else { + // Probably file was renamed/moved or removed again + None + }; + + Self::new(reader, path.metadata().ok(), data.display_name.as_str()) + } +} diff --git a/userland/coreutils/vendor/uu_tail/src/follow/mod.rs b/userland/coreutils/vendor/uu_tail/src/follow/mod.rs new file mode 100644 index 0000000..2cd9b3a --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/follow/mod.rs @@ -0,0 +1,72 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#[cfg(not(target_os = "wasi"))] +mod files; +#[cfg(not(target_os = "wasi"))] +mod watch; + +#[cfg(not(target_os = "wasi"))] +pub use watch::{Observer, follow}; + +// WASI: notify/inotify are unavailable, so `tail -f` cannot work. +// Provide minimal stubs matching the real Observer API so tail compiles. +#[cfg(target_os = "wasi")] +mod wasi_stubs { + use crate::args::Settings; + use std::io::BufRead; + use std::path::Path; + use uucore::error::{UResult, USimpleError}; + + pub struct Observer { + pub use_polling: bool, + } + + impl Observer { + pub fn from(_settings: &Settings) -> Self { + Self { use_polling: false } + } + + #[allow(clippy::unnecessary_wraps)] + pub fn start(&mut self, _settings: &Settings) -> UResult<()> { + Ok(()) + } + + #[allow(clippy::unnecessary_wraps)] + pub fn add_path( + &mut self, + _path: &Path, + _display_name: &str, + _reader: Option>, + _update_last: bool, + ) -> UResult<()> { + Ok(()) + } + + #[allow(clippy::unnecessary_wraps)] + pub fn add_bad_path( + &mut self, + _path: &Path, + _display_name: &str, + _update_last: bool, + ) -> UResult<()> { + Ok(()) + } + + pub fn follow_name_retry(&self) -> bool { + false + } + } + + pub fn follow(_observer: Observer, _settings: &Settings) -> UResult<()> { + Err(USimpleError::new( + 1, + "follow mode is not supported on this platform", + )) + } +} + +#[cfg(target_os = "wasi")] +pub use wasi_stubs::{Observer, follow}; diff --git a/userland/coreutils/vendor/uu_tail/src/follow/watch.rs b/userland/coreutils/vendor/uu_tail/src/follow/watch.rs new file mode 100644 index 0000000..eebab67 --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/follow/watch.rs @@ -0,0 +1,675 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) tailable untailable stdlib kqueue Uncategorized unwatch + +use crate::args::{FollowMode, Settings}; +use crate::follow::files::{FileHandling, PathData}; +use crate::paths::{Input, InputKind, MetadataExtTail, PathExtTail}; +use crate::{platform, text}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher, WatcherKind}; +use std::io::BufRead; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, channel}; +use uucore::display::Quotable; +use uucore::error::{UResult, USimpleError, set_exit_code}; +#[cfg(target_os = "linux")] +use uucore::signals::ensure_stdout_not_broken; +use uucore::translate; + +use uucore::show_error; + +pub struct WatcherRx { + watcher: Box, + receiver: Receiver>, +} + +impl WatcherRx { + fn new( + watcher: Box, + receiver: Receiver>, + ) -> Self { + Self { watcher, receiver } + } + + /// Wrapper for `notify::Watcher::watch` to also add the parent directory of `path` if necessary. + fn watch_with_parent(&mut self, path: &Path) -> UResult<()> { + let mut path = path.to_owned(); + #[cfg(target_os = "linux")] + if path.is_file() { + /* + NOTE: Using the parent directory instead of the file is a workaround. + This workaround follows the recommendation of the notify crate authors: + > On some platforms, if the `path` is renamed or removed while being watched, behavior may + > be unexpected. See discussions in [#165] and [#166]. If less surprising behavior is wanted + > one may non-recursively watch the _parent_ directory as well and manage related events. + NOTE: Adding both: file and parent results in duplicate/wrong events. + Tested for notify::InotifyWatcher and for notify::PollWatcher. + */ + if let Some(parent) = path.parent() { + if parent.is_dir() { + path = parent.to_owned(); + } else { + path = PathBuf::from("."); + } + } else { + return Err(USimpleError::new( + 1, + translate!("tail-error-cannot-watch-parent-directory", "path" => path.quote()), + )); + } + } + if path.is_relative() { + path = path.canonicalize()?; + } + + // for syscalls: 2x "inotify_add_watch" ("filename" and ".") and 1x "inotify_rm_watch" + self.watch(&path, RecursiveMode::NonRecursive)?; + Ok(()) + } + + fn watch(&mut self, path: &Path, mode: RecursiveMode) -> UResult<()> { + self.watcher + .watch(path, mode) + .map_err(|err| USimpleError::new(1, err.to_string())) + } + + fn unwatch(&mut self, path: &Path) -> UResult<()> { + self.watcher + .unwatch(path) + .map_err(|err| USimpleError::new(1, err.to_string())) + } +} + +pub struct Observer { + /// Whether --retry was given on the command line + pub retry: bool, + + /// The [`FollowMode`] + pub follow: Option, + + /// Indicates whether to use the fallback `polling` method instead of the + /// platform specific event driven method. Since `use_polling` is subject to + /// change during runtime it is moved out of [`Settings`]. + pub use_polling: bool, + + pub watcher_rx: Option, + pub orphans: Vec, + pub files: FileHandling, + + pub pid: platform::Pid, +} + +impl Observer { + pub fn new( + retry: bool, + follow: Option, + use_polling: bool, + files: FileHandling, + pid: platform::Pid, + ) -> Self { + let pid = if platform::supports_pid_checks(pid) { + pid + } else { + 0 + }; + + Self { + retry, + follow, + use_polling, + watcher_rx: None, + orphans: Vec::new(), + files, + pid, + } + } + + pub fn from(settings: &Settings) -> Self { + Self::new( + settings.retry, + settings.follow, + settings.use_polling, + FileHandling::from(settings), + settings.pid.unwrap_or_default(), + ) + } + + pub fn add_path( + &mut self, + path: &Path, + display_name: &str, + reader: Option>, + update_last: bool, + ) -> UResult<()> { + if self.follow.is_some() { + let path = if path.is_relative() { + std::env::current_dir()?.join(path) + } else { + path.to_owned() + }; + let metadata = path.metadata().ok(); + self.files.insert( + &path, + PathData::new(reader, metadata, display_name), + update_last, + ); + } + + Ok(()) + } + + pub fn add_bad_path( + &mut self, + path: &Path, + display_name: &str, + update_last: bool, + ) -> UResult<()> { + if self.retry && self.follow.is_some() { + return self.add_path(path, display_name, None, update_last); + } + + Ok(()) + } + + pub fn start(&mut self, settings: &Settings) -> UResult<()> { + if settings.follow.is_none() { + return Ok(()); + } + + let (tx, rx) = channel(); + + /* + Watcher is implemented per platform using the best implementation available on that + platform. In addition to such event driven implementations, a polling implementation + is also provided that should work on any platform. + Linux / Android: inotify + macOS: FSEvents / kqueue + Windows: ReadDirectoryChangesWatcher + FreeBSD / NetBSD / OpenBSD / DragonflyBSD: kqueue + Fallback: polling every n seconds + + NOTE: + We force the use of kqueue with: features=["macos_kqueue"]. + On macOS only `kqueue` is suitable for our use case because `FSEvents` + waits for file close util it delivers a modify event. See: + https://github.com/notify-rs/notify/issues/240 + */ + + let watcher: Box; + let watcher_config = notify::Config::default() + .with_poll_interval(settings.sleep_sec) + /* + NOTE: By enabling compare_contents, performance will be significantly impacted + as all files will need to be read and hashed at each `poll_interval`. + However, this is necessary to pass: "gnu/tests/tail-2/F-vs-rename.sh" + */ + .with_compare_contents(true); + if self.use_polling || RecommendedWatcher::kind() == WatcherKind::PollWatcher { + self.use_polling = true; // We have to use polling because there's no supported backend + watcher = Box::new(notify::PollWatcher::new(tx, watcher_config).unwrap()); + } else { + let tx_clone = tx.clone(); + match RecommendedWatcher::new(tx, notify::Config::default()) { + Ok(w) => watcher = Box::new(w), + Err(e) if e.to_string().starts_with("Too many open files") => { + /* + NOTE: This ErrorKind is `Uncategorized`, but it is not recommended + to match an error against `Uncategorized` + NOTE: Could be tested with decreasing `max_user_instances`, e.g.: + `sudo sysctl fs.inotify.max_user_instances=64` + */ + show_error!( + "{}", + translate!("tail-error-backend-cannot-be-used-too-many-files", "backend" => text::BACKEND) + ); + set_exit_code(1); + self.use_polling = true; + watcher = Box::new(notify::PollWatcher::new(tx_clone, watcher_config).unwrap()); + } + Err(e) => return Err(USimpleError::new(1, e.to_string())), + } + } + + self.watcher_rx = Some(WatcherRx::new(watcher, rx)); + self.init_files(&settings.inputs)?; + + Ok(()) + } + + pub fn follow_descriptor(&self) -> bool { + self.follow == Some(FollowMode::Descriptor) + } + + pub fn follow_name(&self) -> bool { + self.follow == Some(FollowMode::Name) + } + + pub fn follow_descriptor_retry(&self) -> bool { + self.follow_descriptor() && self.retry + } + + pub fn follow_name_retry(&self) -> bool { + self.follow_name() && self.retry + } + + fn init_files(&mut self, inputs: &Vec) -> UResult<()> { + if let Some(watcher_rx) = &mut self.watcher_rx { + for input in inputs { + match input.kind() { + InputKind::Stdin => (), + InputKind::File(path) => { + #[cfg(all(unix, not(target_os = "linux")))] + if !path.is_file() { + continue; + } + let mut path = path.clone(); + if path.is_relative() { + path = std::env::current_dir()?.join(path); + } + + if path.is_tailable() { + // Add existing regular files to `Watcher` (InotifyWatcher). + watcher_rx.watch_with_parent(&path)?; + } else if let Some(active_parent) = path.parent().filter(|p| p.is_dir()) { + // If `path` is not a tailable file, add its parent to `Watcher`. + watcher_rx.watch(active_parent, RecursiveMode::NonRecursive)?; + // Add symlinks to orphans for retry polling (target may not exist) + if path.is_symlink() { + self.orphans.push(path); + } + } else { + // If there is no parent, add `path` to `orphans`. + self.orphans.push(path); + } + } + } + } + } + Ok(()) + } + + #[allow(clippy::cognitive_complexity)] + fn handle_event( + &mut self, + event: ¬ify::Event, + settings: &Settings, + ) -> UResult> { + use notify::event::{ + CreateKind, DataChange, EventKind, MetadataKind, ModifyKind, RemoveKind, RenameMode, + }; + + let event_path = event.paths.first().unwrap(); + let mut paths: Vec = vec![]; + let display_name = self.files.get(event_path).display_name.clone(); + + match event.kind { + // NOTE: `ModifyKind::Any` is emitted by the Windows `ReadDirectoryChangesW` + // backend (for `FILE_ACTION_MODIFIED`) and by the macOS/BSD `kqueue` backend + // (for `NOTE_WRITE`) whenever a watched file's contents change. Without it, + // appends are never followed on those platforms (see GH issue #4827). + // Linux inotify and the polling fallback report the more specific + // `Modify(Data(..))`/`Modify(Metadata(..))` instead, so this is a no-op there. + EventKind::Modify(ModifyKind::Any | ModifyKind::Metadata(MetadataKind::Any | MetadataKind::WriteTime) | ModifyKind::Data(DataChange::Any) | ModifyKind::Name(RenameMode::To)) | + EventKind::Create(CreateKind::File | CreateKind::Folder | CreateKind::Any) => { + if let Ok(new_md) = event_path.metadata() { + // `metadata()` follows symlinks, so under --follow=name a + // watched file swapped for a symlink would otherwise be + // silently followed to its target. GNU treats such a + // replacement as untailable. + let replaced_by_symlink = self.follow_name() + && event_path + .symlink_metadata() + .is_ok_and(|m| m.file_type().is_symlink()); + let is_tailable = !replaced_by_symlink && new_md.is_tailable(); + let pd = self.files.get(event_path); + if let Some(old_md) = &pd.metadata { + if is_tailable { + // The path was untailable (missing, or not a + // regular file) and has become tailable again: + // open the new file and read it from the start. + if !old_md.is_tailable() { + show_error!( + "{}", + translate!("tail-status-has-become-accessible", "file" => display_name.quote()) + ); + self.files.update_reader(event_path)?; + } else if pd.reader.is_none() { + show_error!( + "{}", + translate!("tail-status-has-appeared-following-new-file", "file" => display_name.quote()) + ); + self.files.update_reader(event_path)?; + } else if event.kind == EventKind::Modify(ModifyKind::Name(RenameMode::To)) + || (self.use_polling && !old_md.file_id_eq(&new_md)) { + show_error!( + "{}", + translate!("tail-status-has-been-replaced-following-new-file", "file" => display_name.quote()) + ); + self.files.update_reader(event_path)?; + } else if old_md.got_truncated(&new_md)? { + // A file that has lost content is read again + // from offset zero, since shortening a log + // almost always means it was emptied outright. + show_error!( + "{}", + translate!("tail-status-file-truncated", "file" => display_name) + ); + self.files.update_reader(event_path)?; + } + paths.push(event_path.clone()); + } else if !is_tailable && old_md.is_tailable() { + if replaced_by_symlink { + show_error!( + "{}", + translate!("tail-status-replaced-with-untailable-symlink", "file" => display_name.quote()) + ); + self.files.reset_reader(event_path); + } else if pd.reader.is_some() { + self.files.reset_reader(event_path); + } else { + show_error!( + "{}", + translate!("tail-status-replaced-with-untailable-file", "file" => display_name.quote()) + ); + } + } + } else if is_tailable { + show_error!( + "{}", + translate!("tail-status-has-appeared-following-new-file", "file" => display_name.quote()) + ); + self.files.update_reader(event_path)?; + paths.push(event_path.clone()); + } else if settings.retry { + if self.follow_descriptor() { + show_error!( + "{}", + translate!("tail-status-replaced-with-untailable-file-giving-up", "file" => display_name.quote()) + ); + let _ = self.watcher_rx.as_mut().unwrap().watcher.unwatch(event_path); + self.files.remove(event_path); + if self.files.no_files_remaining(settings) { + return Err(USimpleError::new(1, translate!("tail-no-files-remaining"))); + } + } else { + show_error!( + "{}", + translate!("tail-status-replaced-with-untailable-file", "file" => display_name.quote()) + ); + } + } + self.files.update_metadata(event_path, Some(new_md)); + } else if event_path.is_symlink() && settings.retry { + self.files.reset_reader(event_path); + self.orphans.push(event_path.clone()); + } + } + EventKind::Remove(RemoveKind::File | RemoveKind::Any) + | EventKind::Modify(ModifyKind::Name(RenameMode::From)) => { + if self.follow_name() { + if settings.retry { + if self.files.get_mut_metadata(event_path).is_some_and(MetadataExtTail::is_tailable) + && self.files.get(event_path).reader.is_some() { + show_error!( + "{} {}: {}", + display_name.quote(), + translate!("tail-become-inaccessible"), + translate!("tail-no-such-file-or-directory") + ); + } + if !event_path.has_active_parent() && !self.orphans.contains(event_path) { + show_error!("{}", translate!("tail-status-directory-containing-watched-file-removed")); + show_error!( + "{}", + translate!("tail-status-backend-cannot-be-used-reverting-to-polling", "backend" => text::BACKEND) + ); + self.orphans.push(event_path.clone()); + let _ = self.watcher_rx.as_mut().unwrap().unwatch(event_path); + } + } else { + show_error!( + "{}: {}", + display_name, + translate!("tail-no-such-file-or-directory") + ); + if !self.files.files_remaining() && self.use_polling { + // NOTE: GNU's tail exits here for `---disable-inotify` + return Err(USimpleError::new(1, translate!("tail-no-files-remaining"))); + } + } + self.files.reset_reader(event_path); + } else if self.follow_descriptor_retry() { + // --retry only effective for the initial open + let _ = self.watcher_rx.as_mut().unwrap().unwatch(event_path); + self.files.remove(event_path); + } else if self.use_polling && event.kind == EventKind::Remove(RemoveKind::Any) { + /* + BUG: The watched file was removed. Since we're using Polling, this + could be a rename. We can't tell because `notify::PollWatcher` doesn't + recognize renames properly. + Ideally we want to call seek to offset 0 on the file handle. + But because we only have access to `PathData::reader` as `BufRead`, + we cannot seek to 0 with `BufReader::seek_relative`. + Also because we don't have the new name, we cannot work around this + by simply reopening the file. + */ + } + } + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) + /* + NOTE: For `tail -f a`, keep tracking additions to b after `mv a b` + (gnu/tests/tail-2/descriptor-vs-rename.sh) + NOTE: The File/BufReader doesn't need to be updated. + However, we need to update our `files.map`. + This can only be done for inotify, because this EventKind does not + trigger for the PollWatcher. + BUG: As a result, there's a bug if polling is used: + $ tail -f file_a ---disable-inotify + $ mv file_a file_b + $ echo A >> file_b + $ echo A >> file_a + The last append to file_a is printed, however this shouldn't be because + after the "mv" tail should only follow "file_b". + TODO: [2022-05; jhscheer] add test for this bug + */ + + if self.follow_descriptor() => { + let new_path = event.paths.last().unwrap(); + paths.push(new_path.clone()); + + let new_data = PathData::from_other_with_path(self.files.remove(event_path), new_path); + self.files.insert( + new_path, + new_data, + self.files.get_last().unwrap() == event_path + ); + + // Unwatch old path and watch new path + let _ = self.watcher_rx.as_mut().unwrap().unwatch(event_path); + self.watcher_rx.as_mut().unwrap().watch_with_parent(new_path)?; + } + _ => {} + } + Ok(paths) + } +} + +#[allow(clippy::cognitive_complexity)] +pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { + if observer.files.no_files_remaining(settings) && !observer.files.only_stdin_remaining() { + return Err(USimpleError::new(1, translate!("tail-no-files-remaining"))); + } + + let process = platform::ProcessChecker::new(observer.pid); + + let mut timeout_counter = 0; + + // main follow loop + loop { + let mut _read_some = false; + + // If `--pid=p`, tail checks whether process p + // is alive at least every `--sleep-interval=N` seconds + if settings.follow.is_some() && observer.pid != 0 && process.is_dead() { + // p is dead, tail will also terminate + break; + } + + // For `-F` we need to poll if an orphan path becomes available during runtime. + // If a path becomes an orphan during runtime, it will be added to orphans. + // To be able to differentiate between the cases of test_retry8 and test_retry9, + // here paths will not be removed from orphans if the path becomes available. + if observer.follow_name_retry() { + for new_path in &observer.orphans { + // Use metadata() directly instead of exists() + metadata().unwrap() + // to avoid a TOCTOU race where the file is removed between the two calls. + if let Ok(md) = new_path.metadata() { + let pd = observer.files.get(new_path); + if md.is_tailable() && pd.reader.is_none() { + show_error!( + "{}", + translate!("tail-status-has-appeared-following-new-file", "file" => pd.display_name.quote()) + ); + observer.files.update_metadata(new_path, Some(md)); + observer.files.update_reader(new_path)?; + _read_some = observer.files.tail_file(new_path, settings.verbose)?; + observer + .watcher_rx + .as_mut() + .unwrap() + .watch_with_parent(new_path)?; + } + } + } + } + + // With -f, sleep for approximately N seconds (default 1.0) between iterations; + // We wake up if Notify sends an Event or if we wait more than `sleep_sec`. + let rx_result = observer + .watcher_rx + .as_mut() + .unwrap() + .receiver + .recv_timeout(settings.sleep_sec); + + if rx_result.is_ok() { + timeout_counter = 0; + } + + let mut paths = vec![]; // Paths worth checking for new content to print + + // Helper closure to process a single event + let process_event = |observer: &mut Observer, + event: notify::Event, + settings: &Settings, + paths: &mut Vec| + -> UResult<()> { + if event + .paths + .first() + .is_some_and(|path| observer.files.contains_key(path)) + { + // Handle Event if it is about a path that we are monitoring + let new_paths = observer.handle_event(&event, settings)?; + for p in new_paths { + if !paths.contains(&p) { + paths.push(p); + } + } + } + Ok(()) + }; + + match rx_result { + Ok(Ok(event)) => { + process_event(&mut observer, event, settings, &mut paths)?; + + // Drain any additional pending events to batch them together. + // This prevents redundant headers when multiple inotify events + // are queued (e.g., after resuming from SIGSTOP). + // Multiple iterations with spin_loop hints give the notify + // background thread chances to deliver pending events. + for _ in 0..100 { + while let Ok(Ok(event)) = + observer.watcher_rx.as_mut().unwrap().receiver.try_recv() + { + process_event(&mut observer, event, settings, &mut paths)?; + } + // Use both yield and spin hint for broader CPU support + std::thread::yield_now(); + std::hint::spin_loop(); + } + } + Ok(Err(notify::Error { + kind: notify::ErrorKind::Io(ref e), + paths, + })) if e.kind() == std::io::ErrorKind::NotFound => { + if let Some(event_path) = paths.first().filter(|p| observer.files.contains_key(p)) { + let _ = observer + .watcher_rx + .as_mut() + .unwrap() + .watcher + .unwatch(event_path); + } + } + Ok(Err(notify::Error { + kind: notify::ErrorKind::MaxFilesWatch, + .. + })) => { + return Err(USimpleError::new( + 1, + translate!("tail-error-backend-resources-exhausted", "backend" => text::BACKEND), + )); + } + Ok(Err(e)) => { + return Err(USimpleError::new( + 1, + translate!("tail-error-notify-error", "error" => e), + )); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + timeout_counter += 1; + // Check if stdout pipe is still open + #[cfg(target_os = "linux")] + if let Ok(false) = ensure_stdout_not_broken() { + return Ok(()); + } + } + Err(e) => { + return Err(USimpleError::new( + 1, + translate!("tail-error-recv-timeout-error", "error" => e), + )); + } + } + + if observer.use_polling && settings.follow.is_some() { + // Consider all files to potentially have new content. + // This is a workaround because `Notify::PollWatcher` + // does not recognize the "renaming" of files. + paths = observer.files.keys().cloned().collect::>(); + } + + // main print loop + for path in &paths { + _read_some = observer.files.tail_file(path, settings.verbose)?; + } + + if timeout_counter == settings.max_unchanged_stats { + /* + TODO: [2021-10; jhscheer] implement timeout_counter for each file. + `--max-unchanged-stats=n` counts per file, not once for the whole run: + a name that goes n polls (5 by default) without changing should be + re-opened and its device and inode compared against the handle still + held for it, which is how the swap behind a rotated log is noticed. + Only the polling path needs this, and only when following by name. + */ + } + } + + Ok(()) +} diff --git a/userland/coreutils/vendor/uu_tail/src/main.rs b/userland/coreutils/vendor/uu_tail/src/main.rs new file mode 100644 index 0000000..c72447c --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/main.rs @@ -0,0 +1,6 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +uucore::bin!(uu_tail); diff --git a/userland/coreutils/vendor/uu_tail/src/parse.rs b/userland/coreutils/vendor/uu_tail/src/parse.rs new file mode 100644 index 0000000..a95999a --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/parse.rs @@ -0,0 +1,178 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::ffi::OsString; + +#[derive(PartialEq, Eq, Debug, Copy, Clone)] +pub struct ObsoleteArgs { + pub num: u64, + pub plus: bool, + pub lines: bool, + pub follow: bool, +} + +impl Default for ObsoleteArgs { + fn default() -> Self { + Self { + num: 10, + plus: false, + lines: true, + follow: false, + } + } +} + +#[derive(PartialEq, Eq, Debug)] +pub enum ParseError { + Context, + InvalidEncoding, +} +/// Parses obsolete syntax +/// tail -\[NUM\]\[bcl\]\[f\] and tail +\[NUM\]\[bcl\]\[f\] +pub fn parse_obsolete(src: &OsString) -> Option> { + let Some(mut rest) = src.to_str() else { + return Some(Err(ParseError::InvalidEncoding)); + }; + let sign = if let Some(r) = rest.strip_prefix('-') { + rest = r; + '-' + } else { + let r = rest.strip_prefix('+')?; + rest = r; + '+' + }; + + let end_num = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + let has_num = !rest[..end_num].is_empty(); + let num: u64 = if has_num { + rest[..end_num].parse().unwrap_or(u64::MAX) + } else { + 10 + }; + rest = &rest[end_num..]; + + let mode = if let Some(r) = rest.strip_prefix('l') { + rest = r; + 'l' + } else if let Some(r) = rest.strip_prefix('c') { + rest = r; + 'c' + } else if let Some(r) = rest.strip_prefix('b') { + rest = r; + 'b' + } else { + 'l' + }; + + let follow = rest.contains('f'); + if !rest.chars().all(|f| f == 'f') { + // GNU allows an arbitrary amount of following fs, but nothing else + if sign == '-' && has_num { + return Some(Err(ParseError::Context)); + } + return None; + } + + let multiplier = if mode == 'b' { 512 } else { 1 }; + let num = num.saturating_mul(multiplier); + + Some(Ok(ObsoleteArgs { + num, + plus: sign == '+', + lines: mode == 'l', + follow, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_parse_numbers_obsolete() { + assert_eq!( + parse_obsolete(&OsString::from("+2c")), + Some(Ok(ObsoleteArgs { + num: 2, + plus: true, + lines: false, + follow: false, + })) + ); + assert_eq!( + parse_obsolete(&OsString::from("-5")), + Some(Ok(ObsoleteArgs { + num: 5, + plus: false, + lines: true, + follow: false, + })) + ); + assert_eq!( + parse_obsolete(&OsString::from("+100f")), + Some(Ok(ObsoleteArgs { + num: 100, + plus: true, + lines: true, + follow: true, + })) + ); + assert_eq!( + parse_obsolete(&OsString::from("-2b")), + Some(Ok(ObsoleteArgs { + num: 1024, + plus: false, + lines: false, + follow: false, + })) + ); + } + #[test] + fn test_parse_errors_obsolete() { + assert_eq!( + parse_obsolete(&OsString::from("-5n")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-5c5")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-1vzc")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-5m")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-1k")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-1mmk")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-105kzm")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-1vz")), + Some(Err(ParseError::Context)) + ); + assert_eq!( + parse_obsolete(&OsString::from("-1vzqvq")), // spell-checker:disable-line + Some(Err(ParseError::Context)) + ); + } + #[test] + fn test_parse_obsolete_no_match() { + assert_eq!(parse_obsolete(&OsString::from("-k")), None); + assert_eq!(parse_obsolete(&OsString::from("asd")), None); + assert_eq!(parse_obsolete(&OsString::from("-cc")), None); + } +} diff --git a/userland/coreutils/vendor/uu_tail/src/paths.rs b/userland/coreutils/vendor/uu_tail/src/paths.rs new file mode 100644 index 0000000..3d11dfe --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/paths.rs @@ -0,0 +1,249 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore tailable stdlib (stdlib) + +use crate::text; +use std::ffi::OsStr; +use std::fs::{File, Metadata}; +use std::io::{Seek, SeekFrom}; +#[cfg(unix)] +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::path::{Path, PathBuf}; +#[cfg(not(target_os = "wasi"))] +use uucore::error::UResult; +use uucore::translate; + +#[derive(Debug, Clone)] +pub enum InputKind { + File(PathBuf), + Stdin, +} + +#[cfg(unix)] +impl From<&OsStr> for InputKind { + fn from(value: &OsStr) -> Self { + if value == OsStr::new("-") { + Self::Stdin + } else { + Self::File(PathBuf::from(value)) + } + } +} + +#[cfg(not(unix))] +impl From<&OsStr> for InputKind { + fn from(value: &OsStr) -> Self { + if value == OsStr::new(text::DASH) { + Self::Stdin + } else { + Self::File(PathBuf::from(value)) + } + } +} + +#[derive(Debug, Clone)] +pub struct Input { + kind: InputKind, + pub display_name: String, +} + +impl Input { + pub fn from>(string: T) -> Self { + let string = string.as_ref(); + + let kind = string.into(); + let display_name = match kind { + InputKind::File(_) => string.to_string_lossy().to_string(), + InputKind::Stdin => translate!("tail-stdin-header"), + }; + + Self { kind, display_name } + } + + pub fn kind(&self) -> &InputKind { + &self.kind + } + + pub fn is_stdin(&self) -> bool { + match self.kind { + InputKind::File(_) => false, + InputKind::Stdin => true, + } + } + + pub fn resolve(&self) -> Option { + match &self.kind { + InputKind::File(path) if path != &PathBuf::from(text::DEV_STDIN) => { + path.canonicalize().ok() + } + InputKind::File(_) | InputKind::Stdin => { + // on macOS, /dev/fd isn't backed by /proc and canonicalize() + // on dev/fd/0 (or /dev/stdin) will fail (NotFound), + // so we treat stdin as a pipe here + // https://github.com/rust-lang/rust/issues/95239 + #[cfg(any(target_vendor = "apple", target_os = "wasi"))] + { + // WASI streams have no filesystem path. Read stdin directly. + None + } + #[cfg(not(any(target_vendor = "apple", target_os = "wasi")))] + { + PathBuf::from(text::FD0).canonicalize().ok() + } + } + } + } + + pub fn is_tailable(&self) -> bool { + match &self.kind { + InputKind::File(path) => path_is_tailable(path), + InputKind::Stdin => self.resolve().is_some_and(|path| path_is_tailable(&path)), + } + } +} + +impl Default for Input { + fn default() -> Self { + Self { + kind: InputKind::Stdin, + display_name: translate!("tail-stdin-header"), + } + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct HeaderPrinter { + verbose: bool, + first_header: bool, +} + +impl HeaderPrinter { + pub fn new(verbose: bool, first_header: bool) -> Self { + Self { + verbose, + first_header, + } + } + + pub fn print_input(&mut self, input: &Input) { + self.print(input.display_name.as_str()); + } + + pub fn print(&mut self, string: &str) { + if self.verbose { + println!( + "{}==> {string} <==", + if self.first_header { "" } else { "\n" }, + ); + self.first_header = false; + } + } +} +pub trait FileExtTail { + #[allow(clippy::wrong_self_convention)] + fn is_seekable(&mut self, current_offset: u64) -> bool; +} + +impl FileExtTail for File { + /// Test if File is seekable. + /// Set the current position offset to `current_offset`. + fn is_seekable(&mut self, current_offset: u64) -> bool { + self.stream_position().is_ok() + && self.seek(SeekFrom::End(0)).is_ok() + && self.seek(SeekFrom::Start(current_offset)).is_ok() + } +} + +pub trait MetadataExtTail { + fn is_tailable(&self) -> bool; + #[cfg(not(target_os = "wasi"))] + fn got_truncated(&self, other: &Metadata) -> UResult; + #[cfg(not(target_os = "wasi"))] + fn file_id_eq(&self, other: &Metadata) -> bool; +} + +impl MetadataExtTail for Metadata { + fn is_tailable(&self) -> bool { + let ft = self.file_type(); + #[cfg(unix)] + { + ft.is_file() || ft.is_char_device() || ft.is_fifo() + } + #[cfg(not(unix))] + { + ft.is_file() + } + } + + /// Return true if the file was modified and is now shorter + #[cfg(not(target_os = "wasi"))] + fn got_truncated(&self, other: &Metadata) -> UResult { + Ok(other.len() < self.len() && other.modified()? != self.modified()?) + } + + #[cfg(not(target_os = "wasi"))] + fn file_id_eq(&self, #[cfg(unix)] other: &Metadata, #[cfg(not(unix))] _: &Metadata) -> bool { + #[cfg(unix)] + { + self.ino().eq(&other.ino()) + } + #[cfg(windows)] + { + // TODO: `file_index` requires unstable library feature `windows_by_handle` + // use std::os::windows::prelude::*; + // if let Some(self_id) = self.file_index() { + // if let Some(other_id) = other.file_index() { + // // TODO: not sure this is the equivalent of comparing inode numbers + // + // return self_id.eq(&other_id); + // } + // } + false + } + } +} + +#[cfg(not(target_os = "wasi"))] +pub trait PathExtTail { + fn is_stdin(&self) -> bool; + fn has_active_parent(&self) -> bool; + fn is_tailable(&self) -> bool; +} + +#[cfg(not(target_os = "wasi"))] +impl PathExtTail for Path { + fn is_stdin(&self) -> bool { + self.eq(Self::new(text::DASH)) + || self.eq(Self::new(text::DEV_STDIN)) + || self.eq(Self::new(&translate!("tail-stdin-header"))) + } + + /// Return true if `path` has an existing parent directory + fn has_active_parent(&self) -> bool { + self.parent().is_some_and(Self::is_dir) + } + + /// Return true if `path` is a file type that can be tailed + fn is_tailable(&self) -> bool { + path_is_tailable(self) + } +} + +pub fn path_is_tailable(path: &Path) -> bool { + path.is_file() || path.exists() && path.metadata().is_ok_and(|meta| meta.is_tailable()) +} + +#[inline] +#[cfg(all(unix, not(target_os = "fuchsia")))] +pub fn stdin_is_bad_fd() -> bool { + uucore::signals::stdin_was_closed() +} + +#[inline] +#[cfg(not(all(unix, not(target_os = "fuchsia"))))] +pub fn stdin_is_bad_fd() -> bool { + false +} diff --git a/userland/coreutils/vendor/uu_tail/src/platform/mod.rs b/userland/coreutils/vendor/uu_tail/src/platform/mod.rs new file mode 100644 index 0000000..bb77501 --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/platform/mod.rs @@ -0,0 +1,30 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#[cfg(unix)] +pub use self::unix::{ + Pid, + ProcessChecker, + //stdin_is_bad_fd, stdin_is_pipe_or_fifo, supports_pid_checks, Pid, ProcessChecker, + supports_pid_checks, +}; + +#[cfg(windows)] +pub use self::windows::{Pid, ProcessChecker, supports_pid_checks}; + +// WASI has no process management; provide stubs so tail compiles. +#[cfg(target_os = "wasi")] +pub type Pid = u64; + +#[cfg(target_os = "wasi")] +pub fn supports_pid_checks(_pid: Pid) -> bool { + false +} + +#[cfg(unix)] +mod unix; + +#[cfg(windows)] +mod windows; diff --git a/userland/coreutils/vendor/uu_tail/src/platform/unix.rs b/userland/coreutils/vendor/uu_tail/src/platform/unix.rs new file mode 100644 index 0000000..e9e8c94 --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/platform/unix.rs @@ -0,0 +1,34 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) stdlib, ISCHR, GETFD +// spell-checker:ignore (options) EPERM, ENOSYS, NOSYS + +use rustix::process::{Pid as RustixPid, test_kill_process}; + +pub type Pid = i32; + +pub struct ProcessChecker { + pid: Pid, +} + +impl ProcessChecker { + pub fn new(process_id: Pid) -> Self { + Self { pid: process_id } + } + + pub fn is_dead(&self) -> bool { + RustixPid::from_raw(self.pid) + .is_none_or(|pid| test_kill_process(pid).is_err_and(|e| e != rustix::io::Errno::PERM)) + } +} + +impl Drop for ProcessChecker { + fn drop(&mut self) {} +} + +pub fn supports_pid_checks(pid: Pid) -> bool { + RustixPid::from_raw(pid).is_some_and(|p| test_kill_process(p) != Err(rustix::io::Errno::NOSYS)) +} diff --git a/userland/coreutils/vendor/uu_tail/src/platform/windows.rs b/userland/coreutils/vendor/uu_tail/src/platform/windows.rs new file mode 100644 index 0000000..2abf715 --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/platform/windows.rs @@ -0,0 +1,53 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::cell::Cell; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}; +use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, +}; +use windows_sys::core::BOOL; + +pub type Pid = u32; + +pub struct ProcessChecker { + dead: Cell, + handle: HANDLE, +} + +impl ProcessChecker { + pub fn new(process_id: Pid) -> Self { + #[allow(non_snake_case)] + let FALSE: BOOL = 0; + let h = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, FALSE, process_id) }; + Self { + dead: Cell::new(h.is_null()), + handle: h, + } + } + + pub fn is_dead(&self) -> bool { + if !self.dead.get() { + self.dead.set(unsafe { + let status = WaitForSingleObject(self.handle, 0); + status == WAIT_OBJECT_0 || status == WAIT_FAILED + }); + } + + self.dead.get() + } +} + +impl Drop for ProcessChecker { + fn drop(&mut self) { + unsafe { + CloseHandle(self.handle); + } + } +} + +pub fn supports_pid_checks(_pid: Pid) -> bool { + true +} diff --git a/userland/coreutils/vendor/uu_tail/src/tail.rs b/userland/coreutils/vendor/uu_tail/src/tail.rs new file mode 100644 index 0000000..b6da022 --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/tail.rs @@ -0,0 +1,626 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) seek'd tail'ing ringbuffer ringbuf unwatch +// spell-checker:ignore (ToDO) Uncategorized filehandle Signum memrchr +// spell-checker:ignore (libs) kqueue +// spell-checker:ignore (acronyms) +// spell-checker:ignore (env/flags) +// spell-checker:ignore (jargon) tailable untailable stdlib +// spell-checker:ignore (names) +// spell-checker:ignore (shell/tools) +// spell-checker:ignore (misc) + +pub mod args; +pub mod chunks; +mod follow; +mod parse; +mod paths; +mod platform; +pub mod text; + +pub use args::uu_app; +use args::{FilterMode, Settings, Signum, parse_args}; +use chunks::ReverseChunks; +use follow::Observer; +use memchr::{memchr_iter, memrchr_iter}; +use paths::{FileExtTail, HeaderPrinter, Input, InputKind}; +use std::cmp::Ordering; +use std::fs::File; +use std::io::{self, BufReader, BufWriter, ErrorKind, Read, Seek, SeekFrom, Write, stdin, stdout}; +use std::path::{Path, PathBuf}; +use uucore::display::Quotable; +use uucore::error::{FromIo, UResult, USimpleError, set_exit_code}; +use uucore::translate; + +use uucore::{show, show_error}; + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let settings = parse_args(args)?; + + settings.check_warnings()?; + + match settings.verify() { + args::VerificationResult::CannotFollowStdinByName => { + return Err(USimpleError::new( + 1, + translate!("tail-error-cannot-follow-stdin-by-name", "stdin" => text::DASH.quote()), + )); + } + // Exit early if we do not output anything. Note, that this may break a pipe + // when tail is on the receiving side. + args::VerificationResult::NoOutput => return Ok(()), + args::VerificationResult::Ok => {} + } + + uu_tail(&settings) +} + +fn uu_tail(settings: &Settings) -> UResult<()> { + let mut printer = HeaderPrinter::new(settings.verbose, true); + let mut observer = Observer::from(settings); + + observer.start(settings)?; + + // Print debug info about the follow implementation being used + if settings.debug && settings.follow.is_some() { + if observer.use_polling { + show_error!("{}", translate!("tail-debug-using-polling-mode")); + } else { + show_error!("{}", translate!("tail-debug-using-notification-mode")); + } + } + + // Do an initial tail print of each path's content. + // Add `path` and `reader` to `files` map if `--follow` is selected. + for input in &settings.inputs { + match input.kind() { + InputKind::Stdin => { + tail_stdin(settings, &mut printer, input, &mut observer)?; + } + InputKind::File(path) if cfg!(unix) && path == &PathBuf::from(text::DEV_STDIN) => { + tail_stdin(settings, &mut printer, input, &mut observer)?; + } + InputKind::File(path) => { + if let Err(err) = tail_file(settings, &mut printer, input, path, &mut observer, 0) { + show!(err); + } + } + } + } + + if settings.follow.is_some() { + /* + POSIX specification regarding tail -f + If the input file is a regular file or if the file operand specifies a FIFO, do not + terminate after the last line of the input file has been copied, but read and copy + further bytes from the input file when they become available. If no file operand is + specified and standard input is a pipe or FIFO, the -f option shall be ignored. If + the input file is not a FIFO, pipe, or regular file, it is unspecified whether or + not the -f option shall be ignored. + */ + if !settings.has_only_stdin() || settings.pid.is_some_and(|pid| pid != 0) { + follow::follow(observer, settings)?; + } + } + + Ok(()) +} + +fn tail_file( + settings: &Settings, + header_printer: &mut HeaderPrinter, + input: &Input, + path: &Path, + observer: &mut Observer, + offset: u64, +) -> UResult<()> { + // some platform has different read error message + #[cfg(not(unix))] + if path + .metadata() + .is_err_and(|e| e.kind() == ErrorKind::NotFound) + { + set_exit_code(1); + show_error!( + "{}", + translate!( + "tail-error-cannot-open-no-such-file", + "file" => input.display_name, + "error" => translate!("tail-no-such-file-or-directory") + ) + ); + observer.add_bad_path(path, input.display_name.as_str(), false)?; + return Ok(()); + } + + if path.is_dir() { + set_exit_code(1); + + header_printer.print_input(input); + let err_msg = translate!("tail-is-a-directory"); + + show_error!( + "{}", + translate!("tail-error-reading-file", "file" => input.display_name, "error" => err_msg) + ); + if settings.follow.is_some() { + let msg = if settings.retry { + "" + } else { + &translate!("tail-giving-up-on-this-name") + }; + show_error!( + "{}", + translate!("tail-error-cannot-follow-file-type", "file" => input.display_name, "msg" => msg) + ); + } + if !observer.follow_name_retry() { + return Ok(()); + } + observer.add_bad_path(path, input.display_name.as_str(), false)?; + } else { + #[cfg(unix)] + let open_result = open_file(path, settings.pid.is_some_and(|pid| pid != 0)); + #[cfg(not(unix))] + let open_result = File::open(path); + + match open_result { + Ok(mut file) => { + let st = file.metadata()?; + let blksize_limit = uucore::fs::sane_blksize::sane_blksize_from_metadata(&st); + header_printer.print_input(input); + let mut reader; + if !settings.presume_input_pipe + && file.is_seekable(if input.is_stdin() { offset } else { 0 }) + && (!st.is_file() || st.len() > blksize_limit) + { + bounded_tail(&mut file, settings)?; + reader = BufReader::new(file); + } else { + reader = BufReader::new(file); + unbounded_tail(&mut reader, settings)?; + } + if input.is_tailable() { + observer.add_path( + path, + input.display_name.as_str(), + Some(Box::new(reader)), + true, + )?; + } else { + observer.add_bad_path(path, input.display_name.as_str(), false)?; + } + } + Err(e) if e.kind() == ErrorKind::PermissionDenied => { + observer.add_bad_path(path, input.display_name.as_str(), false)?; + show!(e.map_err_context(|| { + translate!("tail-error-cannot-open-for-reading", "file" => input.display_name) + })); + } + Err(e) => { + observer.add_bad_path(path, input.display_name.as_str(), false)?; + return Err(e.map_err_context(|| { + translate!("tail-error-cannot-open-for-reading", "file" => input.display_name) + })); + } + } + } + + Ok(()) +} + +/// Opens a file, using non-blocking mode for FIFOs when `use_nonblock_for_fifo` is true. +/// +/// When opening a FIFO with `--pid`, we need to use O_NONBLOCK so that: +/// 1. The open() call doesn't block waiting for a writer +/// 2. We can periodically check if the monitored process is still alive +/// +/// After opening, we clear O_NONBLOCK so subsequent reads block normally. +/// Without `--pid`, FIFOs block on open() until a writer connects (GNU behavior). +#[cfg(unix)] +fn open_file(path: &Path, use_nonblock_for_fifo: bool) -> io::Result { + use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; + use std::fs::OpenOptions; + use std::os::unix::fs::{FileTypeExt, OpenOptionsExt}; + + let is_fifo = path.metadata().is_ok_and(|m| m.file_type().is_fifo()); + + if is_fifo && use_nonblock_for_fifo { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path)?; + + // Clear O_NONBLOCK so reads block normally + let flags = fcntl_getfl(&file)?; + let new_flags = flags & !OFlags::NONBLOCK; + fcntl_setfl(&file, new_flags)?; + + Ok(file) + } else { + File::open(path) + } +} + +fn tail_stdin( + settings: &Settings, + header_printer: &mut HeaderPrinter, + input: &Input, + observer: &mut Observer, +) -> UResult<()> { + // on macOS, resolve() will always return None for stdin, + // we need to detect if stdin is a directory ourselves. + // fstat-ing certain descriptors under /dev/fd fails with + // bad file descriptor or might not catch directory cases + // e.g. see the differences between running ls -l /dev/stdin /dev/fd/0 + // on macOS and Linux. + #[cfg(target_vendor = "apple")] + if uucore::fs::is_stdin_directory(&stdin()) { + set_exit_code(1); + show_error!( + "{}", + translate!("tail-error-cannot-open-no-such-file", "file" => input.display_name, "error" => translate!("tail-no-such-file-or-directory")) + ); + return Ok(()); + } + + // Check if stdin was closed before Rust reopened it as /dev/null + if paths::stdin_is_bad_fd() { + set_exit_code(1); + show_error!( + "{}", + translate!("tail-error-cannot-fstat", "file" => translate!("tail-stdin-header").quote(), "error" => translate!("tail-bad-fd")) + ); + show_error!("{}", translate!("tail-no-files-remaining")); + return Ok(()); + } + + if let Some(path) = input.resolve() { + #[cfg(not(unix))] + let stdin_offset = 0; + // Save the current seek position/offset of a stdin redirected file. + // This is needed to pass "gnu/tests/tail-2/start-middle.sh" + #[cfg(unix)] + let stdin_offset = rustix::fs::tell(stdin()).unwrap_or(0); // fifo + tail_file( + settings, + header_printer, + input, + &path, + observer, + stdin_offset, + )?; + } else { + // pipe + header_printer.print_input(input); + if paths::stdin_is_bad_fd() { + set_exit_code(1); + show_error!( + "{}", + translate!("tail-error-cannot-fstat", "file" => translate!("tail-stdin-header"), "error" => translate!("tail-bad-fd")) + ); + if settings.follow.is_some() { + show_error!( + "{}", + translate!("tail-error-reading-file", "file" => translate!("tail-stdin-header"), "error" => translate!("tail-bad-fd")) + ); + } + } else { + let mut reader = BufReader::new(stdin()); + unbounded_tail(&mut reader, settings)?; + } + } + + Ok(()) +} + +/// Find the index after the given number of instances of a given byte. +/// +/// This function reads through a given reader until `num_delimiters` +/// instances of `delimiter` have been seen, returning the index of +/// the byte immediately following that delimiter. If there are fewer +/// than `num_delimiters` instances of `delimiter`, this returns the +/// total number of bytes read from the `reader` until EOF. +/// +/// # Errors +/// +/// This function returns an error if there is an error during reading +/// from `reader`. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ```rust,ignore +/// use std::io::Cursor; +/// +/// let mut reader = Cursor::new("a\nb\nc\nd\ne\n"); +/// let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap(); +/// assert_eq!(i, 4); +/// ``` +/// +/// If `num_delimiters` is zero, then this function always returns +/// zero: +/// +/// ```rust,ignore +/// use std::io::Cursor; +/// +/// let mut reader = Cursor::new("a\n"); +/// let i = forwards_thru_file(&mut reader, 0, b'\n').unwrap(); +/// assert_eq!(i, 0); +/// ``` +/// +/// If there are fewer than `num_delimiters` instances of `delimiter` +/// in the reader, then this function returns the total number of +/// bytes read: +/// +/// ```rust,ignore +/// use std::io::Cursor; +/// +/// let mut reader = Cursor::new("a\n"); +/// let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap(); +/// assert_eq!(i, 2); +/// ``` +fn forwards_thru_file( + reader: &mut impl Read, + num_delimiters: u64, + delimiter: u8, +) -> io::Result { + // If num_delimiters == 0, always return 0. + if num_delimiters == 0 { + return Ok(0); + } + // Use a 32K buffer. + let mut buf = [0; 32 * 1024]; + let mut total = 0; + let mut count = 0; + // Iterate through the input, using `count` to record the number of times `delimiter` + // is seen. Once we find `num_delimiters` instances, return the offset of the byte + // immediately following that delimiter. + loop { + match reader.read(&mut buf) { + // Ok(0) => EoF before we found `num_delimiters` instance of `delimiter`. + // Return the total number of bytes read in that case. + Ok(0) => return Ok(total), + Ok(n) => { + // Use memchr_iter since it greatly improves search performance. + for offset in memchr_iter(delimiter, &buf[..n]) { + count += 1; + if count == num_delimiters { + // Return offset of the byte after the `delimiter` instance. + return Ok(total + offset + 1); + } + } + total += n; + } + Err(e) if e.kind() == ErrorKind::Interrupted => (), + Err(e) => return Err(e), + } + } +} + +/// Iterate over bytes in the file, in reverse, until we find the +/// `num_delimiters` instance of `delimiter`. The `file` is left seek'd to the +/// position just after that delimiter. +fn backwards_thru_file(file: &mut File, num_delimiters: u64, delimiter: u8) { + if num_delimiters == 0 { + file.seek(SeekFrom::End(0)).unwrap(); + return; + } + // This variable counts the number of delimiters found in the file + // so far (reading from the end of the file toward the beginning). + let mut counter = 0; + let mut first_slice = true; + for slice in ReverseChunks::new(file) { + // Iterate over each byte in the slice in reverse order. + let mut iter = memrchr_iter(delimiter, &slice); + + // Ignore a trailing newline in the last block, if there is one. + if first_slice { + if slice.last().is_some_and(|&c| c == delimiter) { + iter.next(); + } + first_slice = false; + } + + // For each byte, increment the count of the number of + // delimiters found. If we have found more than the specified + // number of delimiters, terminate the search and seek to the + // appropriate location in the file. + for i in iter { + counter += 1; + if counter >= num_delimiters { + // We should never over-count - assert that. + assert_eq!(counter, num_delimiters); + // After each iteration of the outer loop, the + // cursor in the file is at the *beginning* of the + // block, so seeking forward by `i + 1` bytes puts + // us right after the found delimiter. + file.seek(SeekFrom::Current((i + 1) as i64)).unwrap(); + return; + } + } + } +} + +/// When tail'ing a file, we do not need to read the whole file from start to +/// finish just to find the last n lines or bytes. Instead, we can seek to the +/// end of the file, and then read the file "backwards" in blocks of size +/// `BLOCK_SIZE` until we find the location of the first line/byte. This ends up +/// being a nice performance win for very large files. +fn bounded_tail(file: &mut File, settings: &Settings) -> UResult<()> { + debug_assert!(!settings.presume_input_pipe); + let mut limit = None; + + // Find the position in the file to start printing from. + match &settings.mode { + FilterMode::Lines(Signum::Negative(count), delimiter) => { + backwards_thru_file(file, *count, *delimiter); + } + FilterMode::Lines(Signum::Positive(count), delimiter) if count > &1 => { + let i = forwards_thru_file(file, *count - 1, *delimiter).unwrap(); + file.seek(SeekFrom::Start(i as u64)).unwrap(); + } + FilterMode::Lines(Signum::MinusZero, _) | FilterMode::Bytes(Signum::MinusZero) => { + file.seek(SeekFrom::End(0)).unwrap(); + } + FilterMode::Bytes(Signum::Negative(count)) => { + if file.seek(SeekFrom::End(-(*count as i64))).is_err() { + file.seek(SeekFrom::Start(0)).unwrap(); + } + limit = Some(*count); + } + FilterMode::Bytes(Signum::Positive(count)) if count > &1 => { + // GNU `tail` seems to index bytes and lines starting at 1, not + // at 0. It seems to treat `+0` and `+1` as the same thing. + // A start offset past the largest seekable position makes the + // underlying `lseek` fail with `EINVAL`; treat that like a start + // beyond the end of the file and produce no output. + file.seek(SeekFrom::Start(*count - 1)) + .or_else(|_| file.seek(SeekFrom::End(0))) + .unwrap(); + } + _ => {} + } + + print_target_section(file, limit)?; + Ok(()) +} + +fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UResult<()> { + let mut writer = BufWriter::new(stdout().lock()); + match &settings.mode { + FilterMode::Lines(Signum::Negative(count), sep) => { + let mut chunks = chunks::LinesChunkBuffer::new(*sep, *count); + chunks.fill(reader)?; + chunks.write(&mut writer)?; + } + FilterMode::Lines(Signum::PlusZero | Signum::Positive(1), _) + | FilterMode::Bytes(Signum::PlusZero | Signum::Positive(1)) => { + io::copy(reader, &mut writer)?; + } + FilterMode::Lines(Signum::Positive(count), sep) => { + let mut num_skip = *count - 1; + let mut chunk = chunks::LinesChunk::new(*sep); + while chunk.fill(reader)?.is_some() { + let lines = chunk.get_lines() as u64; + if lines < num_skip { + num_skip -= lines; + } else { + break; + } + } + if chunk.has_data() { + chunk.write_lines(&mut writer, num_skip as usize)?; + io::copy(reader, &mut writer)?; + } + } + FilterMode::Bytes(Signum::Negative(count)) => { + let mut chunks = chunks::BytesChunkBuffer::new(*count); + chunks.fill(reader)?; + chunks.write(&mut writer)?; + } + FilterMode::Lines(Signum::MinusZero, sep) => { + let mut chunks = chunks::LinesChunkBuffer::new(*sep, 0); + chunks.fill(reader)?; + chunks.write(&mut writer)?; + } + FilterMode::Bytes(Signum::Positive(count)) => { + let mut num_skip = *count - 1; + let mut chunk = chunks::BytesChunk::new(); + loop { + let Some(bytes) = chunk.fill(reader)? else { + return Ok(()); + }; + let bytes: u64 = bytes as u64; + match bytes.cmp(&num_skip) { + Ordering::Less => num_skip -= bytes, + Ordering::Equal => { + break; + } + Ordering::Greater => { + writer.write_all(chunk.get_buffer_with(num_skip as usize))?; + break; + } + } + } + + io::copy(reader, &mut writer)?; + } + _ => {} + } + #[cfg(not(windows))] + writer.flush()?; + + // SIGPIPE is not available on Windows. + #[cfg(windows)] + writer.flush().inspect_err(|err| { + if err.kind() == ErrorKind::BrokenPipe { + std::process::exit(13); + } + })?; + Ok(()) +} + +// Print the target section of the file +// use zero-copy on Linux +fn print_target_section< + #[cfg(any(target_os = "linux", target_os = "android"))] R: Read + rustix::fd::AsFd, + #[cfg(not(any(target_os = "linux", target_os = "android")))] R: Read, +>( + file: &mut R, + limit: Option, +) -> UResult<()> { + let stdout = stdout(); + let mut stdout = stdout.lock(); + if let Some(limit) = limit { + #[cfg(any(target_os = "linux", target_os = "android"))] + uucore::pipes::send_n_bytes(file, &mut stdout, limit)?; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let mut reader = file.take(limit); + io::copy(&mut reader, &mut stdout)?; + } + } else { + #[cfg(any(target_os = "linux", target_os = "android"))] + if uucore::pipes::splice_unbounded_auto(file, &mut stdout)?.is_err() { + io::copy(file, &mut stdout)?; + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + io::copy(file, &mut stdout)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + + use crate::forwards_thru_file; + use std::io::Cursor; + + #[test] + fn test_forwards_thru_file_zero() { + let mut reader = Cursor::new("a\n"); + let i = forwards_thru_file(&mut reader, 0, b'\n').unwrap(); + assert_eq!(i, 0); + } + + #[test] + fn test_forwards_thru_file_basic() { + // 01 23 45 67 89 + let mut reader = Cursor::new("a\nb\nc\nd\ne\n"); + let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap(); + assert_eq!(i, 4); + } + + #[test] + fn test_forwards_thru_file_past_end() { + let mut reader = Cursor::new("x\n"); + let i = forwards_thru_file(&mut reader, 2, b'\n').unwrap(); + assert_eq!(i, 2); + } +} diff --git a/userland/coreutils/vendor/uu_tail/src/text.rs b/userland/coreutils/vendor/uu_tail/src/text.rs new file mode 100644 index 0000000..16eb93b --- /dev/null +++ b/userland/coreutils/vendor/uu_tail/src/text.rs @@ -0,0 +1,22 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) kqueue + +// Non-localized constants (system paths and technical identifiers) +pub const DASH: &str = "-"; +pub const DEV_STDIN: &str = "/dev/stdin"; +pub const FD0: &str = "/dev/fd/0"; +pub const DEV_TTY: &str = "/dev/tty"; +pub const DEV_PTMX: &str = "/dev/ptmx"; + +#[cfg(target_os = "linux")] +pub const BACKEND: &str = "inotify"; +#[cfg(all(unix, not(target_os = "linux")))] +pub const BACKEND: &str = "kqueue"; +#[cfg(windows)] +pub const BACKEND: &str = "ReadDirectoryChanges"; +#[cfg(not(any(unix, windows)))] +pub const BACKEND: &str = "polling"; diff --git a/userland/findutils/src/find.rs b/userland/findutils/src/find.rs index 4c8b507..8c0b0bf 100644 --- a/userland/findutils/src/find.rs +++ b/userland/findutils/src/find.rs @@ -1,67 +1,175 @@ -//! Find world paths by name, type, path, depth, or modification time. -use std::{io::{self, Write}, path::PathBuf}; +//! Find world paths with short-circuit AND/OR predicates and directory pruning. use sausage_spawn::Command; +use std::{ + io::{self, Write}, + path::PathBuf, +}; -enum Predicate { Name(globset::GlobMatcher), Path(globset::GlobMatcher), Type(String), Newer(std::time::SystemTime) } +enum Predicate { + Name(globset::GlobMatcher), + Path(globset::GlobMatcher), + Type(String), + Newer(std::time::SystemTime), + Empty, + Prune, + Print(bool), + Exec(Vec), +} fn run() -> Result> { let args: Vec<_> = std::env::args().skip(1).collect(); let mut i = 0; let mut roots = Vec::new(); - while i < args.len() && !args[i].starts_with('-') { roots.push(PathBuf::from(&args[i])); i += 1; } - if roots.is_empty() { roots.push(".".into()); } - let mut predicates = Vec::new(); + while i < args.len() && !args[i].starts_with('-') { + roots.push(PathBuf::from(&args[i])); + i += 1; + } + if roots.is_empty() { + roots.push(".".into()); + } + let mut groups = vec![Vec::new()]; let mut maxdepth = usize::MAX; - let mut null = false; - let mut exec = None; + let mut mindepth = 0; + let mut explicit_action = false; while i < args.len() { - let flag = &args[i]; i += 1; - match flag.as_str() { - "-print" => (), "-print0" => null = true, - "-name" | "-path" | "-type" | "-maxdepth" | "-newer" => { - let value = args.get(i).ok_or("missing predicate argument")?; i += 1; + let flag = &args[i]; + i += 1; + let predicate = match flag.as_str() { + "-o" | "-or" => { + groups.push(Vec::new()); + continue; + } + "-a" | "-and" => continue, + "-prune" => Predicate::Prune, + "-empty" => Predicate::Empty, + "-print" | "-print0" => { + explicit_action = true; + Predicate::Print(flag == "-print0") + } + "-name" | "-path" | "-type" | "-maxdepth" | "-mindepth" | "-newer" => { + let value = args.get(i).ok_or("missing predicate argument")?; + i += 1; match flag.as_str() { - "-name" => predicates.push(Predicate::Name(globset::Glob::new(value)?.compile_matcher())), - "-path" => predicates.push(Predicate::Path(globset::Glob::new(value)?.compile_matcher())), - "-type" => predicates.push(Predicate::Type(value.clone())), - "-newer" => predicates.push(Predicate::Newer(std::fs::metadata(value)?.modified()?)), - _ => maxdepth = value.parse()?, + "-name" => Predicate::Name(globset::Glob::new(value)?.compile_matcher()), + "-path" => Predicate::Path(globset::Glob::new(value)?.compile_matcher()), + "-type" => Predicate::Type(value.clone()), + "-newer" => Predicate::Newer(std::fs::metadata(value)?.modified()?), + "-maxdepth" => { + maxdepth = value.parse()?; + continue; + } + _ => { + mindepth = value.parse()?; + continue; + } } } "-exec" => { let start = i; - while i < args.len() && args[i] != ";" { i += 1; } - if i == args.len() || i == start { return Err("-exec requires a command and ;".into()); } - exec = Some(args[start..i].to_vec()); i += 1; + while i < args.len() && args[i] != ";" { + i += 1; + } + if i == args.len() || i == start { + return Err("-exec requires a command and ;".into()); + } + explicit_action = true; + let argv = args[start..i].to_vec(); + i += 1; + Predicate::Exec(argv) } _ => return Err(format!("unsupported predicate: {flag}").into()), - } + }; + groups.last_mut().unwrap().push(predicate); } let mut status = 0; let mut out = io::BufWriter::new(io::stdout().lock()); for root in roots { - for entry in walkdir::WalkDir::new(root).max_depth(maxdepth) { - let entry = match entry { Ok(e) => e, Err(e) => { eprintln!("find: {e}"); status = 1; continue; } }; - let mut matches = true; - for predicate in &predicates { - matches &= match predicate { - Predicate::Name(g) => g.is_match(entry.file_name()), - Predicate::Path(g) => g.is_match(entry.path()), - Predicate::Type(t) => match t.as_str() { "f" => entry.file_type().is_file(), "d" => entry.file_type().is_dir(), "l" => entry.file_type().is_symlink(), _ => return Err("unsupported file type".into()) }, - Predicate::Newer(time) => entry.metadata()?.modified()? > *time, - }; + let mut walk = walkdir::WalkDir::new(root).max_depth(maxdepth).into_iter(); + while let Some(entry) = walk.next() { + let entry = match entry { + Ok(e) => e, + Err(e) => { + eprintln!("find: {e}"); + status = 1; + continue; + } + }; + if entry.depth() < mindepth { + continue; } - if !matches { continue; } - if let Some(argv) = &exec { - out.flush()?; - let argv: Vec<_> = argv.iter().map(|arg| arg.replace("{}", &entry.path().to_string_lossy())).collect(); - if let Err(e) = Command::new(&argv[0]).args(&argv[1..]).status() { eprintln!("find: {e}"); status = 1; } - } else { + let mut prune = false; + let mut matches = false; + for group in &groups { + let mut matched = true; + for predicate in group { + matched = match predicate { + Predicate::Name(g) => g.is_match(entry.file_name()), + Predicate::Path(g) => g.is_match(entry.path()), + Predicate::Type(t) => match t.as_str() { + "f" => entry.file_type().is_file(), + "d" => entry.file_type().is_dir(), + "l" => entry.file_type().is_symlink(), + _ => return Err("unsupported file type".into()), + }, + Predicate::Newer(time) => entry.metadata()?.modified()? > *time, + Predicate::Empty => { + if entry.file_type().is_dir() { + std::fs::read_dir(entry.path())?.next().is_none() + } else { + entry.file_type().is_file() && entry.metadata()?.len() == 0 + } + } + Predicate::Prune => { + prune = true; + true + } + Predicate::Print(null) => { + out.write_all(entry.path().as_os_str().as_encoded_bytes())?; + out.write_all(if *null { b"\0" } else { b"\n" })?; + true + } + Predicate::Exec(argv) => { + out.flush()?; + let argv: Vec<_> = argv + .iter() + .map(|arg| arg.replace("{}", &entry.path().to_string_lossy())) + .collect(); + match Command::new(&argv[0]).args(&argv[1..]).status() { + Ok(code) => code == 0, + Err(e) => { + eprintln!("find: {e}"); + status = 1; + false + } + } + } + }; + if !matched { + break; + } + } + if matched { + matches = true; + break; + } + } + if prune && entry.file_type().is_dir() { + walk.skip_current_dir(); + } + if matches && !explicit_action { out.write_all(entry.path().as_os_str().as_encoded_bytes())?; - out.write_all(if null { b"\0" } else { b"\n" })?; + out.write_all(b"\n")?; } } } out.flush()?; Ok(status) } -fn main() { std::process::exit(match run() { Ok(code) => code, Err(e) => { eprintln!("find: {e}"); 1 } }); } +fn main() { + std::process::exit(match run() { + Ok(code) => code, + Err(e) => { + eprintln!("find: {e}"); + 1 + } + }); +} diff --git a/userland/findutils/test.nu b/userland/findutils/test.nu index 9bb0c8c..0d7b2a4 100644 --- a/userland/findutils/test.nu +++ b/userland/findutils/test.nu @@ -1,9 +1,25 @@ #!/usr/bin/env nu -# Compare xargs stdout and exit codes with GNU xargs in a real world. +# Compare find and xargs with native tools in a real world. def main [host: path, bundle: path] { let world = (mktemp -d) mkdir ($world | path join world) cp -r ($bundle | path join bin) ($world | path join world bin) + let fixture = ($world | path join world fixture) + mkdir ($fixture | path join skip/deep) ($fixture | path join keep/deep) ($fixture | path join empty) + 'hidden' | save ($fixture | path join skip/deep/file) + 'shown' | save ($fixture | path join keep/deep/file) + '' | save ($fixture | path join zero) + '' | save ($fixture | path join reference) + ^touch -d '2001-01-01 UTC' ($fixture | path join reference) + for args in [[-name skip -prune -o -type f -print] [-path './skip' -prune -o -print] [-name skip -prune -o -type f] [-maxdepth 1] [-mindepth 1 -maxdepth 2] [-empty] [-newer reference] [-path './keep/*']] { + let native = (do { cd $fixture; ^find . ...$args | complete }) + let command = ($args | each {|arg| $arg | into string | to json -r} | str join ' ') + let guest = (^$host sh $world -c $"cd /fixture; find . ($command)" | complete) + if $native.exit_code != $guest.exit_code or ($native.stdout | lines | sort) != ($guest.stdout | lines | sort) { + error make {msg: $"find mismatch ($args): native=($native) guest=($guest). World: ($world)"} + } + print $"PASS find ($args)" + } let nul = (char nul) let cases = [ {input: "a\nb\n", args: [-I{} printf '<%s>\n' '{}']} diff --git a/userland/git/README.md b/userland/git/README.md index 365a211..55187e7 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -70,6 +70,9 @@ It also checks these forms: - `status -sb` and `status --porcelain=v1`. - `log -N`, `log -n N`, `--max-count=N`, `--oneline`, `-p`, `--stat`, and one literal path after `--`. - `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`. +- `log --decorate` and `--decorate=short`, including `--oneline`. +- `show -s --format=...` and `show -s --pretty=format:...`. +- `%P %p %T %t %cn %ce %cd %ar %cr %b %B %n` in log and show formats. - `log A..B`, `--name-only`, `--no-renames`, and `--diff-merges=first-parent`. - `%x00`, `%x1e`, and `%(trailers:key=Sausage-Conversation,valueonly)` in log formats. - `show --stat`, `--oneline`, `--name-only`, `show HEAD:path`, and merge commits without a combined diff. diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index da2d7cf..2ec5781 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -61,6 +61,7 @@ mod history; mod local; mod inspect; mod plumbing; +mod pretty; mod stash; use clap::{Parser, Subcommand}; #[derive(Parser)] @@ -78,6 +79,12 @@ pub struct Cli { #[derive(Subcommand)] enum Command { Show { + #[arg(short = 's', long = "no-patch")] + no_patch: bool, + #[arg(long)] + format: Option, + #[arg(long)] + pretty: Option, #[arg(long)] stat: bool, #[arg(long)] @@ -224,6 +231,8 @@ enum Command { no_gpg_sign: bool, }, Log { + #[arg(long, num_args = 0..=1, default_missing_value = "short", require_equals = true, value_parser = ["short"])] + decorate: Option, #[arg(long)] name_only: bool, #[arg(long)] @@ -988,6 +997,7 @@ pub fn run(cli: Cli) -> Result { } } Command::Log { + decorate, name_only, no_renames, diff_merges, @@ -1063,49 +1073,7 @@ pub fn run(cli: Cli) -> Result { .as_deref() .map(|s| s.strip_prefix("format:").unwrap_or(s))) { - let author = c.author().map_err(|e| error("log author", e))?; - let date = author - .time() - .map_err(|e| error("log date", e))? - .format_or_unix(gix::date::time::format::DEFAULT); - let mut labels = Vec::new(); - for reference in repository - .references() - .map_err(|e| error("log refs", e))? - .all() - .map_err(|e| error("log refs", e))? - { - let reference = reference.map_err(|e| error("log refs", e))?; - let name = reference.name().as_bstr().to_str_lossy(); - if repository - .rev_parse_single(name.as_ref()) - .map(|id| id.to_string() == c.id.to_string()) - .unwrap_or(false) - { - let short = reference.name().shorten().to_string(); - labels.push( - if head_info(&repository)?.branch.as_deref() == Some(name.as_ref()) - { - format!("HEAD -> {short}") - } else { - short - }, - ); - } - } - let decorations = if labels.is_empty() { - String::new() - } else { - format!(" ({})", labels.join(", ")) - }; - let text = plumbing::format(template, &message) - .replace("%H", &c.id.to_string()) - .replace("%h", &c.id.to_string()[..7]) - .replace("%s", message.lines().next().unwrap_or("")) - .replace("%an", &author.name.to_string()) - .replace("%ae", &author.email.to_string()) - .replace("%ad", &date) - .replace("%d", &decorations); + let text = pretty::format(&c, template)?; if pretty.is_some() { if !first { println!(); @@ -1117,15 +1085,24 @@ pub fn run(cli: Cli) -> Result { first = false; } else if oneline { println!( - "{} {}", + "{}{} {}", &c.id.to_string()[..7], + if decorate.is_some() { + pretty::decorations(&c)? + } else { + String::new() + }, message.lines().next().unwrap_or("") ); } else { if !first { println!(); } - output::commit(&c)?; + if decorate.is_some() { + output::decorated_commit(&c, &pretty::decorations(&c)?)?; + } else { + output::commit(&c)?; + } first = false; } if name_only && !diffs.is_empty() { @@ -1548,6 +1525,9 @@ pub fn run(cli: Cli) -> Result { )?; } Command::Show { + no_patch, + format, + pretty, revision, stat, oneline, @@ -1565,6 +1545,22 @@ pub fn run(cli: Cli) -> Result { .map_err(|e| error("show", e))?; } else { let commit = object.peel_to_commit().map_err(|e| error("show", e))?; + if no_patch { + if let Some(template) = format.as_deref().or(pretty + .as_deref() + .map(|s| s.strip_prefix("format:").unwrap_or(s))) + { + let text = pretty::format(&commit, template)?; + if pretty.is_some() && format.is_none() { + print!("{text}"); + } else { + println!("{text}"); + } + } else { + output::commit(&commit)?; + } + return Ok(0); + } let merge_commit = commit.parent_ids().count() > 1; if merge_commit && !stat { let mut common: Option> = None; @@ -1592,7 +1588,12 @@ pub fn run(cli: Cli) -> Result { return Ok(129); } } - if oneline { + if let Some(template) = format.as_deref().or(pretty + .as_deref() + .map(|s| s.strip_prefix("format:").unwrap_or(s))) + { + println!("{}\n", pretty::format(&commit, template)?); + } else if oneline { let decoded = commit.decode().map_err(|e| error("show", e))?; println!( "{} {}", diff --git a/userland/git/src/output.rs b/userland/git/src/output.rs index b06d5fa..1bd06bd 100644 --- a/userland/git/src/output.rs +++ b/userland/git/src/output.rs @@ -2,10 +2,13 @@ use crate::changes::{FileDiff, Kind, Status}; use gix::bstr::ByteSlice; pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> { + decorated_commit(commit, "") +} +pub(crate) fn decorated_commit(commit: &gix::Commit<'_>, decorations: &str) -> crate::Result<()> { let decoded = commit .decode() .map_err(|e| crate::repo::error("commit output", e))?; - println!("commit {}", commit.id); + println!("commit {}{}", commit.id, decorations); if decoded.parents.len() > 1 { println!( "Merge: {}", diff --git a/userland/git/src/pretty.rs b/userland/git/src/pretty.rs new file mode 100644 index 0000000..0886f40 --- /dev/null +++ b/userland/git/src/pretty.rs @@ -0,0 +1,157 @@ +//! Shared commit formats and short reference decorations for log and show. +use crate::{Result, error}; +use gix::bstr::ByteSlice; + +pub(crate) fn decorations(commit: &gix::Commit<'_>) -> Result { + let repo = commit.repo; + let head = crate::head_info(repo)?; + let mut labels = Vec::new(); + for reference in repo + .references() + .map_err(|e| error("refs", e))? + .all() + .map_err(|e| error("refs", e))? + { + let mut reference = reference.map_err(|e| error("refs", e))?; + let name = reference.name().as_bstr().to_str_lossy().into_owned(); + if reference + .peel_to_id() + .is_ok_and(|id| id.detach() == commit.id) + { + let short = reference.name().shorten().to_string(); + labels.push(if head.branch.as_deref() == Some(name.as_ref()) { + format!("HEAD -> {short}") + } else if name.starts_with("refs/tags/") { + format!("tag: {short}") + } else { + short + }); + } + } + if head.branch.is_none() + && repo + .rev_parse_single("HEAD") + .is_ok_and(|id| id.detach() == commit.id) + { + labels.push("HEAD".into()); + } + labels.sort_by(|a, b| { + b.starts_with("HEAD") + .cmp(&a.starts_with("HEAD")) + .then_with(|| b.starts_with("tag: ").cmp(&a.starts_with("tag: "))) + .then_with(|| b.cmp(a)) + }); + Ok(if labels.is_empty() { + String::new() + } else { + format!(" ({})", labels.join(", ")) + }) +} +fn relative(seconds: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let mut diff = now - seconds; + if diff < 0 { + return "in the future".into(); + } + let (count, unit) = if diff < 90 { + (diff, "second") + } else { + diff = (diff + 30) / 60; + if diff < 90 { + (diff, "minute") + } else { + diff = (diff + 30) / 60; + if diff < 36 { + (diff, "hour") + } else { + diff = (diff + 12) / 24; + if diff < 14 { + (diff, "day") + } else if diff < 70 { + ((diff + 3) / 7, "week") + } else if diff < 365 { + ((diff + 15) / 30, "month") + } else if diff < 1825 { + let total_months = (diff * 12 + 183) / 365; + let years = total_months / 12; + let months = total_months % 12; + if months != 0 { + return format!( + "{years} year{}, {months} month{} ago", + if years == 1 { "" } else { "s" }, + if months == 1 { "" } else { "s" } + ); + } + (years, "year") + } else { + ((diff + 183) / 365, "year") + } + } + } + }; + format!("{count} {unit}{} ago", if count == 1 { "" } else { "s" }) +} +pub(crate) fn format(commit: &gix::Commit<'_>, template: &str) -> Result { + let decoded = commit.decode().map_err(|e| error("format", e))?; + let message = decoded.message.to_str_lossy(); + let author = commit.author().map_err(|e| error("author", e))?; + let committer = commit.committer().map_err(|e| error("committer", e))?; + let at = author.time().map_err(|e| error("date", e))?; + let ct = committer.time().map_err(|e| error("date", e))?; + let parents = decoded + .parents + .iter() + .map(ToString::to_string) + .collect::>(); + let tree = decoded.tree.to_string(); + let id = commit.id.to_string(); + let fields = [ + ("%H", id.clone()), + ("%h", id[..7].into()), + ("%P", parents.join(" ")), + ( + "%p", + parents + .iter() + .map(|p| &p[..7]) + .collect::>() + .join(" "), + ), + ("%T", tree.clone()), + ("%t", tree[..7].into()), + ("%an", author.name.to_string()), + ("%ae", author.email.to_string()), + ("%ad", at.format_or_unix(gix::date::time::format::DEFAULT)), + ("%ar", relative(at.seconds)), + ("%cn", committer.name.to_string()), + ("%ce", committer.email.to_string()), + ("%cd", ct.format_or_unix(gix::date::time::format::DEFAULT)), + ("%cr", relative(ct.seconds)), + ("%s", message.lines().next().unwrap_or("").into()), + ( + "%b", + message.split_once("\n\n").map_or("", |(_, b)| b).into(), + ), + ("%B", message.to_string()), + ("%d", decorations(commit)?), + ("%n", "\n".into()), + ("%%", "%".into()), + ]; + let expanded = crate::plumbing::format(template, &message); + let mut rest = expanded.as_str(); + let mut text = String::new(); + while !rest.is_empty() { + if let Some((key, value)) = fields.iter().find(|(key, _)| rest.starts_with(key)) { + text.push_str(value); + rest = &rest[key.len()..]; + } else { + let ch = rest.chars().next().unwrap(); + text.push(ch); + rest = &rest[ch.len_utf8()..]; + } + } + Ok(text) +} diff --git a/userland/git/test.nu b/userland/git/test.nu index b90fe1c..66457c8 100644 --- a/userland/git/test.nu +++ b/userland/git/test.nu @@ -64,6 +64,11 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [log -n 1 --stat], compare: true} {args: [log --oneline -- file.txt], compare: true} {args: [show --stat], compare: true} + {args: [show -s '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true} + {args: [show -s '--pretty=format:%H%n%B'], compare: true} + {args: [log -1 '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true} + {args: [log --decorate], compare: true} + {args: [log --decorate=short --oneline], compare: true} {args: [show --oneline], compare: true} {args: [show --name-only], compare: true} {args: [show --oneline --stat], compare: true} @@ -88,6 +93,8 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {write: 'topic\n', file: 'topic.txt'} {args: [add .], compare: true} {args: [commit -m topic], compare: true} + {args: [log -1 '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true} + {args: [show -s '--pretty=format:%P%n%p%n%T%n%t'], compare: true} {args: [rev-parse HEAD HEAD~1], compare: true} {args: [log -1], compare: true} {args: [log -2], compare: true} @@ -199,6 +206,8 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [remote -v], compare: true} {args: [tag lightweight], compare: true, state: true} {args: [tag -a annotated -m annotation], compare: true, state: true} + {args: [log --decorate -1], compare: true} + {args: [log --decorate=short --oneline -1], compare: true} {args: [tag], compare: true} {args: [tag --list], compare: true} {args: [tag -d annotated], compare: true, state: true} @@ -332,7 +341,9 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [switch rework], compare: true} {args: [commit --allow-empty -m trailer], native_args: [commit --allow-empty -m 'trailer\n\nSausage-Conversation: conversation-34'], conversation: conversation-34, compare: true, state: true} {args: [log -n 1 --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00'], compare: true} - {args: [commit --allow-empty -m no-trailer], compare: true, state: true} + {args: [commit --allow-empty -m "no-trailer\n\nBody with literal %P and %cn."], compare: true, state: true} + {args: [show -s '--format=%P|%p|%T|%t|%cn|%ce|%cd|%ar|%cr|%b|%B|%n'], compare: true} + {args: [log -1 '--pretty=format:%b|%B|%n'], compare: true} {args: [log --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00' HEAD~3..HEAD], compare: true} {args: [diff --check], compare: true} {args: [diff --cached --check], compare: true} @@ -486,6 +497,15 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) } } ^git -C $no_identity reset --hard -q HEAD + for age in [2min 1hr 1day 15day 100day 1459day 2190day] { + let stamp = ((date now) - $age | format date '%s +0000') + with-env {GIT_AUTHOR_DATE: $stamp, GIT_COMMITTER_DATE: $stamp} { ^git -C $no_identity commit --no-gpg-sign --allow-empty -q -m relative } + let expected = (^git -C $no_identity show -s '--format=%ar|%cr' | complete) + let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo show -s '--format=%ar|%cr' | complete) + let equal = ($expected.exit_code == 0 and $actual.exit_code == 0 and $expected.stdout == $actual.stdout) + print $'relative date ($age): (if $equal {"PASS"} else {"FAIL"})' + if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) } + } ^git -C $no_identity config user.useConfigOnly true let identity_failures = (do { mut failed = 0 From 71da18bfe8e8899ed4de14ad3b8405cf9cf58103 Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 03:54:09 +0000 Subject: [PATCH 2/2] Remove temporary P1 diagnostics after bounded regression checks --- host/tests/it/p1.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/host/tests/it/p1.rs b/host/tests/it/p1.rs index 3855d09..6c9c5d3 100644 --- a/host/tests/it/p1.rs +++ b/host/tests/it/p1.rs @@ -98,7 +98,6 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { ("printf 'a\\n' | xargs echo", "a\n"), ("env FOO=1 printenv FOO", "1\n"), ] { - eprintln!("P1 command: {script}"); let mut out = Vec::new(); let code = exec .exec( @@ -124,7 +123,6 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { ("head -c 4 /dev/urandom | wc -c", "4\n"), ("echo x > /dev/null 2>&1; cat < /dev/null", ""), ] { - eprintln!("P1 command: {script}"); let mut out = Vec::new(); let code = exec .exec( @@ -153,7 +151,6 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { format!("printf '' | {utility}"), format!("printf 'g\\nb\\n' | {utility} | head -n 0"), ] { - eprintln!("P1 pipe: {script}"); let native = std::process::Command::new("bash") .args(["-c", &script]) .output()?; @@ -177,7 +174,6 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { assert_eq!(out, native.stdout, "{script}"); } } - eprintln!("P1 Git history"); let mut history = Vec::new(); assert_eq!( exec.exec(