* tests/find/ls-format-file.sh: new test, verifying that "find
somefile -ls" has the expected output format.
* tests/find/ls-format-symlink.sh: new test for symbolic links.
* lib/listfile.c (print_file_owner): don't print a trailing space
after the owner's name.  Instead print it in the caller as a field
separator.
(print_file_group): Likewise.
(print_file_mode): Likewise.  This change ensures that there is still
a space after this field even if there is a POSIX "optional alternate
access method flag".
(list_file_internal): print spaces to separate the fields (mdoe, user,
group) which no longer include them.  Also print a final newline.
(print_name_with_quoting): actually print the characters of the name,
instead of spaces.
* tests/local.mk (sh_tests): add the new tests.
---
 lib/listfile.c                  |  49 +++++--
 tests/find/ls-format-file.sh    | 222 ++++++++++++++++++++++++++++++
 tests/find/ls-format-symlink.sh | 233 ++++++++++++++++++++++++++++++++
 tests/local.mk                  |   2 +
 4 files changed, 494 insertions(+), 12 deletions(-)
 create mode 100644 tests/find/ls-format-file.sh
 create mode 100644 tests/find/ls-format-symlink.sh

diff --git a/lib/listfile.c b/lib/listfile.c
index 550baf56..03f5d0a0 100644
--- a/lib/listfile.c
+++ b/lib/listfile.c
@@ -71,6 +71,7 @@ static int block_size_width = 6;
 static int nlink_width = 3;
 static int owner_width = 8;
 static int group_width = 8;
+static int symbolic_mode_width = 10;
 /* We don't print st_author even if the system has it. */
 static int major_device_number_width = 3;
 static int minor_device_number_width = 3;
@@ -208,11 +209,11 @@ print_file_owner (const struct stat *statp, FILE *stream)
       int len = mbswidth (user_name, 0);
       if (len > owner_width)
         owner_width = len;
-      chars_out = fprintf (stream, "%-*s ", owner_width, user_name);
+      chars_out = fprintf (stream, "%-*s", owner_width, user_name);
     }
   else
     {
-      chars_out = fprintf (stream, "%-8lu ", (unsigned long) statp->st_uid);
+      chars_out = fprintf (stream, "%-8lu", (unsigned long) statp->st_uid);
     }
   return update_width_if_success (chars_out, &owner_width);
 }
@@ -227,7 +228,7 @@ print_file_group (const struct stat *statp, FILE *stream)
       int len = mbswidth (group_name, 0);
       if (len > group_width)
         group_width = len;
-      chars_out = fprintf (stream, "%-*s ", group_width, group_name);
+      chars_out = fprintf (stream, "%-*s", group_width, group_name);
     }
   else
     {
@@ -237,10 +238,22 @@ print_file_group (const struct stat *statp, FILE *stream)
   return update_width_if_success (chars_out, &group_width);
 }
 
+static void
+delete_single_final_space_if_present (char *s)
+{
+  size_t len = strlen (s);
+  if (0 == len)
+    return;
+  if (s[len -1] == ' ')
+    s[len - 1] = 0;
+}
+
+
 static bool
 print_file_mode (const struct stat *statp, FILE *stream)
 {
   char modebuf[12];
+  modebuf[0] = 0;
 #if HAVE_ST_DM_MODE
   /* Cray DMF: look at the file's migrated, not real, status */
   strmode (statp->st_dm_mode, modebuf);
@@ -248,12 +261,17 @@ print_file_mode (const struct stat *statp, FILE *stream)
   strmode (statp->st_mode, modebuf);
 #endif
   /* modebuf normally includes a trailing space the space between the
-     mode and the number of links, as the POSIX "optional alternate
-     access method flag".  Alternatively though the last character may
-     be non-blank.  In that case we need to emit a space to separate
-     the mode and link count.
+   * mode and the number of links, as the POSIX "optional alternate
+   * access method flag".  Alternatively though the last character may
+   * be non-blank.  If it is blank we delete it (POSIX 2024 ls has an
+   * empty string rather than a single blank if there is no alternate
+   * access method).
    */
-  return fputs (modebuf, stream) >= 0;
+  delete_single_final_space_if_present (modebuf);
+  return update_width_if_success (fprintf (stream, "%-*s",
+                                          symbolic_mode_width,
+                                          modebuf),
+                                 &symbolic_mode_width);
 }
 
 static bool
@@ -428,6 +446,9 @@ list_file_internal (const char *name,
   if (!print_block_count (statp, output_block_size, stream))
     return false;
 
+  if (!print_single_space (stream))
+    return false;
+
   if (!print_file_mode (statp, stream))
     return false;
 
@@ -443,6 +464,9 @@ list_file_internal (const char *name,
   if (!print_file_owner (statp, stream))
     return false;
 
+  if (!print_single_space (stream))
+    return false;
+
   if (!print_file_group (statp, stream))
     return false;
 
@@ -469,7 +493,7 @@ list_file_internal (const char *name,
        stream))
     return false;
 
-  if (!print_single_space (stream))
+  if (EOF == putc ('\n', stream))
     return false;
 
   return true;
@@ -562,9 +586,10 @@ print_name_with_quoting (register const char *p, FILE 
*stream)
         default:
           if (c > 040 && c < 0177)
             {
-              if (!print_single_space (stream))
-                return false;
-              fprintf_result = 1;       /* otherwise it's used uninitialized. 
*/
+             if (EOF == putc (c, stream))
+                fprintf_result = -1;
+             else
+               fprintf_result = 1; /* otherwise it's used uninitialized. */
             }
           else
             {
diff --git a/tests/find/ls-format-file.sh b/tests/find/ls-format-file.sh
new file mode 100644
index 00000000..b5772536
--- /dev/null
+++ b/tests/find/ls-format-file.sh
@@ -0,0 +1,222 @@
+#!/bin/sh
+# Exercise 'find FILE -ls'
+
+# Copyright (C) 2026 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/tests/init.sh"; fu_path_prepend_
+print_ver_ find
+
+# Ensure that find does not generally skip warnings due to POSIX requirements.
+unset POSIXLY_CORRECT
+
+# We want to check the date in the output of -ls, so ensure we're
+# dealing with the C locale only.
+LC_ALL=C
+export LC_ALL
+unset LANG
+
+# Create a test file with some known properties.
+umask 077
+testfile='lsme'
+filesize=765
+rm -f -- "${testfile}"
+if ! ( yes non-empty | dd bs=1 count="${filesize}" of="${testfile}" ) 
2>/dev/null
+then
+    framework_failure_ "failed to create test file ${testfile}"
+fi
+# Any specific time for the test will do as long as it is far enough
+# away from now for ls to display the year.  This happens to be the
+# date of a Swans concert I was at; they played at the Leadmill in
+# Sheffield.
+if ! touch -t 199203141900.01 "${testfile}"
+then
+    framework_failure_ "failed to set mtime of ${testfile}"
+fi
+
+# Check the actual properties of the file (to verify that the setup
+# was successful).
+check_prop() {
+    printf_fmt="${1}"
+    expected="${2}"
+    got="$( find "${testfile}" -printf "${printf_fmt}" )"
+
+    if [ "${expected}" != "${got}" ]
+    then
+       framework_failure_ "expected test file ${testfile} to have -printf 
'${printf_fmt}\n' result '${expected}' but we got '${got}'"
+    fi
+}
+
+check_prop '%M' "-rw-------"  # file mode
+check_prop '%n' 1            # link count
+check_prop '%s' "${filesize}" # size in bytes
+check_prop '%TF' "1992-03-14" # modification date
+check_prop '%TH:%TM' "19:00"  # modification time (without seconds)
+
+# Remember the file's inode number and block count.
+inum="$( find "${testfile}" -printf '%i\n')"
+# The system's value of BLOCKSIZE is implementation-dependent
+# ("ls -k" forces ls to use 1KiB blocks, but -ls is intended
+# to look like the output of ls -dils, without -k)
+
+
+# Generate the ls output.
+find "${testfile}" -ls > output.txt
+
+# Show the reader of the test log what the output was.
+echo "output of find ${testfile} -ls:"
+cat output.txt
+echo "same thing in od -c format:"
+od -c output.txt
+
+# Determine whether there is a trailing newline.  If not, output.txt
+# is not actually a text file and some other checks may not work the
+# way we expect.
+if [ $(sed -n -e '$ s/.*//p'  < output.txt | wc -c) -eq 0 ]
+then
+    fail_ "find -ls output does not end in newline"
+fi
+
+grep -e ' lsme$' < output.txt >/dev/null || fail_ "find -ls output should end 
with the file name"
+
+# The output is supposed to look like this:
+
+# st_ino  st_blocks
+# |       | symbolic st_mode
+# |       | |          st_nlink
+# |       | |          |  Username corresponding to st_uid
+# |       | |          |  |    Group corresponding to st_gid
+# |       | |          |  |    |     Size in bytes
+# |       | |          |  |    |     |   st_mtime
+# |       | |          |  |    |     |   |            file name
+# |       | |          |  |    |     |   |            |
+# 4485432 1 -rw------- 1 james james 765 Mar 14  1992 lsme
+
+awk < output.txt  \
+    -v ME="${ME_}" \
+    -v filename_expected="${testfile}" \
+    -v inodenum="${inum}" \
+    -v filesize="${filesize}" \
+    -v uowner="$(id -un)" \
+    -v gowner="$(id -gn)" '
+BEGIN {
+      rv=2
+}
+{
+  for (i=1; i<=NF; ++i) {
+    printf("%s: info: record %d field %2d contains %s\n", ME, NR, i, $i);
+  }
+}
+NR == 2 {
+   rv=1
+   printf("%s: failed test: Too many lines of output\n", ME);
+}
+NR == 1 {
+  rv=0
+
+  st_ino=$1;
+  st_blocks=$2;
+  symbolic_st_mode="" $3;
+  st_nlink=$4;
+  st_uid=$5;
+  st_gid=$6;
+  st_size=$7;
+  st_mtime_monthname=$8;
+  st_mtime_monthday=$9;
+  st_mtime_year=$10;
+  filename_got=$11;
+
+  printf("%s: info: checking output of -ls (which was [%s])\n", ME, $0);
+
+  # field 1: inode number
+  if (st_ino != inodenum) {
+    rv=1
+    printf("%s: failed test: expected inode number %d, got %s\n", ME, 
inodenum, $1);
+  }
+
+  # field 2: size in blocks is ignored as we probably cannot assume
+  # any particular value for BLOCKSIZE.
+
+  # field 3 is the symbolic mode; we allow a final +
+  # in case there is somehow a non-default ACL on the file.
+  if (symbolic_st_mode != "-rw-------" && symbolic_st_mode != "-rw-------+") {
+    rv=1
+    printf("%s: failed test: expected symbolic mode [-rw-------], got [%s]\n", 
ME, symbolic_st_mode);
+  }
+  # field 4 is the link count
+  if (st_nlink != 1) {
+    rv=1
+    printf("%s: failed test: expected link count 1, got %s\n", ME, st_nlink);
+  } else {
+    printf("%s: link count %s looks OK\n", ME, st_nlink);
+  }
+  # field 5 is the owner
+  if (st_uid != uowner) {
+    rv=1
+    printf("%s: failed test: expected owner [%s], got [%s]\n", ME, uowner, 
st_uid);
+  } else {
+    printf("%s: owner %s looks OK\n", ME, st_uid);
+  }
+  # field 6 is the group owner
+  if (st_gid != uowner) {
+    rv=1
+    printf("%s: failed test: expected group owner [%s], got [%s]\n", ME, 
uowner, st_gid);
+  } else {
+    printf("%s: group owner %s looks OK\n", ME, st_gid);
+  }
+  # field 7 is the size in bytes
+  if (st_size != filesize) {
+    rv=1
+    printf("%s: failed test: expected file size %d, got %s\n", ME, filesize, 
st_size);
+  } else {
+    printf("%s: file size %s looks OK\n", ME, st_size);
+  }
+  # field 8 is the month of the mtime
+  if (st_mtime_monthname != "Mar") {
+    rv=1
+    printf("%s: failed test: expected mtime month Mar, got %s\n", ME, 
st_mtime_monthname);
+  } else {
+    printf("%s: mtime month %s looks OK\n", ME, st_mtime_monthname);
+  }
+  # field 9 is the month-day of the mtime
+  if (st_mtime_monthday != 14) {
+    rv=1
+    printf("%s: failed test: expected mtime month-day 14, got %s\n", ME, 
st_mtime_monthday);
+  } else {
+    printf("%s: mtime month-day %s looks OK\n", ME, st_mtime_monthday);
+  }
+  # field 10 is the year of the mtime
+  if (st_mtime_year != 1992) {
+    rv=1
+    printf("%s: failed test: expected mtime year 1992, got %s\n", ME, 
st_mtime_year);
+  } else {
+    printf("%s: mtime year %s looks OK\n", ME, st_mtime_year);
+  }
+  # field 11 is the file name
+  if (filename_got != filename_expected) {
+    rv=1
+    printf("%s: failed test: expected filename %s, got %s\n", ME, filename, 
$11);
+  } else {
+    printf("%s: file name %s looks OK\n", ME, $11);
+  }
+}
+END {
+    printf("%s: Finished AWK tests: exiting with status %d\n", ME, rv);
+    exit rv;
+}
+'
+rv=$?
+echo "Awk exit status was ${rv}"
+Exit "${rv}"
diff --git a/tests/find/ls-format-symlink.sh b/tests/find/ls-format-symlink.sh
new file mode 100644
index 00000000..6bf17599
--- /dev/null
+++ b/tests/find/ls-format-symlink.sh
@@ -0,0 +1,233 @@
+#!/bin/sh
+# Exercise 'find SYMLINK -ls'
+
+# Copyright (C) 2026 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/tests/init.sh"; fu_path_prepend_
+print_ver_ find
+
+# Ensure that find does not generally skip warnings due to POSIX requirements.
+unset POSIXLY_CORRECT
+
+# We want to check the date in the output of -ls, so ensure we're
+# dealing with the C locale only.
+LC_ALL=C
+export LC_ALL
+unset LANG
+
+# Create a test file with some known properties.
+umask 077
+symlink_target='target'
+symlink='symlink'
+filesize=765
+rm -f -- "${symlink_target}" "${symlink}"
+if ! ( yes non-empty | dd bs=1 count="${filesize}" of="${symlink_target}" ) 
2>/dev/null
+then
+    framework_failure_ "failed to create test file ${symlink_target}"
+fi
+ln -s "${symlink_target}" "${symlink}"
+
+# Any specific time for the test will do as long as it is far enough
+# away from now for ls to display the year.  This happens to be the
+# date of a Swans concert I was at; they played at the Leadmill in
+# Sheffield.
+if ! touch -t 199203141900.01 "${symlink_target}"
+then
+    framework_failure_ "failed to set mtime of ${symlink_target}"
+fi
+if ! touch -h -t 199912312359.59 "${symlink}"
+then
+    framework_failure_ "failed to set mtime of ${symlink}"
+fi
+
+# Check the actual properties of the file (to verify that the setup
+# was successful).
+check_prop() {
+    printf_fmt="${1}"
+    expected="${2}"
+    got="$( find "${symlink}" -printf "${printf_fmt}" )"
+
+    if [ "${expected}" != "${got}" ]
+    then
+       framework_failure_ "expected test file ${symlink} to have -printf 
'${printf_fmt}\n' result '${expected}' but we got '${got}'"
+    fi
+}
+
+check_prop '%M' "lrwxrwxrwx"  # file mode
+check_prop '%n' 1            # link count
+check_prop '%s' 6             # size in bytes (length of "target")
+check_prop '%TF' "1999-12-31" # modification date
+check_prop '%TH:%TM' "23:59"  # modification time (without seconds)
+
+# Remember the file's inode number and block count.
+inum="$( find "${symlink}" -printf '%i\n')"
+# The system's value of BLOCKSIZE is implementation-dependent
+# ("ls -k" forces ls to use 1KiB blocks, but -ls is intended
+# to look like the output of ls -dils, without -k)
+
+
+# Generate the ls output.
+find "${symlink}" -ls > output.txt
+
+# Show the reader of the test log what the output was.
+echo "output of find ${symlink} -ls:"
+cat output.txt
+echo "same thing in od -c format:"
+od -c output.txt
+
+# Determine whether there is a trailing newline.  If not, output.txt
+# is not actually a text file and some other checks may not work the
+# way we expect.
+if [ $(sed -n -e '$ s/.*//p'  < output.txt | wc -c) -eq 0 ]
+then
+    fail_ "find -ls output does not end in newline"
+fi
+
+grep -e ' symlink -> target$' < output.txt >/dev/null || fail_ "find -ls 
output should end with symlink -> target"
+
+# The output is supposed to look like this:
+
+# st_ino       st_blocks
+# |            | symbolic st_mode
+# |            | |            st_nlink
+# |            | |            | Username corresponding to st_uid
+# |            | |            | |        Group corresponding to st_gid
+# |            | |            | |        |               Size in bytes
+# |            | |            | |        |               | st_mtime
+# |            | |            | |        |               | |            
symbolic link name
+# |            | |            | |        |               | |            |      
    symlink target
+# |            | |            | |        |               | |            |      
    |
+# 5122383      1 lrwxrwxrwx   1 james    james           6 Mar 14  1992 
symlink -> target
+
+awk < output.txt  \
+    -v ME="${ME_}" \
+    -v symlink_name_expected="${symlink}" \
+    -v target_name_expected="${symlink_target}" \
+    -v inodenum="${inum}" \
+    -v filesize=6 '
+BEGIN {
+      rv=2
+}
+{
+  for (i=1; i<=NF; ++i) {
+    printf("%s: info: record %d field %2d contains %s\n", ME, NR, i, $i);
+  }
+}
+NR == 2 {
+   rv=1
+   printf("%s: failed test: Too many lines of output\n", ME);
+}
+NR == 1 {
+  rv=0
+
+  st_ino=$1;
+  st_blocks=$2;
+  symbolic_st_mode="" $3;
+  st_nlink=$4;
+  st_uid=$5;
+  st_gid=$6;
+  st_size=$7;
+  st_mtime_monthname=$8;
+  st_mtime_monthday=$9;
+  st_mtime_year=$10;
+  symlink_got=$11;
+  arrow_got=$12;
+  target_got=$13;
+
+  printf("%s: info: checking output of -ls (which was [%s])\n", ME, $0);
+
+  # field 1: inode number
+  if (st_ino != inodenum) {
+    rv=1
+    printf("%s: failed test: expected inode number %d, got %s\n", ME, 
inodenum, $1);
+  }
+
+  # field 2: size in blocks is ignored as we probably cannot assume
+  # any particular value for BLOCKSIZE.
+
+  # field 3 is the symbolic mode; we allow a final +
+  # in case there is somehow a non-default ACL on the file.
+  if (symbolic_st_mode != "lrwxrwxrwx" && symbolic_st_mode != "lrwxrwxrwx+") {
+    rv=1
+    printf("%s: failed test: expected symbolic mode [lrwxrwxrwx], got [%s]\n", 
ME, symbolic_st_mode);
+  }
+  # field 4 is the link count
+  if (st_nlink != 1) {
+    rv=1
+    printf("%s: failed test: expected link count 1, got %s\n", ME, st_nlink);
+  } else {
+    printf("%s: link count %s looks OK\n", ME, st_nlink);
+  }
+
+  # field 5 is the owner
+  # field 6 is the group owner
+
+  # field 7 is the size in bytes
+  if (st_size != filesize) {
+    rv=1
+    printf("%s: failed test: expected file size %d, got %s\n", ME, filesize, 
st_size);
+  } else {
+    printf("%s: file size %s looks OK\n", ME, st_size);
+  }
+  # field 8 is the month of the mtime
+  if (st_mtime_monthname != "Dec") {
+    rv=1
+    printf("%s: failed test: expected mtime month Dec, got %s\n", ME, 
st_mtime_monthname);
+  } else {
+    printf("%s: mtime month %s looks OK\n", ME, st_mtime_monthname);
+  }
+  # field 9 is the month-day of the mtime
+  if (st_mtime_monthday != 31) {
+    rv=1
+    printf("%s: failed test: expected mtime month-day 31, got %s\n", ME, 
st_mtime_monthday);
+  } else {
+    printf("%s: mtime month-day %s looks OK\n", ME, st_mtime_monthday);
+  }
+  # field 10 is the year of the mtime
+  if (st_mtime_year != 1999) {
+    rv=1
+    printf("%s: failed test: expected mtime year 1999, got %s\n", ME, 
st_mtime_year);
+  } else {
+    printf("%s: mtime year %s looks OK\n", ME, st_mtime_year);
+  }
+  # field 11 is the file name
+  if (symlink_got != symlink_name_expected) {
+    rv=1
+    printf("%s: failed test: expected filename %s, got %s\n", ME, filename, 
$11);
+  } else {
+    printf("%s: file name %s looks OK\n", ME, $11);
+  }
+  # field 12 is "->"
+  if ($12 != "->") {
+    rv=1
+    printf("%s: failed test: expected ->, got %s\n", ME, $11);
+  }
+  # field 13 is the symbolic link target
+  if (target_got != target_name_expected) {
+    rv=1
+    printf("%s: failed test: expected symlink target %s, got %s\n", ME, 
target_name_expected, target_got);
+  } else {
+    printf("%s: file name %s looks OK\n", ME, $11);
+  }
+}
+END {
+    printf("%s: Finished AWK tests: exiting with status %d\n", ME, rv);
+    exit rv;
+}
+'
+rv=$?
+echo "Awk exit status was ${rv}"
+Exit "${rv}"
diff --git a/tests/local.mk b/tests/local.mk
index bb575c1b..98e5b15d 100644
--- a/tests/local.mk
+++ b/tests/local.mk
@@ -120,6 +120,8 @@ sh_tests = \
   tests/find/inode-zero.sh \
   tests/find/many-dir-entries-vs-OOM.sh \
   tests/find/name-lbracket-literal.sh \
+  tests/find/ls-format-file.sh \
+  tests/find/ls-format-symlink.sh \
   tests/find/name-slash.sh \
   tests/find/printf_escapechars.sh \
   tests/find/printf_escape_c.sh \
-- 
2.47.3


Reply via email to