如何检查是否可以在bash中创建文件或将其截断/覆盖?


15

用户使用文件路径调用我的脚本,该文件路径将在脚本中的某个点被创建或覆盖,例如foo.sh file.txtfoo.sh dir/file.txt

创建或覆盖行为与将文件放在>输出重定向运算符右侧或将其作为参数传递给tee(实际上,将其作为参数传递给tee我的要求)非常相似。)。

在深入探讨脚本之前,我想进行一次合理的检查,以确定是否可以创建/覆盖文件,但实际上无法创建文件。此检查不一定是完美的,是的,我意识到情况可能会在检查和实际写入文件的位置之间发生变化-但在这里,我可以采用尽力而为的类型的解决方案,因此我可以尽早进行纾困如果文件路径无效。

无法创建文件的原因示例:

  • 该文件包含目录组件,例如dir/file.txt但该目录dir不存在
  • 用户在指定目录中没有写权限(如果未指定目录,则没有CWD

是的,我认识到“预先”检查权限不是UNIX Way™,而是我应该尝试操作并稍后再请求宽恕。但是,在我的特定脚本中,这会导致不良的用户体验,并且我无法更改负责任的组件。


参数将始终基于当前目录,还是用户可以指定完整路径?
jesse_b

@Jesse_b-我想用户可以指定一个绝对路径,例如/foo/bar/file.txt。基本上我传递给路径tee喜欢tee $OUT_FILE其中OUT_FILE在命令行上被传递。那应该“绝对”适用于绝对路径和相对路径,对吗?
BeeOnRope

@BeeOnRope,不,您tee -- "$OUT_FILE"至少需要。如果文件已经存在或存在但不是常规文件(目录,symlink,fifo)怎么办?
斯特凡Chazelas

@StéphaneChazelas-我正在使用tee "${OUT_FILE}.tmp"。如果文件已经存在,则将其tee覆盖,这是这种情况下的所需行为。如果是目录,tee将会失败(我认为)。symlink我不确定100%吗?
BeeOnRope

Answers:


11

显而易见的测试是:

if touch /path/to/file; then
    : it can be created
fi

但是,如果文件尚不存在,它实际上会创建文件。我们可以自己清理:

if touch /path/to/file; then
    rm /path/to/file
fi

但这会删除一个您可能不想要的文件。

但是,我们确实有解决方法:

if mkdir /path/to/file; then
    rmdir /path/to/file
fi

您的目录不能与该目录中的另一个对象同名。我无法想到您可以创建目录但不能创建文件的情况。测试完成后,您的脚本将可以自由创建常规代码/path/to/file,并随心所欲地进行操作。


2
这非常巧妙地处理了很多问题!代码注释解释并证明了不寻常的解决方案可能会超出您的答案的长度。:-)
jpaugh

我也曾用作if mkdir /var/run/lockdir锁定测试。这是原子的,而if ! [[ -f /var/run/lockfile ]]; then touch /var/run/lockfile在测试和touch有问题的脚本的另一个实例可以启动之间只有一小段时间。
DopeGhoti

8

根据我收集的信息,您想在使用时检查一下

tee -- "$OUT_FILE"

(请注意,--或否则不适用于以-开头的文件名),tee将成功打开文件进行写入。

就这样:

  • 文件路径的长度不超过PATH_MAX的限制
  • 该文件存在(在符号链接解析之后),并且不是目录类型,并且您具有对该文件的写许可权。
  • 如果该文件不存在,则该文件的目录名作为目录存在(在符号链接解析之后),并且您对该文件具有写和搜索权限,并且文件名长度不超过该目录所位于的文件系统的NAME_MAX限制。
  • 或文件是一个符号链接,指向一个不存在的文件,它不是一个符号链接循环,但符合上面的条件

现在,我们将忽略像vfat,ntfs或hfsplus这样的文件系统,这些文件系统对文件名可能包含的字节值,磁盘配额,进程限制,selinux,apparmor或其他安全机制有限制,例如完整的文件系统,没有索引节点,设备由于某种原因而无法通过这种方式打开的文件,即当前在某个进程地址空间中映射的可执行文件,所有这些文件也可能影响打开或创建文件的能力。

zsh

zmodload zsh/system
tee_would_likely_succeed() {
  local file=$1 ERRNO=0 LC_ALL=C
  if [ -d "$file" ]; then
    return 1 # directory
  elif [ -w "$file" ]; then
    return 0 # writable non-directory
  elif [ -e "$file" ]; then
    return 1 # exists, non-writable
  elif [ "$errnos[ERRNO]" != ENOENT ]; then
    return 1 # only ENOENT error can be recovered
  else
    local dir=$file:P:h base=$file:t
    [ -d "$dir" ] &&    # directory
      [ -w "$dir" ] &&  # writable
      [ -x "$dir" ] &&  # and searchable
      (($#base <= $(getconf -- NAME_MAX "$dir")))
    return
  fi
}

bash任何类似Bourne的外壳中,只需更换

zmodload zsh/system
tee_would_likely_succeed() {
  <zsh-code>
}

与:

tee_would_likely_succeed() {
  zsh -s -- "$@" << 'EOF'
  zmodload zsh/system
  <zsh-code>
EOF
}

zsh这里的-specific功能是$ERRNO(公开最后一个系统调用的错误代码)和$errnos[]关联数组,以转换为相应的标准C宏名称。和$var:h(来自csh)和$var:P(需要zsh 5.3或更高版本)。

bash还没有等效功能。

$file:h可以用dir=$(dirname -- "$file"; echo .); dir=${dir%??}或用GNU 代替dirnameIFS= read -rd '' dir < <(dirname -z -- "$file")

对于$errnos[ERRNO] == ENOENT,一种方法是ls -Ld在文件上运行,并检查错误消息是否对应于ENOENT错误。但是,可靠且便携地执行此操作很棘手。

一种方法可能是:

msg_for_ENOENT=$(LC_ALL=C ls -d -- '/no such file' 2>&1)
msg_for_ENOENT=${msg_for_ENOENT##*:}

(假设错误消息以的翻译结尾syserror()ENOENT并且该翻译不包含:),然后代替[ -e "$file" ]

err=$(ls -Ld -- "$file" 2>&1)

并检查是否有ENOENT错误

case $err in
  (*:"$msg_for_ENOENT") ...
esac

$file:P部分是在中最难实现的bash,尤其是在FreeBSD上。

FreeBSD确实有一个realpath命令和一个readlink接受-f选项的命令,但是在文件是无法解析的符号链接的情况下不能使用它们。这是具有相同perlCwd::realpath()

pythonos.path.realpath()确实出现了类似的工作zsh $file:P,所以假设中的至少一个版本python已安装并有一个python引用其中之一(这是不是在FreeBSD给出),你可以做的命令:

dir=$(python -c '
import os, sys
print(os.path.realpath(sys.argv[1]) + ".")' "$dir") || return
dir=${dir%.}

但是,您也可以在中完成整个操作python

或者,您可以决定不处理所有这些极端情况。


是的,您正确理解了我的意图。实际上,最初的问题尚不清楚:我最初谈论的是创建文件,但是实际上我正在使用它,tee因此要求实际上是可以创建文件(如果文件不存在),或者可以将其截断为零并覆盖。如果可以(或其他方式可以tee处理)。
BeeOnRope

看起来您的上一次编辑删除了格式
D. Ben Knoble

谢谢@ Bishop,@ D.BenKnoble,请参见编辑。
斯特凡Chazelas

1
@DennisWilliamson,是的,shell是调用程序的工具,必须安装在脚本中调用的每个程序才能使脚本正常工作。macOS默认同时安装了bash和zsh。FreeBSD都没有。OP可能有充分的理由希望在bash中执行此操作,也许这是他们希望将其添加到已编写的bash脚本中的原因。
斯特凡Chazelas

1
同样,@ pipe,shell是命令解释器。你会看到这里写的调用其他语言的口译喜欢,很多shell代码摘录perlawksedpython(甚至shbash...)。Shell脚本编写是关于为任务使用最佳命令。这里甚至perl没有的等效zsh$var:P(它的Cwd::realpath作用就像BSD realpathreadlink -f当你希望它表现得像GNU readlink -fpythonos.path.realpath()
斯特凡Chazelas

6

您可能要考虑的一种选择是尽早创建文件,但仅稍后在脚本中填充它。您可以使用该exec命令在文件描述符(例如3、4等)中打开文件,然后在以后使用重定向到文件描述符(>&3等)将内容写入该文件。

就像是:

#!/bin/bash

# Open the file for read/write, so it doesn't get
# truncated just yet (to preserve the contents in
# case the initial checks fail.)
exec 3<>dir/file.txt || {
    echo "Error creating dir/file.txt" >&2
    exit 1
}

# Long checks here...
check_ok || {
    echo "Failed checks" >&2
    # cleanup file before bailing out
    rm -f dir/file.txt
    exit 1
}

# We're ready to write, first truncate the file.
# Use "truncate(1)" from coreutils, and pass it
# /dev/fd/3 so the file doesn't need to be reopened.
truncate -s 0 /dev/fd/3

# Now populate the file, use a redirection to write
# to the previously opened file descriptor.
populate_contents >&3

您也可以使用trap清除错误的文件,这是一种常见的做法。

这样,您就可以对创建文件的权限进行真正的检查,同时可以尽早执行该文件,以至于如果失败,您就不必花费时间等待长时间的检查。


更新:为了避免在检查失败的情况下破坏文件,请使用bash的fd<>file重定向,该重定向不会立即截断文件。(我们不在乎从文件中读取数据,这只是一种解决方法,因此我们不会截断它。添加>>可能也可以工作,但是我倾向于发现这种方法更为优雅,将O_APPEND标志保留为图片)。

当我们准备替换内容时,我们需要先截断文件(否则,如果写入的字节数少于文件中以前的字节数,则尾随的字节将保留在那里。)我们可以使用truncate(1)为此,可以从coreutils中使用命令,然后我们可以将其拥有的打开文件描述符(使用/dev/fd/3伪文件)传递给它,因此不需要重新打开文件。(再次,从技术上讲,更简单的: >dir/file.txt方法可能会起作用,但不必重新打开文件是一个更优雅的解决方案。)


我曾经考虑过这一点,但问题是,如果当我创建该文件,点一段时间后,我会写它介于两者之间出现其他无辜的错误,用户可能会被打乱,找出指定的文件被覆盖。
BeeOnRope

1
@BeeOnRope不会破坏文件的另一个选项是尝试不添加任何内容:echo -n >>filetrue >> file。当然,ext4具有仅追加文件,但是您可以忍受这种误报。
mosvy

1
@BeeOnRope如果您需要保留(a)现有文件或(b)(完整)新文件,则与您提出的问题不同。一个很好的答案是my-file.txt.backup在创建新文件之前将现有文件移动到新名称(例如)。另一种解决方案是将新文件写入同一文件夹中的临时文件,然后在脚本的其余部分成功执行后,将其复制到旧文件中-如果上一次操作失败,则用户可以手动解决问题而不会丢失他或她的进步。
jpaugh

1
@BeeOnRope如果您使用“原子替换”路由(这是一个好方法!),那么通常您将需要将临时文件写入与最终文件相同的目录中(它们需要位于同一文件系统中才能重命名) (2)成功)并使用唯一的名称(因此,如果有两个正在运行的脚本实例,它们将不会破坏彼此的临时文件。)打开一个临时文件以在同一目录中写入通常是一个很好的指示,以后可以重命名(好吧,除非最终目标存在并且是目录),因此在某种程度上它也解决了您的问题。
filbranden

1
@jpaugh-我非常不愿意这样做。这将不可避免地导致试图解决我的更高级别问题的答案,这可能会接受与“我如何检查...”问题无关的解决方案。不管我的高级问题是什么,我都认为这个问题是您可能会想做的事情。如果我将其更改为“解决我的脚本遇到的特定问题”,这对于回答该特定问题的人是不公平的。我在这里包括这些详细信息是因为有人问我,但我不想将主要问题更改。
BeeOnRope

4

我认为DopeGhoti的解决方案更好,但这应该也可以:

file=$1
if [[ "${file:0:1}" == '/' ]]; then
    dir=${file%/*}
elif [[ "$file" =~ .*/.* ]]; then
    dir="$(PWD)/${file%/*}"
else
    dir=$(PWD)
fi

if [[ -w "$dir" ]]; then
    echo "writable"
    #do stuff with writable file
else
    echo "not writable"
    #do stuff without writable file
fi

第一个if构造检查参数是否为完整路径(以开头/),并将dir变量设置为最后一个目录路径/。否则,如果参数不是以a开头/但包含一个/(指定子目录),它将设置dir为当前工作目录+子目录路径。否则,它将采用当前的工作目录。然后,它检查该目录是否可写。


4

如何使用test下面概述的普通命令?

FILE=$1

DIR=$(dirname $FILE) # $DIR now contains '.' for file names only, 'foo' for 'foo/bar'

if [ -d $DIR ] ; then
  echo "base directory $DIR for file exists"
  if [ -e $FILE ] ; then
    if [ -w $FILE ] ; then
      echo "file exists, is writeable"
    else
      echo "file exists, NOT writeable"
    fi
  elif [ -w $DIR ] ; then
    echo "directory is writeable"
  else
    echo "directory is NOT writeable"
  fi
else
  echo "can NOT create file in non-existent directory $DIR "
fi

我几乎重复了这个答案!不错的介绍,但是您可能会提到man test,它[ ]等同于测试(不再是常识)。这是我将在下等答案中使用的单线内容,以涵盖后代的确切情况:if [ -w $1] && [ ! -d $1 ] ; then echo "do stuff"; fi
Iron Gremlin

4

您提到用户体验正在推动您的问题。我会从用户体验的角度回答,因为您在技术方面有很好的答案。

与其预先执行检查,不如将结果写入临时文件,然后在最后将结果放入用户所需的文件中,该怎么办?喜欢:

userfile=${1:?Where would you like the file written?}
tmpfile=$(mktemp)

# ... all the complicated stuff, writing into "${tmpfile}"

# fill user's file, keeping existing permissions or creating anew
# while respecting umask
cat "${tmpfile}" > "${userfile}"
if [ 0 -eq $? ]; then
    rm "${tmpfile}"
else
    echo "Couldn't write results into ${userfile}." >&2
    echo "Results available in ${tmpfile}." >&2
    exit 1
fi

这种方法的好处是:它可以在正常的快乐路径场景中产生所需的操作,回避测试和设置原子性问题,在必要时在创建时保留目标文件的权限,并且实施起来非常简单。

注意:如果使用过mv,我们将保留临时文件的权限-我想我们不希望这样:我们想保留目标文件上设置的权限。

现在,这很糟糕:它需要两倍的空间(cat .. >构造),如果目标文件在需要时不可写,则迫使用户执行一些手动工作,并留下临时文件(这可能具有安全性)或维护问题)。


实际上,这或多或少是我现在正在做的事情。我将大多数结果写入一个临时文件,然后最后执行最后的处理步骤,然后将结果写入最终文件。问题是,如果最后一步可能失败,我想尽早进行纾困(在脚本开始时)。该脚本可能在无人看管的情况下运行了几分钟或几小时,因此您真的想预先知道它注定要失败!
BeeOnRope

可以,但是有很多方法可能会失败:磁盘可能已满,上游目录可能会被删除,权限可能会发生变化,目标文件可能会用于其他重要内容,而用户却忘记了他分配了该文件以销毁该文件。操作。如果我们从纯粹的UX角度谈论此问题,那么也许正确的做法是将其视为作业提交:最后,当您知道它可以正确完成工作时,只需告诉用户所生成的内容位于何处并提供一个建议他们命令将它移到自己。
主教

从理论上讲,是的,有很多方法可能会失败。实际上,绝大多数情况下失败是因为提供的路径无效。我无法合理地阻止一些流氓用户在操作过程中同时修改FS来中断工作,但是我当然可以检查路径无效或不可写的#1失败原因。
BeeOnRope

2

TL; DR:

: >> "${userfile}"

<> "${userfile}"或不同touch "${userfile}",这不会对文件的时间戳进行任何虚假修改,并且还将与只写文件一起使用。


从OP:

我想做一个合理的检查,是否可以创建/覆盖文件,但实际上不能创建它。

从您对UX的评论到我的回答

失败的绝大多数时间是因为提供的路径无效。我无法合理地阻止一些流氓用户在操作过程中同时修改FS来中断工作,但是我当然可以检查路径无效或不可写的#1失败原因。

唯一可靠的测试是对open(2)文件的测试,因为只有这样才能解决有关可写性的每个问题:路径,所有权,文件系统,网络,安全上下文等。任何其他测试都将解决可写性的某些部分,而不是其他部分。如果您需要一部分测试,则最终必须选择对您而言重要的部分。

但这是另一个想法。据我了解:

  1. 内容创建过程是长期运行的,并且
  2. 目标文件应保持一致状态。

由于#1,您想进行此预检查,而由于#2,您不想摆弄现有文件。那么,为什么不要求外壳程序打开要追加的文件,却不实际追加任何内容呢?

$ tree -ps
.
├── [dr-x------        4096]  dir_r
├── [drwx------        4096]  dir_w
├── [-r--------           0]  file_r
└── [-rw-------           0]  file_w

$ for p in file_r dir_r/foo file_w dir_w/foo; do : >> $p; done
-bash: file_r: Permission denied
-bash: dir_r/foo: Permission denied

$ tree -ps
.
├── [dr-x------        4096]  dir_r
├── [drwx------        4096]  dir_w
   └── [-rw-rw-r--           0]  foo
├── [-r--------           0]  file_r
└── [-rw-------           0]  file_w

在幕后,这完全按照需要解决了可写性问题:

open("dir_w/foo", O_WRONLY|O_CREAT|O_APPEND, 0666) = 3

但不修改文件的内容或元数据。现在,是的,这种方法:

  • 不会告诉您文件是否仅是追加文件,这在内容创建结束时进行更新时可能会出现问题。您可以在某种程度上检测到这一点lsattr并做出相应的反应。
  • 创建一个以前不存在的文件(如果是这种情况):使用selectant减轻它rm

尽管我主张(在我的其他答复中)最用户友好的方法是创建用户必须移动的临时文件,但我认为这是对用户输入进行完全审查的最少用户恶意的方法。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.