Como evitar metacaracteres de shell automaticamente com o comando 'find'?

4

Eu tenho um monte de arquivos XML em uma árvore de diretórios que eu gostaria de mover para pastas correspondentes com o mesmo nome dentro da mesma árvore de diretórios.

Aqui está a estrutura de amostra (no shell):

touch foo.xml bar.xml "[ foo ].xml" "( bar ).xml"
mkdir -p foo bar "foo/[ foo ]" "bar/( bar )"

Então, minha abordagem aqui é:

find . -name "*.xml" -exec sh -c '
  DST=$(
    find . -type d -name "$(basename "{}" .xml)" -print -quit
  )
  [ -d "$DST" ] && mv -v "{}" "$DST/"' ';'

que fornece a seguinte saída:

‘./( bar ).xml’ -> ‘./bar/( bar )/( bar ).xml’
mv: ‘./bar/( bar )/( bar ).xml’ and ‘./bar/( bar )/( bar ).xml’ are the same file
‘./bar.xml’ -> ‘./bar/bar.xml’
‘./foo.xml’ -> ‘./foo/foo.xml’

Mas o arquivo com colchetes ( [ foo ].xml ) não foi movido como se tivesse sido ignorado.

Eu verifiquei e basename (por exemplo, basename "[ foo ].xml" ".xml" ) converte o arquivo corretamente, mas find tem problemas com colchetes. Por exemplo:

find . -name '[ foo ].xml'

não encontrará o arquivo corretamente. No entanto, ao escapar dos colchetes ( '\[ foo \].xml' ), ele funciona bem, mas não resolve o problema, porque faz parte do script e não sei quais arquivos têm esses caracteres especiais (shell?). Testado com BSD e GNU find .

Existe alguma maneira universal de escapar os nomes de arquivos quando usando o parâmetro find -name , para que eu possa corrigir meu comando para suportar arquivos com os metacaracteres?

    
por kenorb 13.02.2016 / 16:30

4 respostas

7

É muito mais fácil com zsh globs aqui:

for f (**/*.xml(.)) (mv -v -- $f **/$f:r:t(/[1]))

Ou se você quiser incluir arquivos xml ocultos e pesquisar em diretórios ocultos, como find :

for f (**/*.xml(.D)) (mv -v -- $f **/$f:r:t(D/[1]))

Mas tenha cuidado com o facto de os ficheiros chamados .xml , ..xml ou ...xml se tornarem um problema, pelo que poderá querer excluí-los:

setopt extendedglob
for f (**/(^(|.|..)).xml(.D)) (mv -v -- $f **/$f:r:t(D/[1]))

Com as ferramentas GNU, outra abordagem para evitar a varredura de toda a árvore de diretórios para cada arquivo seria varrê-lo uma vez e procurar todos os diretórios e xml arquivos, registrar onde eles estão e fazer a movimentação no final:

(export LC_ALL=C
find . -mindepth 1 -name '*.xml' ! -name .xml ! \
  -name ..xml ! -name ...xml -type f -printf 'F/%P
LC_ALL=C find . -type f -name '*.xml' ! -name .xml ! -name ..xml \
  ! -name ...xml -exec sh -c '
  for file do
    base=${file##*/}
    base=${base%.xml}
    escaped_base=$(printf "%s\n" "$base" |
      sed "s/[[*?\\]/\\&/g"; echo .)
    escaped_base=${escaped_base%??}
    find . -name "$escaped_base" -type d -exec mv -v "$file" {\} \; -quit
  done' sh {} +
' -o \ -type d -printf 'D/%P
for f (**/*.xml(.)) (mv -v -- $f **/$f:r:t(/[1]))
' | awk -v RS='
for f (**/*.xml(.D)) (mv -v -- $f **/$f:r:t(D/[1]))
' -F / ' { if ($1 == "F") { root = $NF sub(/\.xml$/, "", root) F[root] = substr($0, 3) } else D[$NF] = substr($0, 3) } END { for (f in F) if (f in D) printf "%s
setopt extendedglob
for f (**/(^(|.|..)).xml(.D)) (mv -v -- $f **/$f:r:t(D/[1]))
%s
(export LC_ALL=C
find . -mindepth 1 -name '*.xml' ! -name .xml ! \
  -name ..xml ! -name ...xml -type f -printf 'F/%P
LC_ALL=C find . -type f -name '*.xml' ! -name .xml ! -name ..xml \
  ! -name ...xml -exec sh -c '
  for file do
    base=${file##*/}
    base=${base%.xml}
    escaped_base=$(printf "%s\n" "$base" |
      sed "s/[[*?\\]/\\&/g"; echo .)
    escaped_base=${escaped_base%??}
    find . -name "$escaped_base" -type d -exec mv -v "$file" {\} \; -quit
  done' sh {} +
' -o \ -type d -printf 'D/%P%pre%' | awk -v RS='%pre%' -F / ' { if ($1 == "F") { root = $NF sub(/\.xml$/, "", root) F[root] = substr($0, 3) } else D[$NF] = substr($0, 3) } END { for (f in F) if (f in D) printf "%s%pre%%s%pre%", F[f], D[f] }' | xargs -r0n2 mv -v -- )
", F[f], D[f] }' | xargs -r0n2 mv -v -- )

Sua abordagem tem vários problemas se você quiser permitir qualquer nome de arquivo arbitrário:

  • a incorporação de {} no código da shell está sempre errada. E se houver um arquivo chamado $(rm -rf "$HOME").xml , por exemplo? A maneira correta é passar o argumento {} as para o script de shell in-line ( -exec sh -c 'use as "$1"...' sh {} \; ).
  • Com o find do GNU (sugerido aqui porque você está usando -quit ), *.xml corresponderia apenas aos arquivos que consistem em uma sequência de caracteres válidos seguidos por .xml , excluindo os nomes de arquivos que contêm caracteres inválidos no local atual (por exemplo, nomes de arquivos no charset errado). A correção para isso é para fixar a localidade em C , onde cada byte é um caractere válido (isso significa que as mensagens de erro serão exibidas em inglês).
  • Se algum desses arquivos xml for do tipo diretório ou link simbólico, isso causaria problemas (afeta a verificação de diretórios ou quebra os links simbólicos quando movido). Você pode querer adicionar um -type f para mover apenas arquivos regulares.
  • A substituição de comandos ( $(...) ) retira todos caracteres de nova linha à direita. Isso causaria problemas com um arquivo chamado foo␤.xml , por exemplo. Trabalhar em torno disso é possível, mas é uma dor: base=$(basename "$1" .xml; echo .); base=${base%??} . Você pode pelo menos substituir basename pelos operadores ${var#pattern} . E evite a substituição de comandos, se possível.
  • seu problema com nomes de arquivos contendo caracteres curinga ( ? , [ , * e barra invertida; eles não são especiais para o shell, mas para a correspondência de padrões ( fnmatch() ) feita por find que por acaso é muito semelhante à correspondência de padrões de shell). Você precisaria escapar deles com uma barra invertida.
  • o problema com .xml , ..xml , ...xml mencionado acima.

Então, se abordarmos todos os itens acima, acabamos com algo como:

%pre%

Ufa ...

Agora, não é tudo. Com -exec ... {} + , executamos o mínimo de sh possível. Se tivermos sorte, executaremos apenas um, mas, se não, após a primeira invocação de sh , teremos movido vários arquivos xml e, em seguida, find continuaremos procurando por mais, e pode muito bem encontrar novamente os arquivos que movemos no primeiro turno (e provavelmente tentar movê-los para onde eles estiverem).

Além disso, é basicamente a mesma abordagem que os zshs. Algumas outras diferenças notáveis:

  • com o zsh one, a lista de arquivos é classificada (por nome de diretório e nome de arquivo), portanto, o diretório de destino é mais ou menos consistente e previsível. Com find , é baseado na ordem bruta de arquivos nos diretórios.
  • com zsh , você receberá uma mensagem de erro se nenhum diretório correspondente para mover o arquivo for encontrado, não com a abordagem find acima.
  • Com find , você receberá mensagens de erro se alguns diretórios não puderem ser percorridos, não com zsh one.

Uma última nota de aviso. Se o motivo pelo qual você obtém alguns arquivos com nomes de arquivos desonestos é porque a árvore de diretórios pode ser gravada por um adversário, então fique atento do que nenhuma das soluções acima seja segura se o adversário puder renomear arquivos sob os pés daquele comando.

Por exemplo, se você estiver usando o LXDE, o invasor pode criar um foo/lxde-rc.xml mal-intencionado, criar uma pasta lxde-rc , detectar quando você estiver executando o comando e substituir esse lxde-rc por um link simbólico para sua ~/.config/openbox/ durante a janela de corrida (que pode ser feita do tamanho necessário conforme necessário) entre find e encontrar lxde-rc e mv fazendo o rename("foo/lxde-rc.xml", "lxde-rc/lxde-rc.xml") ( foo também pode ser alterado para esse link simbólico mova seu lxde-rc.xml em outro lugar).

Trabalhando em torno disso é provavelmente impossível usando utilitários padrão ou mesmo GNU, você precisaria escrevê-lo em uma linguagem de programação apropriada, fazendo uma travessia de diretórios segura e usando renameat() chamadas do sistema.

Todas as soluções acima também falharão se a árvore de diretórios for profunda o suficiente para que o limite do comprimento dos caminhos dados à chamada do sistema rename() feita por mv seja atingido (fazendo com que rename() falhe com% código%). Uma solução usando ENAMETOOLONG também resolveria o problema.

    
por 13.02.2016 / 19:35
3

Quando você usa o script in-line com find ... -exec sh -c ... , deve passar o resultado de find para o shell por meio do parâmetro posicional, então não é necessário usar {} em todos os lugares no script in-line.

Se você tiver bash ou zsh , poderá transmitir basename de saída por printf '%q' :

find . -name "*.xml" -exec bash -c '
  for f do
    BASENAME="$(printf "%q" "$(basename -- "$f" .xml)")"
    DST=$(find . -type d -name "$BASENAME" -print -quit)
    [ -d "$DST" ] && mv -v -- "$f" "$DST/"
  done
' bash {} +

Com bash , você pode usar printf -v BASENAME e essa abordagem não funcionará corretamente se o nome do arquivo contiver caracteres de controle ou caracteres não-ascii.

Se você quiser que ele funcione corretamente, será necessário escrever uma função de shell para escapar apenas de [ , * , ? e backslash.

    
por 13.02.2016 / 17:42
2

A boa notícia:

find . -name '[ foo ].xml'

não é interpretado pelo shell, ele é passado dessa maneira para o programa find. No entanto, localizar interpreta o argumento para -name como um padrão glob e isso precisa ser levado em conta.

Se você gosta de chamar find -exec \; ou melhor find -exec + , não há shell envolvido.

Se você quiser processar a saída find pelo shell, recomendo desabilitar o globbing do nome do arquivo no shell chamando set -f antes do código em questão e ligá-lo novamente chamando set +f mais tarde.

    
por 13.02.2016 / 17:34
2

O seguinte é um pipeline compatível com POSIX, relativamente simples. Ele verifica a hierarquia duas vezes, primeiro para diretórios e depois para arquivos regulares * .xml. Uma linha em branco entre as digitalizações sinaliza o AWK da transição.

O componente AWK mapeia nomes de base para diretórios de destino (se houver vários diretórios com o mesmo nome de base, somente o primeiro percurso será lembrado). Para cada arquivo * .xml, ele imprime uma linha delimitada por tabulações com dois campos: 1) o caminho do arquivo e 2) o diretório de destino correspondente.

{
    find . -type d
    echo
    find . -type f -name \*.xml
} |
awk -F/ '
    !NF { ++i; next }
    !i && !($NF".xml" in d) { d[$NF".xml"] = $0 }
    i { print $0 "\t" d[$NF] }
' |
while IFS='     ' read -r f d; do
    mv -- "$f" "$d"
done

O valor atribuído ao IFS pouco antes da leitura é um caractere de tabulação literal, não um espaço.

Aqui está uma transcrição usando o esqueleto touch / mkdir da pergunta original:

$ touch foo.xml bar.xml "[ foo ].xml" "( bar ).xml"
$ mkdir -p foo bar "foo/[ foo ]" "bar/( bar )"
$ find .
.
./foo
./foo/[ foo ]
./bar.xml
./foo.xml
./bar
./bar/( bar )
./[ foo ].xml
./( bar ).xml
$ ../mv-xml.sh
$ find .
.
./foo
./foo/[ foo ]
./foo/[ foo ]/[ foo ].xml
./foo/foo.xml
./bar
./bar/( bar )
./bar/( bar )/( bar ).xml
./bar/bar.xml
    
por 13.02.2016 / 23:53