Como escapar dos metacaracteres do shell automaticamente com o comando `find`?

Como escapar dos metacaracteres do shell automaticamente com o comando `find`?

Eu tenho vários arquivos XML em uma árvore de diretórios que gostaria de mover para as pastas correspondentes com o mesmo nome na mesma árvore de diretórios.

Aqui está um exemplo de estrutura (em 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 entre colchetes ( [ foo ].xml) não foi movido como se tivesse sido ignorado.

Já verifiquei e basename(ex. basename "[ foo ].xml" ".xml") converte o arquivo corretamente, porém findtem problemas com colchetes. Por exemplo:

find . -name '[ foo ].xml'

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

Existe alguma maneira universal de escapar dos nomes de arquivos ao usar o parâmetro with find, -namepara que eu possa corrigir meu comando para suportar arquivos com os metacaracteres?

Responder1

É muito mais fácil com zshglobs aqui:

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

Ou se você quiser incluir arquivos xml ocultos e procurar dentro de diretórios ocultos, como findfaria:

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

Mas cuidado, pois os arquivos chamados .xml, ..xmlou ...xmlpodem se tornar um problema, então você pode 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 em busca de cada arquivo seria varrê-la uma vez e procurar todos os diretórios e xmlarquivos, 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\0' -o \
  -type d -printf 'D/%P\0' | awk -v RS='\0' -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\0%s\0", F[f], D[f]
  }' | xargs -r0n2 mv -v --
)

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

  • incorporar {}no código shell ésempreerrado. E se houver um arquivo chamado, $(rm -rf "$HOME").xmlpor exemplo? A maneira correta é passá-los {}como argumento para o shell script in-line ( -exec sh -c 'use as "$1"...' sh {} \;).
  • Com GNU find(implícito aqui como você está usando -quit), *.xmlcorresponderia apenas a arquivos que consistem em uma sequência de caracteres válidos seguidos por .xml, de modo que exclui nomes de arquivos que contêm caracteres inválidos no código do idioma atual (por exemplo, nomes de arquivos no conjunto de caracteres errado). A solução para isso é corrigir o local onde Ccada byte é um caractere válido (isso significa que mensagens de erro serão exibidas em inglês).
  • Se algum desses xmlarquivos for do tipo diretório ou link simbólico, isso causaria problemas (afetaria a verificação de diretórios ou quebraria links simbólicos quando movido). Você pode adicionar um -type fpara mover apenas arquivos regulares.
  • Tiras de substituição de comando ( $(...))todoscaracteres de nova linha à direita. Isso causaria problemas com um arquivo chamado, foo␤.xmlpor exemplo. Contornar isso é possível, mas é uma dor: base=$(basename "$1" .xml; echo .); base=${base%??}. Você pode pelo menos substituir basenamepelos ${var#pattern}operadores. 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 ( ) feita pela qual é muito semelhante à correspondência de padrões de shell). Você precisaria escapar deles com uma barra invertida.[*fnmatch()find
  • o problema com .xml, ..xml, ...xmlmencionado acima.

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

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 {} +

Ufa...

Agora, não é tudo. Com -exec ... {} +, executamos o mínimo shpossível. Se tivermos sorte, executaremos apenas um, mas se não, após a primeira shinvocação, teremos movido vários xmlarquivos e findcontinuaremos procurando por mais, e poderemos muito bem encontrar os arquivos que temos movidos na primeira rodada novamente (e provavelmente tentarão movê-los para onde estão).

Fora isso, é basicamente a mesma abordagem dos zsh. Algumas outras diferenças notáveis:

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

Uma última nota de advertência. Se o motivo de você obter alguns arquivos com nomes duvidosos é porque a árvore de diretórios pode ser gravada por um adversário, tome cuidado, pois nenhuma das soluções acima é segura se o adversário puder renomear arquivos sob esse comando.

Por exemplo, se você estiver usando o LXDE, o invasor pode criar um arquivo malicioso foo/lxde-rc.xml, criar uma lxde-rcpasta, detectar quando você está executando seu comando e substituí-lo lxde-rcpor um link simbólico para sua ~/.config/openbox/janela durante a corrida (que pode ser tão grande quanto necessário de várias maneiras) entre findencontrar isso lxde-rce mvfazer o rename("foo/lxde-rc.xml", "lxde-rc/lxde-rc.xml")( footambém pode ser alterado para aquele link simbólico, fazendo você mudar lxde-rc.xmlpara outro lugar).

Provavelmente é impossível contornar isso usando utilitários padrão ou mesmo GNU, você precisaria escrevê-lo em uma linguagem de programação adequada, fazendo uma travessia segura de diretórios e usando renameat()chamadas de sistema.

Todas as soluções acima também falharão se a árvore de diretórios for profunda o suficiente para que o limite no comprimento dos caminhos fornecidos para a rename()chamada do sistema feita por mvseja atingido (causando rename()falha com ENAMETOOLONG). Uma solução usando renameat()também resolveria o problema.

Responder2

Ao usar o script embutido com find ... -exec sh -c ..., você deve passar findo resultado para o shell por meio do parâmetro posicional, então não precisa usar em {}todos os lugares do seu script embutido.

Se você tiver bashou zsh, poderá passar basenamea 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 quiser que funcione corretamente, você precisa escrever uma função shell para escapar apenas de [, *e ?barra invertida.

Responder3

As boas notícias:

find . -name '[ foo ].xml'

não é interpretado pelo shell, é passado desta forma para o programa find. No entanto, Find interpreta o argumento -namecomo um globpadrão e isso precisa ser levado em consideração.

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

Se você gosta de processar a findsaída pelo shell, recomendo apenas desabilitar o globbing do nome do arquivo no shell chamando set -fantes do código em questão e ativá-lo novamente chamando set +fmais tarde.

Responder4

A seguir está um pipeline relativamente simples e compatível com POSIX. Ele verifica a hierarquia duas vezes, primeiro em busca de diretórios e depois em busca de arquivos regulares *.xml. Uma linha em branco entre as varreduras sinaliza 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, apenas a primeira travessia será lembrada). Para cada arquivo *.xml, ele imprime uma linha delimitada por tabulações com dois campos: 1) o caminho do arquivo e 2) seu 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 logo 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

informação relacionada