Como posso proteger os scripts do bash para causar danos quando alterados no futuro?

46

Portanto, excluí minha pasta pessoal (ou, mais precisamente, todos os arquivos aos quais tive acesso de gravação). O que aconteceu é que eu tinha

build="build"
...
rm -rf "${build}/"*
...
<do other things with $build>

em um script bash e, depois de não precisar mais $build, remover a declaração e todos os seus usos - mas o rm. Bash se expande alegremente para rm -rf /*. Sim.

Eu me senti idiota, instalei o backup, refiz o trabalho que perdi. Tentando superar a vergonha.

Agora, eu me pergunto: quais são as técnicas para escrever scripts bash para que tais erros não ocorram ou sejam pelo menos menos prováveis? Por exemplo, se eu tivesse escrito

FileUtils.rm_rf("#{build}/*")

em um script Ruby, o intérprete teria reclamado por buildnão ter sido declarado, então a linguagem me protege.

O que eu considerei no bash, além de encurralar rm(que, como muitas respostas em perguntas relacionadas mencionam, não é problemático):

  1. rm -rf "./${build}/"*
    Isso teria matado meu trabalho atual (um repositório Git), mas nada mais.
  2. Uma variante / parametrização rmque requer interação ao atuar fora do diretório atual. (Não foi possível encontrar nenhum.) Efeito semelhante.

É isso ou existem outras maneiras de escrever scripts bash que são "robustos" nesse sentido?

Rafael
fonte
3
Não há razão para rm -rf "${build}/*não importa para onde vão as aspas. rm -rf "${build} fará a mesma coisa por causa do f.
Monty Harder
1
A verificação estática com o shellcheck.net é um lugar muito sólido para começar. Há integração de editor disponível, para que você possa receber um aviso de suas ferramentas assim que remover a definição de algo que ainda é usado.
Charles Duffy
@CharlesDuffy para adicionar a minha vergonha, eu escrevi esse script em um IDE de estilo IDEA com BashSupport instalado, que não advertir nesse caso. Então, sim, ponto válido, mas eu realmente precisava de um cancelamento forçado.
Raphael
2
Peguei vocês. Observe as advertências no BashFAQ # 112 . set -unão é tão desaprovado quanto set -eé, mas ainda tem suas desvantagens.
Charles Duffy
2
resposta da piada: Basta colocar #! /usr/bin/env rubyno topo de todos os scripts de shell e esquecer o bash;) #
1212 Pod Pod

Respostas:

75
set -u

ou

set -o nounset

Isso faria o shell atual tratar expansões de variáveis ​​não definidas como um erro:

$ unset build
$ set -u
$ rm -rf "$build"/*
bash: build: unbound variable

set -ue set -o nounsetsão opções de shell POSIX .

Um valor vazio não acionaria um erro.

Para isso, use

$ rm -rf "${build:?Error, variable is empty or unset}"/*
bash: build: Error, variable is empty or unset

A expansão de ${variable:?word}seria expandida para o valor de, a variablemenos que esteja vazio ou não esteja definido . Se estiver vazio ou não definido, o worderro será exibido no erro padrão e o shell tratará a expansão como um erro (o comando não será executado e, se estiver executando em um shell não interativo, isso será encerrado). Deixar de :fora acionaria o erro apenas para um valor não definido, assim como abaixo set -u.

${variable:?word}é uma expansão de parâmetro POSIX .

Nenhum deles causaria o término de um shell interativo, a menos que set -e(ou set -o errexit) também estivesse em vigor. ${variable:?word}faz com que os scripts saiam se a variável estiver vazia ou não definida. set -ucausaria a saída de um script se usado junto com set -e.


Quanto à sua segunda pergunta. Não há como limitar rma não trabalhar fora do diretório atual.

A implementação do GNU rmtem uma --one-file-systemopção que impede a exclusão recursiva de sistemas de arquivos montados, mas é o mais próximo que acredito que podemos chegar sem envolver a rmchamada em uma função que realmente verifica os argumentos.


Como uma nota lateral: ${build}é exatamente equivalente a $buildmenos que a expansão ocorre como parte de uma cadeia onde o caractere imediatamente seguinte é um caractere válido em um nome de variável, como em "${build}x".

Kusalananda
fonte
Muito bem, obrigado! 1) É ${build:?msg}ou ${build?msg}? 2) No contexto de algo como scripts de construção, acho que seria bom usar uma ferramenta diferente da rm para excluir com segurança: sabemos que não queremos trabalhar fora do diretório atual, por isso, tornamos explícito usando um método restrito comando. Não há necessidade de tornar a empresa mais segura em geral.
Raphael
1
@ Rafael Desculpe, deve estar com:. Corrigirei esse erro de digitação agora e mencionarei seu significado mais tarde quando voltar ao computador.
Kusalananda
2
@ Rafael Ok, eu adicionei uma frase curta sobre o que acontece sem o :(isso acionaria o erro apenas para variáveis não definidas ). Não ouso tentar escrever uma ferramenta que lide com a exclusão de arquivos sob um caminho específico, exclusivamente, em todas as circunstâncias. Analisar caminhos e cuidar / não se importar com links simbólicos etc. é um pouco complicado para mim no momento.
Kusalananda
1
Obrigado, a explicação ajuda! Em relação à "empresa local": eu estava pensando, talvez procurando um "certo, esse é o <nome do arquivo>" - mas certamente não estou tentando ajudar você a escrever isso! OO Tudo bem, você já ajudou o suficiente! :)
Raphael
2
@MateuszKonieczny Acho que não, desculpe. Acredito que medidas de segurança como essas devem ser usadas quando necessário . Como tudo o que torna um ambiente seguro, acabará tornando as pessoas cada vez mais descuidadas e dependentes das medidas de segurança. É melhor saber o que cada uma das medidas de segurança faz e aplicá-las seletivamente, conforme necessário.
Kusalananda
12

Vou sugerir verificações normais de validação usando test/[ ]

Você estaria seguro se tivesse escrito seu script como tal:

build="build"
...
[ -n "${build}" ] || exit 1
rm -rf "${build}/"*
...

As [ -n "${build}" ]verificações que "${build}"são uma sequência de comprimento diferente de zero .

O ||é o operador OR lógico no bash. Isso faz com que outro comando seja executado se o primeiro falhar.

Desta forma, ${build}estava vazio / indefinido / etc. o script teria saído (com um código de retorno 1, que é um erro genérico).

Isso também o protegeria caso você removesse todos os usos, ${build}porque [ -n "" ]sempre será falso.


A vantagem de usar test/ [ ]é que existem muitas outras verificações mais significativas que também podem ser usadas.

Por exemplo:

[ -f FILE ] True if FILE exists and is a regular file.
[ -d FILE ] True if FILE exists and is a directory.
[ -O FILE ] True if FILE exists and is owned by the effective user ID.
Centimane
fonte
Justo, mas um pouco pesado. Estou faltando alguma coisa ou ela faz o mesmo que o ${variable:?word}@Kusalananda propõe?
Raphael
@Raphael funciona da mesma forma no caso de "a string tem um valor", mas test(por exemplo [) possui muitas outras verificações relevantes, como -d(o argumento é um diretório), -f(o argumento é um arquivo), -O(o arquivo pertence ao usuário atual) e assim por diante.
Centimane 11/07/2018
1
@ Rafael também, a validação deve ser uma parte normal de qualquer código / script. Observe também que, se você removeu todas as instâncias da compilação, também não a removeu ${build:?word}? O ${variable:?word}formato não o protege se você remover todas as instâncias da variável.
Centimane 11/07/2018
1
1) Eu acho que há uma diferença entre garantir que as suposições sejam mantidas (existem arquivos, etc.) e verificar se as variáveis ​​estão definidas (imho trabalho de um compilador / intérprete). Se eu tiver que fazer o último manualmente, é melhor haver uma sintaxe ultra-sofisticada para ele - o que um completo ifnão é. 2) "você também não removeu $ {build:? Word} junto com ele" - o cenário é que perdi o uso da variável. Usar ${v:?w}teria me protegido de danos. Se eu tivesse removido todos os usos, mesmo o acesso simples não teria sido prejudicial, obviamente.
Raphael
Dito isso, sua resposta é uma resposta justa e inteligente à pergunta geral do titular: garantir que as suposições sejam válidas é importante para os scripts que permanecem por perto. A questão específica no corpo da pergunta é, melhor, respondida por Kusalananda. Obrigado!
Raphael
4

No seu caso específico, reformulei a 'exclusão' no passado para mover arquivos / diretórios (supondo que / tmp esteja na mesma partição que o seu diretório):

# mktemp -d is also a good, reliable choice
trashdir=/tmp/.trash-$USER/trash-`date`
mkdir -p "$trashdir"
...
mv "${build}"/* "$trashdir"
...

Nos bastidores, isso move as referências de arquivo / diretório de nível superior da origem para as $trashdirestruturas de diretório de destino na mesma partição e não gasta tempo percorrendo a estrutura de diretórios e liberando os blocos de disco por arquivo naquele momento. Isso produz uma limpeza muito mais rápida enquanto o sistema está em uso ativo, em troca de uma reinicialização um pouco mais lenta (/ tmp é limpo nas reinicializações).

Como alternativa, uma entrada cron para limpar periodicamente /tmp/.trash-$USER manterá / tmp preenchido, para processos (por exemplo, compilações) que consomem muito espaço em disco. Se seu diretório estiver em uma partição diferente como / tmp, você poderá criar um diretório semelhante a / tmp na partição e fazer com que o cron limpe isso.

Mais importante, porém, se você estragar as variáveis ​​de alguma forma, poderá recuperar o conteúdo antes que a limpeza ocorra.

user117529
fonte
2
"Isso produz uma limpeza muito mais rápida enquanto o sistema está em uso ativo" - produz? Eu teria pensado que ambos são sobre alterar apenas os inodes afetados. Certamente não é mais rápido se /tmpestiver em outra partição (acho que sempre é para mim, pelo menos para scripts executados no espaço do usuário); então, a pasta da lixeira precisa ser alterada (e não se beneficiará da manipulação do SO /tmp).
Raphael
1
Você pode usar mktemp -dpara obter esse diretório temporário sem pisar nos dedos de outro processo (e honrar corretamente $TMPDIR).
Toby Speight
Adicionado suas anotações precisas e úteis de vocês dois, obrigado. Acho que originalmente postei isso muito rapidamente, sem considerar os pontos que você levantou.
user117529
2

Use a substituição do bash paramater para atribuir padrões quando as variáveis ​​não forem inicializadas, por exemplo:

rm -rf $ {variable: - "/ inexistente"}

rackandboneman
fonte
1

Eu sempre tento iniciar meus scripts Bash com uma linha #!/bin/bash -ue.

-esignifica "falhar em primeiro uncathed e rror";

-usignifica "falhar em primeiro uso de u ndeclared variável".

Encontre mais detalhes no excelente artigo Use o modo restrito não oficial do Bash (a menos que você perca a depuração) . O autor também recomenda o uso, set -o pipefail; IFS=$'\n\t'mas para meus propósitos isso é um exagero.

niya3
fonte
1

O conselho geral para verificar se sua variável está definida é uma ferramenta útil para evitar esse tipo de problema. Mas, neste caso, havia uma solução mais simples.

Provavelmente, não há necessidade de exibir o conteúdo do $builddiretório para excluí-lo, mas não o próprio $builddiretório. Portanto, se você pular o estranho *, um valor não definido se tornará o rm -rf /que, por padrão, a maioria das implementações rm na última década se recusará a executar (a menos que você desative essa proteção com a --no-preserve-rootopção do GNU rm ).

Ignorar a trilha /também resultaria na rm ''mensagem de erro:

rm: can't remove '': No such file or directory

Isso funciona mesmo que seu comando rm não implemente proteção para /.

eschwartz
fonte
-2

Você está pensando em termos de linguagem de programação, mas bash é uma linguagem de script :-) Portanto, use um paradigma de instrução totalmente diferente para um paradigma de linguagem totalmente diferente .

Nesse caso:

rmdir ${build}

Como rmdirse recusará a remover um diretório não vazio, você deverá remover os membros primeiro. Você sabe o que são esses membros, certo? Provavelmente são parâmetros para o seu script ou derivados de um parâmetro, portanto:

rm -rf ${build}/${parameter}
rmdir ${build}

Agora, se você colocar alguns outros arquivos ou diretórios, como um arquivo temporário ou algo que não deveria, o rmdirerro será gerado . Manuseie-o corretamente e:

rmdir ${build} || build_dir_not_empty "${build}"

Esse modo de pensar me serviu bem porque ... sim, esteve lá, fez isso.

Rico
fonte
6
Obrigado pelo seu esforço, mas isso é completamente errado. A suposição "você sabe o que são esses membros" está incorreta; pense na saída do compilador. make cleanusará alguns curingas (a menos que um compilador muito diligente crie uma lista de todos os arquivos que cria). Além disso, parece-me que ter rm -rf ${build}/${parameter}apenas movido a questão um pouco para baixo.
Raphael
Hum. Veja como está escrito. "Obrigado, mas isso é por algo que não revelei na pergunta original, então não apenas rejeitarei sua resposta, mas também a diminuirei, apesar de ser mais aplicável ao caso geral". Uau.
1750 Rich
"Deixe-me fazer suposições adicionais além do que você escreveu na pergunta e depois escrever uma resposta especializada." * shrug * (FYI, não que isso seja da sua empresa: Eu não downvote Provavelmente eu deveria ter, porque a resposta não foi útil (para mim, pelo menos)..)
Raphael
Hum. Não fiz suposições e escrevi uma resposta geral . Não há nada na makequestão. Escrever uma resposta aplicável apenas makeseria uma suposição muito específica e surpreendente. Minha resposta funciona para outros casos além do makedesenvolvimento de software, além do problema específico para iniciantes que você estava tendo, excluindo suas coisas.
Ricos
1
Kusalananda me ensinou a pescar. Você veio e disse: "Como você mora nas planícies, por que não come carne de vaca?"
Raphael