8

ps -o command shows each command on a separate line, with space separated, unquoted arguments:

$ ps -o command
COMMAND
bash
ps -o command

This can be a problem when checking whether the quoting was correct or to copy and paste a command to run it again. For example:

$ xss-lock --notifier="notify-send -- 'foo bar'" slock &
[1] 20172
$ ps -o command | grep [x]ss-lock
xss-lock --notifier=notify-send -- 'foo bar' slock

The output of ps is misleading - if you try to copy and paste it, the command will not do the same thing as the original. So is there a way, similar to Bash's printf %q, to print a list of running commands with correctly escaped or quoted arguments?

l0b0
  • 51,350

4 Answers4

5

On Linux, you can get a slightly more raw list of args to a command from /proc/$pid/cmdline for a given process id. The args are separated by the nul char. Try cat -v /proc/$pid/cmdline to see the nuls as ^@, in your case: xss-lock^@--notifier=notify-send -- 'foo bar'^@slock^@.

The following perl script can read the proc file and replace the nuls by a newline and tab giving for your example:

xss-lock
    --notifier=notify-send -- 'foo bar'
    slock

Alternatively, you can get a requoted command like this:

xss-lock '--notifier=notify-send -- '\''foo bar'\''' 'slock' 

if you replace the if(1) by if(0):

perl -e '
  $_ = <STDIN>;
  if(1){
      s/\000/\n\t/g;  
      s/\t$//; # remove last added tab
  }else{
      s/'\''/'\''\\'\'\''/g;  # escape all single quotes
      s/\000/ '\''/;          # do first nul
      s/(.*)\000/\1'\''/;     # do last nul
      s/\000/'"' '"'/g;       # all other nuls
  }
  print "$_\n";
' </proc/$pid/cmdline 
meuh
  • 51,383
  • +1 works, but can't be used without further munging to run the command again. – l0b0 Nov 15 '15 at 16:27
  • @l0b0 It's the best you can get. At least on Linux it's possible, there are Unix variants that only store the command line in an ambiguous, truncated form. – Gilles 'SO- stop being evil' Nov 15 '15 at 19:42
  • this actually breaks if the process named is called from a path with apostrophes. the command name needs quoting, too. – mikeserv Nov 15 '15 at 20:47
  • Try perl -ne 'print join(" ", map quotemeta, split(/\000/)), "\n"' < /proc/.../cmdline for a quick-and-dirty re-runnable version, with excess backslashes. – oals Nov 15 '15 at 21:04
  • 1
    Or slightly shorter, perl -nE '$, = " "; say map quotemeta, split /\0/' /proc/.../cmdline – oals Nov 15 '15 at 21:11
  • @oals nice. I had thought quotemeta would provide an unreadable result, but in fact it's not too bad, you just have to notice where the \<space> occur. – meuh Nov 15 '15 at 21:20
  • @oals Nice! Why don't you post it as another solution? – l0b0 Nov 15 '15 at 21:31
2

As meuh noted, you can get the string on Linux and NetBSD from /proc/PID/cmdline with arguments delimited by NUL bytes. Here is a quick and dirty way to transform them into runnable command-lines.

perl -ne 'print join(" ", map quotemeta, split(/\000/)), "\n"' /proc/.../cmdline

The output looks like:

xss\-lock \-\-notifier\=notify\-send\ \-\-\ \'foo\ bar\' slock

You can directly copypaste it to your shell to run it.

Shorter variant (requires Perl 5.10 or newer):

perl -nE '$, = " "; say map quotemeta, split /\0/' /proc/.../cmdline

And while I'm at it, a golfed version (40 bytes):

perl -nE'$,=" ";say map"\Q$_",split/\0/' /proc/.../cmdline
oals
  • 371
2

As with the other answers, /proc/${PID}/cmdline is the key, and this is available on Linux, FreeBSD, and NetBSD.

As an alternative to perl and some quite complex sed, observe that xargs with the common but not standard -0 can happily turn -terminated strings into command arguments, and (as the question says) printf with the common but not standard %q format specifier can happily turn command arguments into shell-quoted strings.

for i in /proc/[0-9]*/
do
    basename "$i"
    < $i/cmdline xargs -0 printf '\t%q'
    printf '\n'
done

The only tricky part is systems like FreeBSD where the system external printf, and the printf built in to sh, do not have %q. This is less widespread than -0 to xargs is. So one has to resort to using someone else's printf.

for i in /proc/[0-9]*/
do
    basename "$i"
    < $i/cmdline xargs -0 zsh -c 'printf "\t%q" "$0" "$@"'
    printf '\n'
done

Observe that there's no guarantee that these are the command name and arguments that the program was started with. Programs can change what is seen here. You will find that a lot of them do, and they use an inferior setproctitle() function that mangles the argument string. There is nothing that you can do about this. The individual programs themselves have constructed a faulty argument string that puts everything into a single argument.

Further reading

JdeBP
  • 68,745
  • /proc is not mounted by default on FreeBSD as of 12.1 (2020). there is also no zsh by default. –  Feb 12 '20 at 00:16
1

first i should say that i assume a standard pc linux distribution for all that follows. for example, i am using a more-or-less default arch linux installation while testing.


ps -ocommand | grep \[x]ss-lock

...will first print series of running process command lines and then filter that list to only those that match the regex \[x]ss-lock. the \[x]... used in the regex is a fairly common work-around for handling the results race involved with listing a bunch of processes named xss-lock by filtering another list with a command containing the words xss-lock.

that's not the best way to do it, though. a linux system's ps is typically a procps-ng ps which supports the -Command name filter, which, as I have been told, likely originated with AIX's ps.

in any case, you can drop part of the race like:

ps -C xss-lock -ocommand

...to print only those command-lines for running processes if the command name (which you might also list like ps -Aocomm=) matches xss-lock entirely - as a grep -Fx filter might do. to actually filter with regexes there is also the pgrep tool which can do similarly race-free regex matching.


eponymously, the procps-ng ps operates by parsing files in the /proc tree. for your ps -ocommand, for example, it reads the /proc/$pid/cmdline file for each match it should print and replaces the \0NULs (less the last - which becomes a \newline instead) therein (which delimit each argument in the file) with a space each.

if you want shell-quoted argument lists you'll have to do similar. the most simple way to shell quote any list is to do it with ' hard-quotes. it is most simple because a hard-quoted string is always flat - there's no depth-recursion in parsing because a hard-quoted string cannot contain another hard-quote. so...

'it'\''s not a single string'

...is not a single hard-quoted string, but is three concatenated strings. the first, it is surrounded on both sides by hard-quotes; the second is a single, backspace-quoted apostrophe; and the third, s not a single string, is hard-quoted like the first. the shell combines all three quoted-strings into a single shell word when parsing.

we can do the same with a /proc/$pid/cmdline file. we need to split each file on \0NUL bytes, pre-quote every apostrophe found therein like '\'', then surround every object with apostrophes and insert one or more \tab or space characters between each.

now that i'm thinking about it, my first version of this was both needlessly complicated and, as a result, actually prone to error. intuitively i attempted to hard-quote each command's arguments - so all objects for a single /proc/$pid/file less the first - but that doesn't work (and in, fact, completely inverts the entire resulting command-line's quote-level) if the command name contains an odd number of apostrophes. and it doesn't matter anyway - 'cat' works just as well as cat when run at a command-line. in fact, it works better because the first is a lot more likely to be the cat found in the /proc/$pid/file than otherwise if shell-aliases might be interpreted (as would happen with cat).


so i did a similar thing to meuh's perl script, but with GNU tools. specifically, the sed -z option instructs a GNU sed to split input on \0NUL bytes rather than \newlines, and its -s option instructs to treat each file argument as a separate input stream (so the $ last line might be individually referenced for each argument and the Hold space is reinitialized for each). the ps -Csh -opid= prints a single pid number per line for every running process matching the command name sh, and sed "s|$|/cmdline|" just appends that pathname to each.

because $IFS is unset, the $(cmdsub's) output will definitely be split by the shell into separate arguments on white-space - which is only a single \newline per pathname - and the sed -sze process gets an argument list of all of the /proc/$pid/cmdline pathnames which existed at the time ps -C went looking for them. this last bit reintroduces the whole race game, which is made apparent by the error message printed when sed -sze tries to read the cmdline file for the command sub's sh process which no longer exists - as it has quit by the time sed -sze is called at all.


sh -c '
    cd /proc; unset IFS
    sed  -sze "H;1h;$"\!"d;x"  \
         -e   "s/$1/&\\\\&&/g" \
         -e   "s/\x00/$1 $1/g" \
         -e   "s/.*/$1&$1\n/"  \
    $(  ps  -Csh -opid=  | 
        sed "s|$|/cmdline|")   /dev/null
' -- \'

'./sh' '-IE'
'sh'
'./sh' '-E'
'../sh'
'sh' '-c' '
        cd /proc; unset IFS
        sed  -sze "H;1h;$"\!"d;x"  \
             -e   "s/$1/&\\\\&&/g" \
             -e   "s/\x00/$1 $1/g" \
             -e   "s/.*/$1&$1\n/"  \
        $(  ps  -Csh -opid=  |
            sed "s|$|/cmdline|")   /dev/null
' '--' ''\'''

...i went through the sh -c embedded quoting hassle in the first place for testing, and for demonstration purposes - i needed a process that would hang around with a lot of spaced-out arguments while i ran the test.

if I drop that last empty line of input and end the quoted command just after /dev/null (which is used to keep the sed -sze process from hijacking stdin in case of no ps -Csome_process results), the sh -c process (on a system with a dash sh) will merely exec sed -sze and replace itself with it rather than waiting to check the next line of input. in that case sed -sze will manage to read its own /proc/$pid/cmdline file - because it retains sh -c's pid:

sh -c '
    cd /proc; unset IFS
    sed  -sze "H;1h;$"\!"d;x"  \
         -e   "s/$1/&\\\\&&/g" \
         -e   "s/\x00/$1 $1/g" \
         -e   "s/.*/$1&$1\n/"  \
    $(  ps  -Csh -opid=  | 
        sed "s|$|/cmdline|")   /dev/null' -- \'

'./sh' '-IE'
'sh'
'./sh' '-E'
'../sh'
'sed' '-sze' 'H;1h;$!d;x' '-e' 's/'\''/&\\&&/g' '-e' 's/\x00/'\'' '\''/g' '-e' 's/.*/'\''&'\''\n/' '2508/cmdline' '3773/cmdline' '5099/cmdline' '26599/cmdline' '31487/cmdline' '31488/cmdline' '31881/cmdline' '/dev/null'
sed: can't read 31488/cmdline: No such file or directory
'sh'

Here is a similar version, but it individually quotes each command entire, and stacks the escaped hard-quotes within each another layer deep:

eval "set $(
    sh -c '
        cd /proc; unset IFS
        sed  -sze "H;$"\!"d;x"                  \
             -e   "s/$1/$2\\\\$2$2/g"           \
             -e   "s/\x00\([^\x00]*\)/$2\1$2 /g"\
             -e   "s/.*/$1&$1\\\\\n /"          \
        $(  ps  -Csh -opid=  |
            sed "s|$|/cmdline|")    /dev/null
    ' -- \' "'\\\''")"
i=
for arg do printf "$arg $((i+=1)):\t%s\n" "$arg"
done;   eval "$5"

arg 1:  './sh' '-IE' 
arg 2:  'sh' 
arg 3:  './sh' '-E' 
arg 4:  '../sh' 
arg 5:  'sh' '-c' '
        cd /proc; unset IFS
        sed  -sze "H;$"\!"d;x"                  \
             -e   "s/$1/$2\\\\$2$2/g"           \
             -e   "s/\x00\([^\x00]*\)/$2\1$2 /g"\
             -e   "s/.*/$1&$1\\\\\n /"          \
        $(  ps  -Csh -opid=  | 
            sed "s|$|/cmdline|")   /dev/null
' '--' ''\''' ''\''\\'\'''\''' 
arg 6:  'sh' 
''\''./sh'\'' '\''-IE'\'' '\
 ''\''sh'\'' '\
 ''\''./sh'\'' '\''-E'\'' '\
 ''\''../sh'\'' '\
 ''\''sh'\'' '\''-c'\'' '\''
        cd /proc; unset IFS
        sed  -sze "H;$"\!"d;x"                  \
             -e   "s/$1/$2\\\\$2$2/g"           \
             -e   "s/\x00\([^\x00]*\)/$2\1$2 /g"\
             -e   "s/.*/$1&$1\\\\\n /"          \
        $(  ps  -Csh -opid=  |
            sed "s|$|/cmdline|")   /dev/null
'\'' '\''--'\'' '\'''\''\'\'''\'''\'' '\'''\''\'\'''\''\\'\''\'\'''\'''\''\'\'''\'''\'' '\
sed: can't read 31725/cmdline: No such file or directory
 ''\''sh'\'' '
mikeserv
  • 58,310
  • @l0b0 - do you have a specific question? I guess I could have left out the ./sh '-IE' and etc - but those are also sh processes running in other sessions. the point is that the /proc/$pid/cmdline file was read for each of the ps -Csh -opid= output lines so long as the associated processes still existed when the sed -sze process attempted to read them (which was, for example, not true when sed -sze tried to read the /proc/$(cmd sub sh pid)/cmdline file because it quit before the sed -sze was up and running). There's always a race with ps queries - it's the name of the game. – mikeserv Nov 15 '15 at 17:29
  • OK, I'll try to line up the questions: Can you explain the first series of sed commands and why they're needed? ps -Csh -opid= fails when run on its own, so I guess it gets a process list for $$? How do I use this to get all processes of the current user or all processes globally? Are the extra lines of output other shells that you've started for testing purposes? Because I don't see the setup for the example you're running. – l0b0 Nov 15 '15 at 18:23
  • @l0bo - i'll fix it. – mikeserv Nov 15 '15 at 18:38
  • @l0b0 - its fixed. – mikeserv Nov 15 '15 at 19:33