Как я могу проверить, может ли файл быть создан или обрезан / перезаписан в bash?

15

Пользователь вызывает мой сценарий с путем к файлу, который будет либо создан, либо перезаписан в какой-то момент сценария, например foo.sh file.txtили foo.sh dir/file.txt.

Поведение «создать или перезаписать» во многом похоже на требования для размещения файла справа от >оператора перенаправления вывода или для передачи его в качестве аргумента tee(фактически, передача его в качестве аргумента teeявляется именно тем, что я делаю ).

Перед тем, как попасть в кишках сценария, я хочу , чтобы сделать разумный контроль , если файл может быть создан / перезаписаны, но на самом деле не создать. Эта проверка не обязательно должна быть идеальной, и да, я понимаю, что ситуация может измениться между проверкой и моментом, когда файл фактически записан, - но здесь у меня все в порядке с наилучшим решением для типов усилий, поэтому я могу выручить рано в случае, если путь к файлу неверен.

Примеры причин, по которым файл не может быть создан:

  • файл содержит компонент каталога, например, dir/file.txtно каталог dirне существует
  • пользователь не имеет разрешения на запись в указанный каталог (или CWD, если каталог не был указан

Да, я понимаю, что проверка разрешений "заранее" - это не UNIX Way ™ , скорее я должен просто попробовать выполнить операцию и попросить прощения позже. Однако в моем конкретном сценарии это приводит к плохому взаимодействию с пользователем, и я не могу изменить ответственный компонент.

BeeOnRope
источник
Будет ли аргумент всегда основываться на текущем каталоге или пользователь может указать полный путь?
Jesse_b
@Jesse_b - я полагаю, что пользователь может указать абсолютный путь, например /foo/bar/file.txt. В основном я прохожу путь teeнравится , tee $OUT_FILEкогда OUT_FILEпередается в командной строке. Это должно "просто работать" как с абсолютными, так и с относительными путями, верно?
BeeOnRope
@BeeOnRope, нет, тебе нужно tee -- "$OUT_FILE"хотя бы. Что если файл уже существует или существует, но не является обычным файлом (каталог, символическая ссылка, fifo)?
Стефан Шазелас
@ StéphaneChazelas - ну, я использую tee "${OUT_FILE}.tmp". Если файл уже существует, teeперезаписывает, что является желаемым поведением в этом случае. Если это каталог, не teeполучится (я думаю). символическая ссылка Я не уверен на 100%?
BeeOnRope

Ответы:

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и делать с ними все, что угодно.

DopeGhoti
источник
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
  • файл существует (после разрешения символической ссылки) и не относится к каталогу типа, и у вас есть разрешение на запись в него.
  • если файл не существует, dirname файла существует (после разрешения символической ссылки) как каталог, и у вас есть разрешение на запись и поиск в нем, а длина имени файла не превышает ограничение NAME_MAX файловой системы, в которой находится каталог.
  • или файл является символической ссылкой, которая указывает на файл, который не существует и не является циклом символической ссылки, но соответствует критериям, указанным выше

На данный момент мы будем игнорировать файловые системы, такие как vfat, ntfs или hfsplus, которые имеют ограничения относительно того, какие байтовые значения могут содержать имена файлов, квоту диска, ограничение процесса, selinux, apparmor или другой механизм безопасности в пути, полную файловую систему, не осталось inode, устройство файлы, которые не могут быть открыты таким образом по той или иной причине, файлы, которые являются исполняемыми файлами, которые в настоящее время отображаются в некотором адресном пространстве процесса, что также может повлиять на возможность открытия или создания файла.

С 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или любой подобной Борну оболочке, просто замените

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

с:

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

Здесь zsh-специальные функции $ERRNO(которые предоставляют код ошибки последнего системного вызова) и $errnos[]ассоциативный массив для перевода в соответствующие стандартные имена макросов C. И $var:h(из csh) и $var:P(требуется zsh 5.3 или выше).

У bash еще нет аналогичных функций.

$file:hможет быть заменен dir=$(dirname -- "$file"; echo .); dir=${dir%??}, или с GNU dirname: IFS= 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опцию, но их нельзя использовать в тех случаях, когда файл является символической ссылкой, которая не разрешается. То же самое и с perlх Cwd::realpath().

python«ы os.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.BenKnoble, см. Редактирование.
Стефан Шазелас
1
@DennisWilliamson, ну да, оболочка - это инструмент для вызова программ, и каждая программа, которую вы вызываете в своем скрипте, должна быть установлена ​​для того, чтобы ваш скрипт работал, само собой разумеется. MacOS поставляется с bash и zsh, установленными по умолчанию. FreeBSD не содержит ни того, ни другого. У OP может быть веская причина для желания сделать это в bash, может быть, это то, что они хотят добавить в уже написанный сценарий bash.
Стефан Шазелас
1
@pipe, опять же, оболочка является интерпретатором команд. Вы увидите , что многие кода оболочки отрывки , написанные здесь Invoke интерпретаторы других языков , таких как perl, awk, sedили python(или даже sh, bash...). Сценарии оболочки - это использование лучшей команды для этой задачи. Здесь даже perlнет эквивалента zsh's $var:P(он Cwd::realpathведет себя как BSD realpathили в readlink -fто время как вы хотите, чтобы он вел себя как GNU readlink -fили pythons os.path.realpath())
Стефан Шазелас
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для очистки файла при ошибке, это обычная практика.

Таким образом, вы получаете реальную проверку разрешений, что вы сможете создать файл, и в то же время вы можете выполнить его достаточно рано, чтобы в случае неудачи вы не тратили время на ожидание длинных проверок.


ОБНОВЛЕНО: во избежание путаницы файла в случае неудачной проверки используйте fd<>fileперенаправление bash, которое не усекает файл сразу. (Мы не заботимся о чтении из файла, это всего лишь обходной путь, поэтому мы не усекаем его. Добавление с >>, вероятно, тоже сработает, но я склонен находить это более элегантным, оставляя флаг O_APPEND вне картинки.)

Когда мы будем готовы заменить содержимое, нам нужно сначала обрезать файл (в противном случае, если мы пишем меньше байтов, чем было в файле ранее, конечные байты остались бы там.) Мы можем использовать усечение (1) Для этого /dev/fd/3нужно выполнить команду из coreutils, и мы можем передать ей дескриптор открытого файла, который у нас есть (с использованием псевдофайла), поэтому нет необходимости повторно открывать файл. (Опять же, технически что-то более простое, как : >dir/file.txtхотелось бы, возможно, сработает, но не нужно повторно открывать файл, это более элегантное решение.)

filbranden
источник
Я обдумывал это, но проблема в том, что если где-то между тем, когда я создаю файл, и моментом, когда я записываю файл, возникает другая невинная ошибка, пользователь, вероятно, будет расстроен, обнаружив, что указанный файл был перезаписан ,
BeeOnRope
1
@BeeOnRope Еще одна опция, которая не будет загромождать файл, - попытаться ничего не добавлять к нему: echo -n >>fileили true >> file. Конечно, у ext4 есть файлы только для добавления, но вы можете жить с этим ложным срабатыванием.
Мосви
1
@BeeOnRope Если вам нужно сохранить (а) существующий файл или (б) (полный) новый файл, это другой вопрос, чем вы задавали. Хороший ответ - переместить существующий файл под новым именем (например, 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переменной путь к каталогу до последнего /. В противном случае, если аргумент не начинается с, /но содержит /(указав подкаталог), он будет установлен dirна текущий рабочий каталог + путь подкаталога. В противном случае предполагается наличие текущего рабочего каталога. Затем он проверяет, доступен ли этот каталог для записи.

Jesse_b
источник
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
Jaleks
источник
Я почти продублировал этот ответ! Хорошее введение, но вы могли бы упомянуть man test, и это [ ]эквивалентно тесту (больше не общеизвестно). Вот строчка, которую я собирался использовать в своем неполноценном ответе, чтобы покрыть точный случай для потомков:if [ -w $1] && [ ! -d $1 ] ; then echo "do stuff"; fi
Железный Гремлин
4

Вы упомянули, что пользовательский опыт управлял вашим вопросом. Я отвечу с точки зрения UX, так как у вас есть хорошие ответы по технической части.

Вместо того, чтобы выполнять предварительную проверку, как насчет записи результатов во временный файл, а затем в самом конце, поместив результаты в нужный файл пользователя? Подобно:

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

Преимущество такого подхода: он производит желаемую операцию в нормальном сценарии «счастливого пути», обходит проблему атомарности test-and-set, сохраняет разрешения целевого файла при создании, если это необходимо, и очень прост в реализации.

Примечание: если бы мы использовали mv, мы бы сохранили права доступа к временному файлу - я думаю, мы этого не хотим: мы хотим сохранить права доступа, установленные для целевого файла.

Теперь, плохо: требуется вдвое больше места ( cat .. >конструкции), вынуждает пользователя выполнять некоторую ручную работу, если целевой файл не был доступен для записи в то время, когда это было необходимо, и оставляет временный файл лежащим вокруг (что может иметь безопасность или вопросы обслуживания).

епископ
источник
На самом деле, это более или менее то, что я делаю сейчас. Я записываю большинство результатов во временный файл, а затем в конце выполняю последний этап обработки и записываю результаты в окончательный файл. Проблема в том, что я хочу выручить пораньше (в начале скрипта), если этот последний шаг, скорее всего, потерпит неудачу. Сценарий может выполняться без присмотра в течение нескольких минут или часов, так что вы действительно хотите знать заранее, что он обречен на провал!
BeeOnRope
Конечно, но есть много способов, которыми это может не сработать: диск может заполниться, может быть удален вышестоящий каталог, могут измениться разрешения, целевой файл может быть использован для некоторых других важных вещей, и пользователь забыл, что он назначил этот же файл для уничтожения этим операция. Если мы поговорим об этом с точки зрения чистого UX, то, возможно, правильнее будет рассматривать его как передачу задания: в конце, когда вы знаете, что оно работало правильно до завершения, просто сообщите пользователю, где находится результирующий контент, и предложите предложил команда для них , чтобы переместить его сами.
епископ
В теории, да, есть бесконечные способы, которыми это может потерпеть неудачу На практике подавляющее большинство времени это терпит неудачу, потому что предоставленный путь недопустим. Я не могу разумно помешать некоторым мошенническим пользователям одновременно изменять FS, чтобы прервать работу в середине операции, но я, конечно, могу проверить причину сбоя # 1 неверного или недоступного для записи пути.
BeeOnRope
2

TL; DR:

: >> "${userfile}"

В отличие от <> "${userfile}"или touch "${userfile}", это не приведет к ложным изменениям временных меток файла, а также будет работать с файлами только для записи.


Из ОП:

Я хочу сделать разумную проверку, может ли файл быть создан / перезаписан, но на самом деле не создавать его.

И из вашего комментария к моему ответу с точки зрения 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и реагировать соответственно.
  • создает файл, который ранее не существовал, если это так: смягчите это с помощью выборочного rm.

Хотя я утверждаю (в моем другом ответе), что наиболее удобный для пользователя подход - это создать временный файл, который пользователь должен переместить, я думаю, что это наименее враждебный для пользователя подход, чтобы полностью проверить его ввод.

епископ
источник