From: Tyler Anderson Subject: [PATCH] ls: optional ANSI SGR output To: tech@openbsd.org Date: Fri, 14 Aug 2026 11:44:22 -0700 Greetings @tech, I've been carrying a small local mod to ls, adding optional ANSI SGR colouring, and I'd like to put it up for discussion. I'm not suggesting that this is something that necessarily belongs in base, and given the relatively simple delta, I find it likely that leaving color out was a, perfectly reasonable, choice. That said I find it useful for interactive use, and I'm interested in whether the added functionality is considered worthwhile when balanced against maintenance/security surface it introduces etc. The implementation is deliberately limited in scope: -e enables SGR output when stdout is a tty; -E forces it regardless of the output destination. Colour definitions are supplied through LS_SGR. Classification is based only on information already available from the existing FTS/stat traversal; no filename parsing or additional filesystem lookups are performed for colour classification. In addition to the usual file-type distinctions, the implementation can distinguish executable files, SUID/SGID files and directories, sticky directories, and directories crossing a filesystem boundary. Colour remains entirely opt-in, and the existing FTS_NOSTAT optimization is retained when colour isn't requested. I intentionally did not try to reproduce the more elaborate behaviour of GNU ls or colorls; in particular, there is no filename/extension parsing. I've been using this version locally for some time, but this is my first patch submission to OpenBSD, so I'm particularly interested in feedback on both the implementation and whether this is something that would be appropriate for base at all. Diff follows: Index: ls.1 =================================================================== RCS file: /cvs/src/bin/ls/ls.1,v diff -u -p -u -p -r1.80 ls.1 --- ls.1 18 Jan 2026 14:05:29 -0000 1.80 +++ ls.1 14 Aug 2026 20:22:27 -0000 @@ -41,7 +41,7 @@ .Nd list directory contents .Sh SYNOPSIS .Nm ls -.Op Fl 1AaCcdFfgHhikLlmnopqRrSsTtux +.Op Fl 1AaCcdEeFfgHhikLlmnopqRrSsTtux .Op Ar .Sh DESCRIPTION For each operand that names a @@ -103,6 +103,16 @@ or .It Fl d Directories are listed as plain files (not searched recursively) and symbolic links in the argument list are not indirected through. +.It Fl E +As with +.Fl e +but emit ANSI SGR sequences regardless of output. +.It Fl e +Emit ANSI SGR (ECMA-48) escape sequences to indicate basic file types and +properties only if the output is a terminal. SGR parameters must be +configured by setting the +.Ev LS_SGR +environment variable, see ENVIRONMENT below for more information. .It Fl F Display a slash .Pq Sq / @@ -449,6 +459,86 @@ If unset or set to .Qq C , .Qq POSIX , or an unsupported value, non-ASCII bytes are replaced by question marks. +.It Ev LS_SGR +If set to a valid ASCII string, and +.Fl E +or +.Fl e +are specified, filenames corresponding to defined types will be +printed between the configured SGR, and reset SGR sequences +(in the case of +.Fl e , +subject to the output being a tty). +SGR sequences emitted, are done so as configured, without +checking whether an output terminal supports them. +.Pp +A valid +.Ev LS_SGR +string must contain one or more definitions separated by colons +.Pq Ql \:: . +Definitions consist +of the type, +.Ns eg. +.Ql fb +followed immediately by +.Ql = , +and one or more ANSI SGR parameters separated by semicolons +.Ns eg. +.Ql 1;34;4 . +.Pp +Supported types are: +.Ql bl , +.Ql ch , +.Ql dg , +.Ql di , +.Ql du , +.Ql ex , +.Ql fb , +.Ql fg , +.Ql fi , +.Ql fu , +.Ql ln , +.Ql pi , +.Ql so , +and +.Ql st +and correspond to block-special, character-special, directory-setgid, +directory, directory-setuid, executable, filesystem-boundary-directory, +file-setgid, regular-file, file-setuid, symbolic-link, pipe/FIFO, socket, +and sticky-bit respectively; in accordance with +.Xr stat 2 . +.Pp +Additionally the SGR reset sequence may be set using the +.Ql rs +type. +.Pp +Each definition's SGR parameter string must be less than 28 characters, +the maximum length of all definitions combined must be less +than 512, later definitions override earlier definitions, whitespace +(outside of the +.Aq type +string) +is ignored, and any invalid or non-ascii characters in a given definition +will cause that definition to be dropped. +.Pp +An example +.Ev LS_SGR +value: +.So +bl=1;93 +:ch=1;93 +:db=1;38;2;180;140;255 +:dg=1;94;4 +:di=1;94 +:du=1;94;4 +:ex=1;92 +:fg=1;92;4 +:fi=null +:fu=1;92;4 +:ln=1;96 +:pi=1;93;40 +:so=1;95 +.Sc . .It Ev TZ The time zone to use when displaying dates. See Index: ls.c =================================================================== RCS file: /cvs/src/bin/ls/ls.c,v diff -u -p -u -p -r1.57 ls.c --- ls.c 28 Jan 2026 20:31:49 -0000 1.57 +++ ls.c 14 Aug 2026 20:22:27 -0000 @@ -71,6 +71,7 @@ int sortkey = BY_NAME; /* flags */ int f_accesstime; /* use time of last access */ +int f_color; /* use ANSI SGR escape sequences */ int f_column; /* columnated format */ int f_columnacross; /* columnated format, sorted across */ int f_flags; /* show flags associated with a file */ @@ -132,7 +133,7 @@ ls_main(int argc, char *argv[]) f_listdot = 1; fts_options = FTS_PHYSICAL; - while ((ch = getopt(argc, argv, "1ACFHLRSTacdfghiklmnopqrstux")) != -1) { + while ((ch = getopt(argc, argv, "1ACEeFHLRSTacdfghiklmnopqrstux")) != -1) { switch (ch) { /* * The -1, -C and -l, -m, -n and -x options all override each @@ -148,6 +149,13 @@ ls_main(int argc, char *argv[]) f_columnacross = f_longform = f_numericonly = 0; f_singlecol = f_stream = 0; break; + case 'E': + f_color = 1; + break; + case 'e': + if (isatty(STDOUT_FILENO)) + f_color = 1; + break; case 'g': f_longform = 1; if (f_grouponly != -1) @@ -259,11 +267,11 @@ ls_main(int argc, char *argv[]) f_grouponly = 0; /* - * If not -F, -i, -l, -p, -S, -s or -t options, don't require stat + * If not -e -F, -i, -l, -p, -S, -s or -t options, don't require stat * information. */ if (!f_longform && !f_inode && !f_size && !f_type && !f_typedir && - sortkey == BY_NAME) + !f_color && sortkey == BY_NAME) fts_options |= FTS_NOSTAT; /* @@ -369,7 +377,8 @@ traverse(int argc, char *argv[], int opt * If not recursing down this tree and don't need stat info, just get * the names. */ - ch_options = !f_recursive && options & FTS_NOSTAT ? FTS_NAMEONLY : 0; + ch_options = !f_recursive && !f_color && options & FTS_NOSTAT ? + FTS_NAMEONLY : 0; while ((p = fts_read(ftsp)) != NULL) switch (p->fts_info) { @@ -444,7 +453,7 @@ display(FTSENT *p, FTSENT *list) char nuser[12], ngroup[12]; char *flags = NULL; - needstats = f_inode || f_longform || f_size; + needstats = f_inode || f_longform || f_size || f_color; flen = 0; btotal = maxblock = maxinode = maxlen = maxnlink = 0; bcfile = 0; Index: ls.h =================================================================== RCS file: /cvs/src/bin/ls/ls.h,v diff -u -p -u -p -r1.10 ls.h --- ls.h 7 Oct 2023 11:51:08 -0000 1.10 +++ ls.h 14 Aug 2026 20:22:27 -0000 @@ -40,6 +40,7 @@ extern long blocksize; /* block size units */ extern int f_accesstime; /* use time of last access */ +extern int f_color; /* use ANSI SGR escape sequences */ extern int f_flags; /* show flags associated with a file */ extern int f_grouponly; /* long listing format without owner */ extern int f_humanval; /* show human-readable file sizes */ Index: print.c =================================================================== RCS file: /cvs/src/bin/ls/print.c,v diff -u -p -u -p -r1.41 print.c --- print.c 27 Mar 2024 14:44:52 -0000 1.41 +++ print.c 14 Aug 2026 20:22:27 -0000 @@ -36,6 +36,7 @@ #include #include +#include #include #include #include @@ -52,6 +53,7 @@ #include "ls.h" #include "extern.h" +static void color_set(FTSENT *); static int printaname(FTSENT *, int, int); static void printlink(FTSENT *); static void printsize(int, off_t); @@ -61,11 +63,25 @@ static int compute_columns(DISPLAY *, in #define IS_NOPRINT(p) ((p)->fts_number == NO_PRINT) +#define COLORBUFLEN 32 +#define COLOR_TYPES "bl=","ch=","db=","dg=","di=",\ + "du=","ex=","fg=","fi=","fu=",\ + "ln=","pi=","rs=","so=","st=" +#define C_SGRBUFLEN 28 +#define COLOR_DEFINED(i) (*color_get(i) != '\0') + #define DATELEN 64 #define SECSPERDAY (24 * 60 * 60) #define SIXMONTHS (SECSPERDAY * 365 / 2) +enum color_types_e { + CT_BLSP, CT_CHSP, CT_DBND, CT_DGID, CT_DIRE, + CT_DUID, CT_EXEC, CT_FGID, CT_FILE, CT_FUID, + CT_LINK, CT_PIPE, CT_RSET, CT_SOCK, CT_STKY, + CT_NTYPES +}; + void printscol(DISPLAY *dp) { @@ -121,7 +137,11 @@ printlong(DISPLAY *dp) printtime(sp->st_ctime); else printtime(sp->st_mtime); + if (f_color) + color_set(p); (void)mbsprint(p->fts_name, 1); + if (f_color) + color_set(NULL); if (f_type || (f_typedir && S_ISDIR(sp->st_mode))) (void)printtype(sp->st_mode); if (S_ISLNK(sp->st_mode)) @@ -231,7 +251,11 @@ printaname(FTSENT *p, int inodefield, in if (f_size) chcnt += printf("%*lld ", sizefield, howmany((long long)sp->st_blocks, blocksize)); + if (f_color) + color_set(p); chcnt += mbsprint(p->fts_name, 1); + if (f_color) + color_set(NULL); if (f_type || (f_typedir && S_ISDIR(sp->st_mode))) chcnt += printtype(sp->st_mode); return (chcnt); @@ -382,4 +406,152 @@ printsize(int width, off_t bytes) return; } (void)printf("%*lld ", width, (long long)bytes); +} + +static int +color_sgr(char *tok_p, char *sgrbuf) { + int n; + char c; + + memset(sgrbuf, '\0', C_SGRBUFLEN); + + /* SGR 27 character limit */ + if (strlen(tok_p) > C_SGRBUFLEN-1) + return 0; + + /* SGR parameters consist of digits and semicolons only. */ + for (n = 0; n < C_SGRBUFLEN-1 && (c = *tok_p++);) { + if (isspace(c)) + continue; + if (!(c >= '0' && c <= '9') && c != ';') + return 0; + + sgrbuf[n++] = c; + } + return n; +} + +/* + * Load and cache any color definitions, then return a pointer to the + * cache-offset corresponding to color_idx. + */ +static const char * +color_get(int color_idx) +{ + static int cg_init = 0; + static char cache[CT_NTYPES*COLORBUFLEN] = {0}; + static const char *colortypes[CT_NTYPES] = {COLOR_TYPES}; + char *lsenv, *tok_p, *envbuf_p, envbuf[512], sgrbuf[C_SGRBUFLEN]; + + /* all subsequent calls after init. */ + if (cg_init) + return (char *)cache+(color_idx*COLORBUFLEN); + + /* first call, init. */ + cg_init = 1; + memset(cache, '\0', sizeof(cache)); + lsenv = getenv("LS_SGR"); + if (!lsenv) + return ""; + + /* set env character limit (511), and ptr to mutable. */ + if (strlcpy(envbuf, lsenv, sizeof(envbuf)) >= sizeof(envbuf)) + return ""; + envbuf_p = envbuf; + + while ((tok_p = strsep(&envbuf_p, ":")) != NULL) { + for (int i = 0; i < CT_NTYPES; i++) { + size_t typelen = strlen(colortypes[i]); + + /* skip leading whitespace */ + for (char c = *tok_p; isspace(c); c = *(++tok_p)); + + if (strncmp(tok_p, colortypes[i], typelen) != 0) + continue; + + tok_p += typelen; + if (color_sgr(tok_p, sgrbuf)) { + (void)snprintf(cache + (i * COLORBUFLEN), + COLORBUFLEN, "\033[%sm", sgrbuf); + } + break; + } + } + return (char *)cache+(color_idx*COLORBUFLEN); +} + +static void +color_set(FTSENT *p) +{ + static const char *curr_color = ""; + mode_t mode; + + if (p == NULL) { + if (*curr_color) { + curr_color = ""; + if (*(color_get(CT_RSET))) + printf("%s", color_get(CT_RSET)); + else + printf("\033[0m"); + } + return; + } + + mode = p->fts_statp->st_mode; + curr_color = ""; + + if (S_ISLNK(mode)) + curr_color = color_get(CT_LINK); + + /* Directory filesystem boundry - inode's device differs parent */ + else if (S_ISDIR(mode) + && p->fts_level > FTS_ROOTLEVEL + && p->fts_parent->fts_statp->st_dev != p->fts_statp->st_dev + && COLOR_DEFINED(CT_DBND)) + curr_color = color_get(CT_DBND); + + /* Directory suid/sgid */ + else if (S_ISDIR(mode) + && mode & S_ISUID + && COLOR_DEFINED(CT_DUID)) + curr_color = color_get(CT_DUID); + else if (S_ISDIR(mode) + && mode & S_ISGID + && COLOR_DEFINED(CT_DGID)) + curr_color = color_get(CT_DGID); + + /* The sticky bit is only applicable to directories. */ + else if (S_ISDIR(mode) + && mode & S_ISVTX + && COLOR_DEFINED(CT_STKY)) + curr_color = color_get(CT_STKY); + + /* Regular file suid/sgid */ + else if (S_ISREG(mode) + && mode & S_ISUID + && *(color_get(CT_FUID))) + curr_color = color_get(CT_FUID); + else if (S_ISREG(mode) + && mode & S_ISGID + && *(color_get(CT_FGID))) + curr_color = color_get(CT_FGID); + + /* Base types & properties, apply color str. regardless. */ + else if (S_ISDIR(mode)) + curr_color = color_get(CT_DIRE); + else if (S_ISFIFO(mode)) + curr_color = color_get(CT_PIPE); + else if (S_ISBLK(mode)) + curr_color = color_get(CT_BLSP); + else if (S_ISCHR(mode)) + curr_color = color_get(CT_CHSP); + else if (S_ISSOCK(mode)) + curr_color = color_get(CT_SOCK); + else if (S_ISREG(mode) + && (mode & (S_IXUSR | S_IXGRP | S_IXOTH))) + curr_color = color_get(CT_EXEC); + else if (S_ISREG(mode)) + curr_color = color_get(CT_FILE); + if (*curr_color) + printf("%s", curr_color); } Index: util.c =================================================================== RCS file: /cvs/src/bin/ls/util.c,v diff -u -p -u -p -r1.17 util.c --- util.c 1 Dec 2015 18:36:13 -0000 1.17 +++ util.c 14 Aug 2026 20:22:27 -0000 @@ -64,7 +64,7 @@ void usage(void) { (void)fprintf(stderr, - "usage: %s [-1AaCcdFfgHhikLlmnopqRrSsTtux] [file ...]\n", + "usage: %s [-1AaCcdEeFfgHhikLlmnopqRrSsTtux] [file ...]\n", __progname); exit(1); } --- One disclosure for completeness: I wrote the implementation myself, AI was not used in any way to generate the submitted code. I mention it because of the project's concerns around provenance and copyright of AI-generated code. Thanks, -- Tyler Anderson | Canada/Pacific