Como posso verificar se um arquivo pode ser criado ou truncado / substituído no bash?

15

O usuário chama meu script com um caminho de arquivo que será criado ou substituído em algum momento do script, como foo.sh file.txtou foo.sh dir/file.txt.

O comportamento de criação ou substituição é muito parecido com os requisitos para colocar o arquivo no lado direito do >operador de redirecionamento de saída ou transmiti-lo como argumento para tee(na verdade, transmiti-lo como argumento para teeé exatamente o que estou fazendo )

Antes de entrar no âmago do script, quero fazer uma verificação razoável se o arquivo pode ser criado / substituído, mas na verdade não o cria. Essa verificação não precisa ser perfeita, e sim, eu percebo que a situação pode mudar entre a verificação e o ponto em que o arquivo está realmente gravado - mas aqui eu estou bem com a melhor solução de tipo de esforço para que eu possa resgatar cedo no caso em que o caminho do arquivo é inválido.

Exemplos de razões pelas quais o arquivo não pôde ser criado:

  • o arquivo contém um componente de diretório, como dir/file.txtmas o diretório dirnão existe
  • o usuário não tem permissões de gravação no diretório especificado (ou o CWD se nenhum diretório foi especificado

Sim, eu percebo que verificar as permissões "de antemão" não é The UNIX Way ™ ; devo tentar a operação e pedir perdão mais tarde. No meu script específico, no entanto, isso leva a uma experiência ruim do usuário e não posso alterar o componente responsável.

BeeOnRope
fonte
O argumento sempre será baseado no diretório atual ou o usuário poderá especificar um caminho completo?
Jesse_b
@ Jesse_b - Suponho que o usuário possa especificar um caminho absoluto como /foo/bar/file.txt. Basicamente, passo o caminho para teegostar de tee $OUT_FILEonde OUT_FILEé passado na linha de comando. Isso deve "apenas funcionar" com caminhos absolutos e relativos, certo?
BeeOnRope
@BeeOnRope, não, você precisaria tee -- "$OUT_FILE"pelo menos. E se o arquivo já existir ou existir, mas não for um arquivo regular (diretório, link simbólico, fifo)?
Stéphane Chazelas
@ StéphaneChazelas - bem, eu estou usando tee "${OUT_FILE}.tmp". Se o arquivo já existir, teesubstitua, qual é o comportamento desejado nesse caso. Se for um diretório, teefalhará (eu acho). symlink não tenho 100% de certeza?
BeeOnRope

Respostas:

11

O teste óbvio seria:

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

Mas ele realmente cria o arquivo se ele ainda não estiver lá. Podemos limpar depois de nós mesmos:

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

Mas isso removeria um arquivo que já existia, o que você provavelmente não deseja.

No entanto, temos uma maneira de contornar isso:

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

Você não pode ter um diretório com o mesmo nome que outro objeto nesse diretório. Não consigo pensar em uma situação em que você seria capaz de criar um diretório, mas não criar um arquivo. Após esse teste, seu script estará livre para criar um convencional /path/to/filee fazer o que bem entender.

DopeGhoti
fonte
2
Isso lida muito bem com tantos problemas! Um comentário de código explicando e justificando que uma solução incomum pode muito bem exceder a duração da sua resposta. :-)
jpaugh
Eu também usei if mkdir /var/run/lockdircomo um teste de bloqueio. Isso é atômico, embora if ! [[ -f /var/run/lockfile ]]; then touch /var/run/lockfiletenha uma pequena fatia de tempo entre o teste e touchonde outra instância do script em questão pode ser iniciada.
DopeGhoti 13/1118
8

Pelo que estou coletando, você deseja verificar se, ao usar

tee -- "$OUT_FILE"

(observe que --ou não funcionaria para nomes de arquivos que começam com -), teeseria possível abrir o arquivo para gravação.

Isso é isso:

  • o comprimento do caminho do arquivo não excede o limite PATH_MAX
  • o arquivo existe (após a resolução do link simbólico) e não é do tipo diretório e você tem permissão de gravação para ele.
  • se o arquivo não existir, o nome do diretório do arquivo existe (após a resolução do link simbólico) como um diretório e você tem permissão de gravação e pesquisa e o comprimento do nome do arquivo não excede o limite NAME_MAX do sistema de arquivos em que o diretório reside.
  • ou o arquivo é um link simbólico que aponta para um arquivo que não existe e não é um loop de link simbólico, mas atende aos critérios logo acima

Por enquanto, ignoraremos sistemas de arquivos como vfat, ntfs ou hfsplus que têm limitações sobre quais valores de bytes os nomes de arquivos podem conter, cota de disco, limite de processo, selinux, apparmor ou outro mecanismo de segurança, como sistema de arquivos completo, sem inode restante, dispositivo arquivos que não podem ser abertos dessa maneira por um motivo ou outro, arquivos executáveis ​​atualmente mapeados em algum espaço de endereço do processo, os quais também podem afetar a capacidade de abrir ou criar o arquivo.

Com 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
}

Em bashou qualquer shell semelhante a Bourne, basta substituir o

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

com:

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

Os zshrecursos específicos aqui são $ERRNO(que expõem o código de erro da última chamada do sistema) e a $errnos[]matriz associativa para converter nos nomes de macro C padrão correspondentes. E o $var:h(do csh) e $var:P(precisa do zsh 5.3 ou superior).

O bash ainda não possui recursos equivalentes.

$file:hpode ser substituído por dir=$(dirname -- "$file"; echo .); dir=${dir%??}, ou com GNU dirname: IFS= read -rd '' dir < <(dirname -z -- "$file").

Para $errnos[ERRNO] == ENOENT, uma abordagem poderia ser executada ls -Ldno arquivo e verificar se a mensagem de erro corresponde ao erro ENOENT. Fazer isso de maneira confiável e portável é complicado.

Uma abordagem poderia ser:

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

(supondo que a mensagem de erro termine com a syserror()tradução de ENOENTe que essa tradução não inclua a :) e, em vez de [ -e "$file" ]:

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

E verifique se há um erro ENOENT com

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

A $file:Pparte é a mais difícil de conseguir bash, especialmente no FreeBSD.

O FreeBSD possui um realpathcomando e um readlinkcomando que aceita uma -fopção, mas eles não podem ser usados ​​nos casos em que o arquivo é um link simbólico que não resolve. Isso é o mesmo com perl's Cwd::realpath().

python's os.path.realpath()parece funcionar de forma semelhante a zsh $file:P, por isso, assumindo que pelo menos uma versão do pythonestá instalado e que há um pythoncomando que refere-se a um deles, você poderia fazer (que não é um dado no FreeBSD é):

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

Mas então, é melhor você fazer a coisa toda python.

Ou você pode decidir não lidar com todos esses casos de canto.

Stéphane Chazelas
fonte
Sim, você entendeu minha intenção corretamente. De fato, a pergunta original não era clara: eu falei originalmente sobre a criação do arquivo, mas na verdade estou usando-o com teeisso, o requisito é realmente que o arquivo possa ser criado se não existir, ou truncado para zero e substituído se o fizer (ou, no entanto, teelida com isso).
BeeOnRope
Parece que sua última edição limpou a formatação
D. Ben Knoble
Obrigado, @ bispo, @ D.BenKnoble, veja editar.
Stéphane Chazelas
1
@DennisWilliamson, bem, sim, um shell é uma ferramenta para invocar programas, e todos os programas que você invoca em seu script devem ser instalados para que seu script funcione, isso é desnecessário. O macOS vem com o bash e o zsh instalados por padrão. O FreeBSD vem com nenhum dos dois. O OP pode ter um bom motivo para querer fazer isso no bash, talvez seja algo que eles desejem adicionar a um script bash já escrito.
Stéphane Chazelas
1
@pipe, novamente, um shell é um interpretador de comandos. Você verá que muitos trechos de código shell escritos aqui invocar intérpretes de outras línguas como perl, awk, sedou python(ou mesmo sh, bash...). Script de shell é sobre como usar o melhor comando para a tarefa. Aqui ainda perlnão tem um equivalente a zsh's $var:P( Cwd::realpathse comporta como o BSD realpathou readlink -fenquanto você deseja que se comporte como o GNU readlink -fou python' s os.path.realpath()) #
Stéphane Chazelas
6

Uma opção que você pode considerar é criar o arquivo desde o início, mas apenas preenchê-lo posteriormente no seu script. Você pode usar o execcomando para abrir o arquivo em um descritor de arquivo (como 3, 4 etc.) e, posteriormente, usar um redirecionamento para um descritor de arquivo ( >&3etc.) para gravar o conteúdo desse arquivo.

Algo como:

#!/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

Você também pode usar a trappara limpar o arquivo com erro, isso é uma prática comum.

Dessa forma, você recebe uma verificação real das permissões que poderá criar o arquivo e, ao mesmo tempo, pode executá-lo com antecedência suficiente para que, se isso falhar, você não tenha perdido tempo esperando as verificações longas.


ATUALIZADO: Para evitar atropelar o arquivo no caso de as verificações falharem, use o fd<>fileredirecionamento do bash que não trunca o arquivo imediatamente. (Não nos preocupamos com a leitura do arquivo, essa é apenas uma solução alternativa, portanto não a truncamos. Anexar com >>provavelmente funcionaria também, mas eu costumo achar esse um pouco mais elegante, mantendo o sinalizador O_APPEND fora da imagem.)

Quando estivermos prontos para substituir o conteúdo, precisamos truncar o arquivo primeiro (caso contrário, se estivermos escrevendo menos bytes do que o existente no arquivo antes, os bytes à direita permanecerão lá.) Podemos usar o truncado (1) comando do coreutils para esse fim, e podemos passar o descritor de arquivo aberto que temos (usando o /dev/fd/3pseudo-arquivo) para que ele não precise reabrir o arquivo. (Novamente, tecnicamente algo mais simples : >dir/file.txtprovavelmente funcionaria, mas não ter que reabrir o arquivo é uma solução mais elegante.)

filbranden
fonte
Eu tinha considerado isso, mas o problema é que, se outro erro inocente ocorrer em algum momento entre a criação do arquivo e o momento em que eu o escreveria, o usuário provavelmente ficará chateado ao descobrir que o arquivo especificado foi substituído .
BeeOnRope
1
@BeeOnRope outra opção que não atrapalha o arquivo é tentar acrescentar nada a ele: echo -n >>fileou true >> file. Obviamente, o ext4 possui arquivos somente anexados, mas você pode conviver com esse falso positivo.
mosvy
1
@BeeOnRope Se você precisar preservar (a) o arquivo existente ou (b) o (completo) novo arquivo, é uma pergunta diferente da que você fez. Uma boa resposta é mover o arquivo existente para um novo nome (por exemplo my-file.txt.backup) antes de criar o novo. Outra solução é gravar o novo arquivo em um arquivo temporário na mesma pasta e copiá-lo sobre o arquivo antigo depois que o restante do script for bem-sucedido - se a última operação falhar, o usuário poderá corrigir o problema manualmente sem perder seu progresso.
jpaugh
1
@BeeOnRope Se você optar pela rota "substituição atômica" (que é boa!), Normalmente você deseja gravar o arquivo temporário no mesmo diretório que o arquivo final (eles precisam estar no mesmo sistema de arquivos para renomear (2) para obter êxito) e use um nome exclusivo (por isso, se houver duas instâncias do script em execução, elas não prejudicam os arquivos temporários uma da outra.) Abrir um arquivo temporário para gravação no mesmo diretório geralmente é uma boa indicação de que você você poderá renomeá-lo mais tarde (bem, exceto se o destino final existir e for um diretório); portanto, até certo ponto, ele também abordará sua pergunta.
18718 Filbranden
1
@ jpaugh - Estou bastante relutante em fazer isso. Isso inevitavelmente levaria a respostas tentando resolver meu problema de nível superior, o que poderia admitir soluções que nada têm a ver com a pergunta "Como posso verificar ...". Independentemente de qual seja meu problema de alto nível, acho que essa pergunta permanece sozinha como algo que você pode razoavelmente querer fazer. Seria injusto para as pessoas que estão respondendo a essa pergunta específica se eu a alterasse para "resolver esse problema específico que estou tendo com o meu script". Incluí esses detalhes aqui porque me pediram, mas não quero alterar a questão principal.
BeeOnRope
4

Eu acho que a solução do DopeGhoti é melhor, mas isso também deve funcionar:

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

A primeira construção if verifica se o argumento é um caminho completo (começa com /) e define a dirvariável para o caminho do diretório até o último /. Caso contrário, se o argumento não começar com a, /mas contiver um /(especificando um subdiretório), ele será definido dircomo o diretório de trabalho atual + o caminho do subdiretório. Caso contrário, ele assume o diretório de trabalho atual. Em seguida, verifica se esse diretório é gravável.

Jesse_b
fonte
4

Que tal usar o testcomando normal como descrito abaixo?

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
fonte
Eu quase dupliquei esta resposta! Boa introdução, mas você pode mencionar man test, e isso [ ]é equivalente a teste (não é mais um conhecimento comum). Aqui é um one-liner eu estava prestes a uso na minha resposta inferior, para cobrir o caso exato, para a posteridade:if [ -w $1] && [ ! -d $1 ] ; then echo "do stuff"; fi
Ferro Gremlin
4

Você mencionou que a experiência do usuário estava direcionando sua pergunta. Vou responder de um ângulo UX, já que você tem boas respostas no lado técnico.

Em vez de executar a verificação antecipadamente, que tal gravar os resultados em um arquivo temporário e, no final, colocar os resultados no arquivo desejado do usuário? Gostar:

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

O bom dessa abordagem: ela produz a operação desejada no cenário normal de caminho feliz, evita o problema de atomicidade de testar e definir, preserva as permissões do arquivo de destino enquanto cria, se necessário, e é simples de implementar.

Nota: se tivéssemos usado mv, estaríamos mantendo as permissões do arquivo temporário - não queremos isso, penso: queremos manter as permissões definidas no arquivo de destino.

Agora, o ruim: ele requer o dobro do espaço ( cat .. >construção), força o usuário a fazer algum trabalho manual se o arquivo de destino não tiver sido gravável no momento em que precisava, e deixa o arquivo temporário por aí (o que pode ter segurança problemas de manutenção).

bispo
fonte
De fato, isso é mais ou menos o que estou fazendo agora. Escrevo a maioria dos resultados em um arquivo temporário e, no final, faço a etapa final do processamento e escrevo os resultados no arquivo final. O problema é que eu quero sair mais cedo (no início do script) se é provável que a etapa final falhe. O script pode ser executado autonomamente por minutos ou horas, então você realmente quer saber de antemão que está fadado ao fracasso!
BeeOnRope
Claro, mas há muitas maneiras de isso falhar: o disco pode ser preenchido, o diretório upstream pode ser removido, as permissões podem mudar, o arquivo de destino pode ser usado para outras coisas importantes e o usuário esqueceu que ele atribuiu o mesmo arquivo a ser destruído por esse Operação. Se falarmos sobre isso de uma perspectiva pura de UX, talvez a coisa certa a fazer seja tratá-la como uma submissão de trabalho: no final, quando você souber que funcionou corretamente até a conclusão, diga ao usuário onde o conteúdo resultante reside e oferece um comando sugerido para eles moverem eles mesmos.
bispo
Em teoria, sim, existem infinitas maneiras pelas quais isso pode falhar. Na prática, a grande maioria das vezes que isso falha ocorre porque o caminho fornecido não é válido. Não posso impedir razoavelmente que um usuário não autorizado modifique o FS simultaneamente para interromper o trabalho no meio da operação, mas certamente posso verificar a causa número um da falha de um caminho inválido ou não gravável.
BeeOnRope
2

TL; DR:

: >> "${userfile}"

Diferentemente de <> "${userfile}"ou touch "${userfile}", isso não fará modificações espúrias nos registros de data e hora do arquivo e também funcionará com arquivos somente para gravação.


Do OP:

Quero fazer uma verificação razoável se o arquivo pode ser criado / substituído, mas na verdade não o cria.

E do seu comentário à minha resposta da perspectiva do UX :

A grande maioria das vezes que isso falha ocorre porque o caminho fornecido não é válido. Não posso razoavelmente impedir que um usuário não autorizado modifique o FS simultaneamente para interromper o trabalho no meio da operação, mas certamente posso verificar a causa número um da falha de um caminho inválido ou não gravável.

O único teste confiável é open(2)o arquivo, porque somente isso resolve todas as questões sobre a capacidade de gravação: caminho, propriedade, sistema de arquivos, rede, contexto de segurança etc. Qualquer outro teste abordará parte da capacidade de gravação, mas não outras. Se você quiser um subconjunto de testes, terá que escolher o que é importante para você.

Mas aqui está outro pensamento. Pelo que entendi:

  1. o processo de criação de conteúdo é demorado e
  2. o arquivo de destino deve ser deixado em um estado consistente.

Você deseja fazer essa pré-verificação por causa do nº 1 e não deseja mexer em um arquivo existente por causa do nº 2. Então, por que você não pede ao shell para abrir o arquivo para anexar, mas na verdade não acrescenta nada?

$ 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

Sob o capô, isso resolve a questão da gravabilidade exatamente como desejado:

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

mas sem modificar o conteúdo ou os metadados do arquivo. Agora, sim, esta abordagem:

  • não informa se o arquivo é apenas anexado, o que pode ser um problema quando você atualiza o arquivo no final da criação do conteúdo. Você pode detectar isso, até certo ponto, com lsattre reagir de acordo.
  • cria um arquivo que não existia anteriormente, se for o caso: mitigue isso com uma seleção rm.

Embora eu afirme (em minha outra resposta) que a abordagem mais amigável é criar um arquivo temporário que o usuário precisa mover, acho que essa é a abordagem menos hostil ao usuário para avaliar completamente sua entrada.

bispo
fonte