From b1c975af64b8a19da3133357dd32b264313a4c23 Mon Sep 17 00:00:00 2001 From: Quin Gillespie Date: Tue, 20 Jan 2026 13:35:23 -0700 Subject: [PATCH 1/2] Fixed tail --follow=name continuing to tail a symlink target, unlike GNU tail. Closes #10328 --- src/uu/tail/locales/en-US.ftl | 1 + src/uu/tail/locales/fr-FR.ftl | 1 + src/uu/tail/src/follow/files.rs | 17 ++++++++++++- src/uu/tail/src/follow/watch.rs | 45 ++++++++++++++++++++++++++++++++- tests/by-util/test_tail.rs | 39 ++++++++++++++++++++++++++-- 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/uu/tail/locales/en-US.ftl b/src/uu/tail/locales/en-US.ftl index 6d434ae9862..21bf1c3d22e 100644 --- a/src/uu/tail/locales/en-US.ftl +++ b/src/uu/tail/locales/en-US.ftl @@ -58,6 +58,7 @@ tail-status-has-been-replaced-following-new-file = { $file } has been replaced; 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-file-giving-up = { $file } has been replaced with an untailable file; giving up on this name +tail-status-replaced-with-untailable-symlink = { $file } has been replaced with an untailable symbolic link tail-status-file-became-inaccessible = { $file } { $become_inaccessible }: { $no_such_file } 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 diff --git a/src/uu/tail/locales/fr-FR.ftl b/src/uu/tail/locales/fr-FR.ftl index 85d973571ae..e4c647c1abb 100644 --- a/src/uu/tail/locales/fr-FR.ftl +++ b/src/uu/tail/locales/fr-FR.ftl @@ -57,6 +57,7 @@ tail-status-has-been-replaced-following-new-file = { $file } a été remplacé ; 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-file-giving-up = { $file } a été remplacé par un fichier non suivable ; abandon de ce nom +tail-status-replaced-with-untailable-symlink = { $file } a été remplacé par un lien symbolique non suivable tail-status-file-became-inaccessible = { $file } { $become_inaccessible } : { $no_such_file } 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 diff --git a/src/uu/tail/src/follow/files.rs b/src/uu/tail/src/follow/files.rs index af9ed39d4eb..8477a5b68a5 100644 --- a/src/uu/tail/src/follow/files.rs +++ b/src/uu/tail/src/follow/files.rs @@ -134,6 +134,10 @@ impl FileHandling { }; } + pub fn update_symlink(&mut self, path: &Path, is_symlink: bool) { + self.get_mut(path).is_symlink = is_symlink; + } + /// 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); @@ -178,6 +182,7 @@ pub struct PathData { pub reader: Option>, pub metadata: Option, pub display_name: String, + pub is_symlink: bool, } impl PathData { @@ -185,11 +190,13 @@ impl PathData { reader: Option>, metadata: Option, display_name: &str, + is_symlink: bool, ) -> Self { Self { reader, metadata, display_name: display_name.to_owned(), + is_symlink, } } pub fn from_other_with_path(data: Self, path: &Path) -> Self { @@ -205,7 +212,15 @@ impl PathData { // Probably file was renamed/moved or removed again None }; + let is_symlink = path + .symlink_metadata() + .is_ok_and(|meta| meta.file_type().is_symlink()); - Self::new(reader, path.metadata().ok(), data.display_name.as_str()) + Self::new( + reader, + path.metadata().ok(), + data.display_name.as_str(), + is_symlink, + ) } } diff --git a/src/uu/tail/src/follow/watch.rs b/src/uu/tail/src/follow/watch.rs index b195ab0a47a..8f472d82f31 100644 --- a/src/uu/tail/src/follow/watch.rs +++ b/src/uu/tail/src/follow/watch.rs @@ -151,9 +151,12 @@ impl Observer { path.to_owned() }; let metadata = path.metadata().ok(); + let is_symlink = path + .symlink_metadata() + .is_ok_and(|meta| meta.file_type().is_symlink()); self.files.insert( &path, - PathData::new(reader, metadata, display_name), + PathData::new(reader, metadata, display_name, is_symlink), update_last, ); } @@ -304,8 +307,29 @@ impl Observer { EventKind::Modify(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() { + let new_is_symlink = event_path + .symlink_metadata() + .is_ok_and(|meta| meta.file_type().is_symlink()); let is_tailable = new_md.is_tailable(); let pd = self.files.get(event_path); + + if self.follow_name() && !pd.is_symlink && new_is_symlink { + if pd.reader.is_some() { + self.files.reset_reader(event_path); + } + show_error!( + "{}", + translate!( + "tail-status-replaced-with-untailable-symlink", + "file" => display_name.quote() + ) + ); + self.files + .update_metadata(event_path, event_path.symlink_metadata().ok()); + self.files.update_symlink(event_path, true); + return Ok(paths); + } + if let Some(old_md) = &pd.metadata { if is_tailable { // We resume tracking from the start of the file, @@ -374,6 +398,7 @@ impl Observer { } } self.files.update_metadata(event_path, Some(new_md)); + self.files.update_symlink(event_path, new_is_symlink); } } EventKind::Remove(RemoveKind::File | RemoveKind::Any) @@ -497,12 +522,30 @@ pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { if new_path.exists() { let pd = observer.files.get(new_path); let md = new_path.metadata().unwrap(); + let new_is_symlink = new_path + .symlink_metadata() + .is_ok_and(|meta| meta.file_type().is_symlink()); + if !pd.is_symlink && new_is_symlink { + show_error!( + "{}", + translate!( + "tail-status-replaced-with-untailable-symlink", + "file" => pd.display_name.quote() + ) + ); + observer + .files + .update_metadata(new_path, new_path.symlink_metadata().ok()); + observer.files.update_symlink(new_path, true); + continue; + } 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_symlink(new_path, new_is_symlink); observer.files.update_reader(new_path)?; _read_some = observer.files.tail_file(new_path, settings.verbose)?; observer diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 9d4a270e2b2..afed3c02243 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -3684,10 +3684,8 @@ fn test_when_argument_file_is_a_symlink() { fn test_when_argument_file_is_a_symlink_to_directory_then_error() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - at.mkdir("dir"); at.symlink_file("dir", "dir_link"); - let expected = "tail: error reading 'dir_link': Is a directory\n"; ts.ucmd() .arg("dir_link") @@ -3695,6 +3693,43 @@ fn test_when_argument_file_is_a_symlink_to_directory_then_error() { .stderr_only(expected); } +// TODO: make this work on windows +#[test] +#[cfg(unix)] +fn test_follow_name_replaced_with_symlink() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + let file = "testfile"; + let target = "target"; + + at.write(file, "original\n"); + at.write(target, "target\n"); + + let mut child = ts + .ucmd() + .args(&[ + "--follow=name", + "-n", + "0", + "--sleep-interval=0.1", + "--use-polling", + file, + ]) + .run_no_wait(); + + child.delay(500); + at.remove(file); + at.symlink_file(target, file); + child.delay(500); + + child + .kill() + .make_assertion() + .with_all_output() + .stderr_contains("tail: 'testfile' has been replaced with an untailable symbolic link") + .stdout_is(""); +} + // TODO: make this work on windows #[test] #[cfg(unix)] From 6904ee8c5f8cf87f686db93051c625760b0bff0f Mon Sep 17 00:00:00 2001 From: Quin Gillespie Date: Tue, 20 Jan 2026 14:28:36 -0700 Subject: [PATCH 2/2] Deduplicate code and added a comment. --- src/uu/tail/src/follow/files.rs | 6 ++---- src/uu/tail/src/follow/watch.rs | 16 ++++++---------- src/uu/tail/src/paths.rs | 5 +++++ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/uu/tail/src/follow/files.rs b/src/uu/tail/src/follow/files.rs index 8477a5b68a5..2cb7475538b 100644 --- a/src/uu/tail/src/follow/files.rs +++ b/src/uu/tail/src/follow/files.rs @@ -7,7 +7,7 @@ use crate::args::Settings; use crate::chunks::BytesChunkBuffer; -use crate::paths::{HeaderPrinter, PathExtTail}; +use crate::paths::{HeaderPrinter, PathExtTail, path_is_symlink}; use crate::text; use std::collections::HashMap; use std::collections::hash_map::Keys; @@ -212,9 +212,7 @@ impl PathData { // Probably file was renamed/moved or removed again None }; - let is_symlink = path - .symlink_metadata() - .is_ok_and(|meta| meta.file_type().is_symlink()); + let is_symlink = path_is_symlink(path); Self::new( reader, diff --git a/src/uu/tail/src/follow/watch.rs b/src/uu/tail/src/follow/watch.rs index 8f472d82f31..cc77af49145 100644 --- a/src/uu/tail/src/follow/watch.rs +++ b/src/uu/tail/src/follow/watch.rs @@ -7,7 +7,7 @@ use crate::args::{FollowMode, Settings}; use crate::follow::files::{FileHandling, PathData}; -use crate::paths::{Input, InputKind, MetadataExtTail, PathExtTail}; +use crate::paths::{Input, InputKind, MetadataExtTail, PathExtTail, path_is_symlink}; use crate::{platform, text}; use notify::{RecommendedWatcher, RecursiveMode, Watcher, WatcherKind}; use std::io::BufRead; @@ -151,9 +151,7 @@ impl Observer { path.to_owned() }; let metadata = path.metadata().ok(); - let is_symlink = path - .symlink_metadata() - .is_ok_and(|meta| meta.file_type().is_symlink()); + let is_symlink = path_is_symlink(&path); self.files.insert( &path, PathData::new(reader, metadata, display_name, is_symlink), @@ -307,13 +305,13 @@ impl Observer { EventKind::Modify(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() { - let new_is_symlink = event_path - .symlink_metadata() - .is_ok_and(|meta| meta.file_type().is_symlink()); + let new_is_symlink = path_is_symlink(event_path); let is_tailable = new_md.is_tailable(); let pd = self.files.get(event_path); if self.follow_name() && !pd.is_symlink && new_is_symlink { + // GNU tail treats a path that turns into a symlink as untailable when + // following by name, to avoid following the symlink target. if pd.reader.is_some() { self.files.reset_reader(event_path); } @@ -522,9 +520,7 @@ pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { if new_path.exists() { let pd = observer.files.get(new_path); let md = new_path.metadata().unwrap(); - let new_is_symlink = new_path - .symlink_metadata() - .is_ok_and(|meta| meta.file_type().is_symlink()); + let new_is_symlink = path_is_symlink(new_path); if !pd.is_symlink && new_is_symlink { show_error!( "{}", diff --git a/src/uu/tail/src/paths.rs b/src/uu/tail/src/paths.rs index 6eaeae9801b..5faa289c0aa 100644 --- a/src/uu/tail/src/paths.rs +++ b/src/uu/tail/src/paths.rs @@ -228,6 +228,11 @@ pub fn path_is_tailable(path: &Path) -> bool { path.is_file() || path.exists() && path.metadata().is_ok_and(|meta| meta.is_tailable()) } +pub fn path_is_symlink(path: &Path) -> bool { + path.symlink_metadata() + .is_ok_and(|meta| meta.file_type().is_symlink()) +} + #[inline] #[cfg(unix)] pub fn stdin_is_bad_fd() -> bool {