Como posso verificar se um arquivo pode ser criado ou truncado / sobrescrito no bash?

14

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

O comportamento de criar ou sobrescrever é muito parecido com os requisitos para colocar o arquivo no lado direito do operador de redirecionamento > output ou passá-lo como um argumento para tee (na verdade, passá-lo como um argumento para tee é exatamente o que eu estou fazendo).

Antes de eu entrar nas entranhas do script, eu quero fazer uma verificação razoável se o arquivo puder ser criado / sobrescrito, mas na verdade não criá-lo. Essa checagem não precisa ser perfeita, e sim eu percebo que a situação pode mudar entre a checagem e o ponto onde o arquivo está realmente escrito - mas aqui eu estou bem com o tipo melhor esforço solução para que eu possa socorrer no início no caso em que o caminho do arquivo é inválido.

Exemplos de motivos pelos quais o arquivo não pôde ser criado:

  • o arquivo contém um componente de diretório, como dir/file.txt , mas o diretório dir não existe
  • o usuário não possui 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 "na frente" não é The UNIX Way ™ , mas eu deveria tentar a operação e pedir perdão mais tarde. No meu script particular, no entanto, isso leva a uma má experiência do usuário e não posso alterar o componente responsável.

    
por BeeOnRope 08.11.2018 / 23:02

7 respostas

10

O teste óbvio seria:

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

Mas, na verdade, ele cria o arquivo, se ainda não estiver lá. Nós poderíamos 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 de outro objeto nesse diretório. Não consigo pensar em uma situação na qual você possa criar um diretório, mas não criar um arquivo. Após este teste, seu script estará livre para criar um /path/to/file convencional e fazer o que lhe agrada.

    
por 08.11.2018 / 23:11
8

Pelo que estou coletando, você quer verificar isso ao usar

tee -- "$OUT_FILE"

(observe o -- ou não funcionaria para nomes de arquivos que começam com -), tee teria sucesso em abrir o arquivo para gravação.

É isso:

  • o comprimento do caminho do arquivo não excede o limite de PATH_MAX
  • o arquivo existe (depois da resolução do symlink) e não é do tipo diretório e você tem permissão de escrita para ele.
  • se o arquivo não existir, o dirname do arquivo existe (após a resolução do symlink) como um diretório e você tem permissão de gravação e pesquisa nele e o tamanho do nome do arquivo não excede o limite NAME_MAX do sistema de arquivos desse diretório reside em.
  • 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 acima

Ignoraremos por enquanto sistemas de arquivos como vfat, ntfs ou hfsplus que têm limitações sobre quais valores de bytes podem conter, cota de disco, limite de processo, selinux, apparmor ou outro mecanismo de segurança no caminho, sistema de arquivos completo, sem inode à esquerda, arquivos de dispositivo 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 de processo, e que 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 bash ou qualquer shell parecido com o 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 recursos zsh -específicos aqui são $ERRNO (que expõe o código de erro da última chamada de sistema) e $errnos[] matriz associativa para traduzir para os 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:h pode 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 executar ls -Ld no 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 ENOENT e que essa tradução não inclua : ) e, em vez de [ -e "$file" ] , faça :

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

E verifique se há um erro ENOENT com

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

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

O FreeBSD tem um comando realpath e um comando readlink que aceita uma opção -f , mas eles não podem ser usados nos casos em que o arquivo é um link simbólico que não resolve. É o mesmo com perl ' Cwd::realpath() .

python os.path.realpath() parece funcionar de forma semelhante a zsh $file:P , portanto, supondo que pelo menos uma versão de python esteja instalada e que exista um comando python que se refere a um deles (o que não é um dado no FreeBSD), você poderia fazer:

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

Mas você pode fazer tudo em python .

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

    
por 09.11.2018 / 00:44
6

Uma opção que você pode considerar é criar o arquivo no início, mas só o preenche mais tarde no seu script. Você pode usar o comando exec para abrir o arquivo em um descritor de arquivo (como 3, 4, etc.) e depois usar um redirecionamento para um descritor de arquivo ( >&3 , etc.) para gravar o conteúdo nesse 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 um trap para limpar o arquivo por erro, o que é uma prática comum.

Dessa forma, você obtém uma verificação real de permissões que você poderá criar o arquivo, ao mesmo tempo em que será capaz de executá-lo com antecedência suficiente, se isso falhar, Não passei tempo esperando as verificações longas.

ATUALIZADO: Para evitar que o arquivo seja danificado, caso as verificações falhem, use fd<>file redirection que não trunca o arquivo imediatamente. (Não nos importamos com a leitura do arquivo, isso é apenas uma solução para não truncá-lo. Acrescentar com >> provavelmente só funcionaria também, mas eu costumo achar este um pouco mais elegante, mantendo o O_APPEND sinaliza fora da imagem.)

Quando estivermos prontos para substituir o conteúdo, precisamos truncar o arquivo primeiro (caso contrário, estaríamos escrevendo menos bytes do que havia no arquivo antes, os bytes finais ficariam lá.) Podemos usar o < um comando href="https://linux.die.net/man/1/truncate"> truncate (1) do coreutils para essa finalidade, e podemos passar o descritor de arquivo aberto que temos (usando o comando /dev/fd/3 pseudo-file) para que não seja necessário reabrir o arquivo. (Novamente, tecnicamente, algo mais simples como : >dir/file.txt provavelmente funcionaria, mas não ter que reabrir o arquivo é uma solução mais elegante.)

    
por 08.11.2018 / 23:30
4

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 se a construção verifica se o argumento é um caminho completo (começa com / ) e define a variável dir para o caminho do diretório até o último / . Caso contrário, se o argumento não iniciar com / , mas conter / (especificando um subdiretório), ele definirá dir como o diretório de trabalho atual + o caminho do subdiretório. Caso contrário, assume o diretório de trabalho atual. Em seguida, ele verifica se esse diretório é gravável.

    
por 08.11.2018 / 23:19
4

Que tal usar o comando normal test 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
    
por 09.11.2018 / 01:11
4

Você mencionou a experiência do usuário que estava gerando 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 escrever os resultados em um arquivo temporário e, no final, colocar os resultados no arquivo desejado do usuário? Como:

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 com esta abordagem: produz a operação desejada no cenário de caminho feliz normal, acompanha o problema de atomicidade de teste e conjunto, 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 como definidas no arquivo de destino.

Agora, o ruim: ele requer o dobro do espaço ( cat .. > construct), obriga o usuário a fazer algum trabalho manual se o arquivo de destino não for gravável no momento necessário e deixa o arquivo temporário ao redor (o que pode ter problemas de segurança ou manutenção).

    
por 09.11.2018 / 17:00
2

TL; DR:

: >> "${userfile}"

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

Do OP:

I want to make a reasonable check if the file can be created/overwritten, but not actually create it.

E do seu comentário para minha resposta de uma perspectiva de UX :

The overwhelming majority of the time this fails is because the path provided is not valid. I can't reasonably prevent some rogue user some concurrently modifying the FS to break the job in the middle of the operation, but I certainly can check the #1 failure cause of an invalid or not writable path.

O único teste confiável é o open(2) arquivo, porque apenas esse 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á alguma parte da capacidade de escrita, 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 é de longa duração e
  2. o arquivo de destino deve ser deixado em um estado consistente.

Você está querendo fazer essa pré-verificação por causa do número 1 e não quer mexer em um arquivo existente por causa do número 2. Então, por que você simplesmente não pede ao shell para abrir o arquivo para acrescentar, 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 capacidade de gravação 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, essa 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 lsattr e reagir de acordo.
  • cria um arquivo que não existia anteriormente, se for o caso: atenua isso com um rm seletivo.

Embora eu afirme (na minha outra resposta) que a abordagem mais fácil de usar é criar um arquivo temporário que o usuário tenha que mover, acho que esse é menos hostil ao usuário abordagem para avaliar completamente sua entrada.

    
por 09.11.2018 / 20:31