Come posso verificare se un file può essere creato o troncato / sovrascritto in bash?


15

L'utente chiama il mio script con un percorso file che verrà creato o sovrascritto ad un certo punto dello script, come foo.sh file.txto foo.sh dir/file.txt.

Il comportamento di creazione o sovrascrittura è molto simile ai requisiti per mettere il file sul lato destro >dell'operatore di reindirizzamento di output o passarlo come argomento a tee(in effetti, passarlo come argomento a teeè esattamente quello che sto facendo ).

Prima di entrare nelle viscere dello script, voglio fare un controllo ragionevole se il file può essere creato / sovrascritto, ma in realtà non lo crea. Questo controllo non deve essere perfetto, e sì, mi rendo conto che la situazione può cambiare tra il controllo e il punto in cui il file è effettivamente scritto - ma qui sto bene con una soluzione del tipo di sforzo migliore in modo da poter salvare in anticipo nel caso in cui il percorso del file non sia valido.

Esempi di motivi per cui il file non è stato creato:

  • il file contiene un componente di directory, come dir/file.txtma la directory dirnon esiste
  • l'utente non dispone delle autorizzazioni di scrittura nella directory specificata (o nel CWD se non è stata specificata alcuna directory

Sì, mi rendo conto che il controllo delle autorizzazioni "in anticipo" non è The UNIX Way ™ , ma dovrei semplicemente provare l'operazione e chiedere perdono in seguito. Nel mio script particolare, tuttavia, ciò porta a una brutta esperienza dell'utente e non posso modificare il componente responsabile.


L'argomento sarà sempre basato sulla directory corrente o l'utente potrebbe specificare un percorso completo?
Jesse_b

@Jesse_b - Suppongo che l'utente possa specificare un percorso assoluto come /foo/bar/file.txt. Fondamentalmente passo il percorso per teepiacere tee $OUT_FILEdove OUT_FILEè passato sulla riga di comando. Dovrebbe "solo funzionare" con percorsi sia assoluti che relativi, giusto?
BeeOnRope,

@BeeOnRope, almeno non ne avresti bisogno tee -- "$OUT_FILE". Cosa succede se il file esiste già o esiste ma non è un file normale (directory, symlink, fifo)?
Stéphane Chazelas,

@ StéphaneChazelas - beh, lo sto usando tee "${OUT_FILE}.tmp". Se il file esiste già, teesovrascrive, che è il comportamento desiderato in questo caso. Se è una directory, teefallirà (penso). symlink non sono sicuro al 100%?
BeeOnRope,

Risposte:


11

Il test ovvio sarebbe:

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

Ma in realtà crea il file se non è già lì. Potremmo ripulire dopo noi stessi:

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

Ma questo rimuoverà un file già esistente, che probabilmente non vuoi.

Tuttavia, abbiamo un modo per aggirare questo:

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

Non puoi avere una directory con lo stesso nome di un altro oggetto in quella directory. Non riesco a pensare a una situazione in cui potresti creare una directory ma non creare un file. Dopo questo test, la tua sceneggiatura sarebbe libera di creare un convenzionale /path/to/filee fare tutto ciò che gli piace.


2
Questo gestisce molto bene così tanti problemi! Un commento in codice che spiega e giustifica che una soluzione insolita potrebbe superare la lunghezza della tua risposta. :-)
jpaugh

Ho anche usato if mkdir /var/run/lockdircome test di blocco. Questo è atomico, mentre if ! [[ -f /var/run/lockfile ]]; then touch /var/run/lockfileha una piccola parte di tempo tra il test e touchdove può iniziare un'altra istanza dello script in questione.
DopeGhoti,

8

Da quello che sto raccogliendo, vuoi verificarlo quando lo usi

tee -- "$OUT_FILE"

(nota che --o non funzionerebbe per i nomi di file che iniziano con -), teeriuscirebbe ad aprire il file per la scrittura.

Questo è quanto:

  • la lunghezza del percorso del file non supera il limite PATH_MAX
  • il file esiste (dopo la risoluzione del collegamento simbolico) e non è di tipo directory e si dispone dell'autorizzazione di scrittura per esso.
  • se il file non esiste, il nome dir del file esiste (dopo la risoluzione del collegamento simbolico) come directory e si dispone dell'autorizzazione di scrittura e ricerca e la lunghezza del nome file non supera il limite NAME_MAX del filesystem in cui risiede la directory.
  • oppure il file è un collegamento simbolico che punta a un file che non esiste e non è un ciclo di collegamento simbolico ma soddisfa i criteri appena sopra

Per ora ignoreremo i filesystem come vfat, ntfs o hfsplus che hanno limitazioni su quali valori di byte possono contenere i nomi dei file, la quota del disco, il limite di processo, selinux, apparmor o altri meccanismi di sicurezza nel modo, filesystem completo, nessun inode a sinistra, dispositivo file che non possono essere aperti in quel modo per un motivo o per un altro, file che sono eseguibili attualmente mappati in uno spazio degli indirizzi di processo, il che potrebbe anche influenzare la possibilità di aprire o creare il file.

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

In basho qualsiasi shell simile a Bourne, basta sostituire il

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

con:

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

Le zshcaratteristiche specifiche qui sono $ERRNO(che espongono il codice di errore dell'ultima chiamata di sistema) e l' $errnos[]array associativo da tradurre nei corrispondenti nomi di macro C standard. E $var:h(da csh) e $var:P(necessita di zsh 5.3 o versioni successive).

bash non ha ancora caratteristiche equivalenti.

$file:hpossono essere sostituiti con dir=$(dirname -- "$file"; echo .); dir=${dir%??}, o con GNU dirname: IFS= read -rd '' dir < <(dirname -z -- "$file").

Per $errnos[ERRNO] == ENOENT, un approccio potrebbe essere quello di eseguire ls -Ldil file e verificare se il messaggio di errore corrisponde all'errore ENOENT. Farlo in modo affidabile e portabile è però complicato.

Un approccio potrebbe essere:

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

(presupponendo che il messaggio di errore si concluda con la syserror()traduzione di ENOENTe che tale traduzione non includa a :) e quindi, invece di [ -e "$file" ]:

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

E verifica la presenza di un errore ENOENT con

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

La $file:Pparte è la più difficile da realizzare bash, specialmente su FreeBSD.

FreeBSD ha un realpathcomando e un readlinkcomando che accetta -fun'opzione, ma non possono essere usati nei casi in cui il file è un collegamento simbolico che non si risolve. È lo stesso con quello perldi Cwd::realpath().

python's os.path.realpath()non sembra funzionare in modo simile a zsh $file:P, quindi assumendo che almeno una versione di pythonè installato e che c'è un pythoncomando che fa riferimento a uno di loro, si potrebbe fare (che non è un dato su FreeBSD è):

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

Ma poi, potresti anche fare tutto python.

Oppure potresti decidere di non gestire tutti quei casi angolari.


Sì, hai capito bene il mio intento. In effetti, la domanda originale non era chiara: in origine avevo parlato della creazione del file, ma in realtà lo sto usando con teequindi il requisito è che il file può essere creato se non esiste, oppure può essere troncato a zero e sovrascritto se lo fa (o comunque lo teegestisce).
BeeOnRope

Sembra che la tua ultima modifica abbia cancellato la formattazione
D. Ben Knoble

Grazie, @ bishop, @ D.BenKnoble, vedi modifica.
Stéphane Chazelas,

1
@DennisWilliamson, beh sì, una shell è uno strumento per invocare i programmi, e ogni programma che invochi nel tuo script deve essere installato per far funzionare il tuo script, questo è ovvio. macOS viene fornito con bash e zsh installati per impostazione predefinita. FreeBSD non viene fornito con nessuno dei due. L'OP potrebbe avere una buona ragione per volerlo fare in bash, forse è qualcosa che vogliono aggiungere a uno script bash già scritto.
Stéphane Chazelas,

1
@pipe, di nuovo, una shell è un interprete di comandi. Vedrai che molti estratti di codice shell scritto invoke interpreti di altre lingue come perl, awk, sedo python(o anche sh, bash...). Lo scripting della shell riguarda l'utilizzo del comando migliore per l'attività. Anche qui perlnon ha un equivalente di zsh" $var:P( Cwd::realpathsi comporta come BSD realpatho readlink -fmentre vorresti che si comportasse come GNU readlink -fo python" os.path.realpath())
Stéphane Chazelas,

6

Un'opzione che potresti prendere in considerazione è la creazione anticipata del file, ma solo il popolamento successivo nello script. È possibile utilizzare il execcomando per aprire il file in un descrittore di file (come 3, 4, ecc.) E successivamente utilizzare un reindirizzamento a un descrittore di file ( >&3, ecc.) Per scrivere i contenuti su quel file.

Qualcosa di simile a:

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

Puoi anche usare a trapper ripulire il file in caso di errore, questa è una pratica comune.

In questo modo, ottieni un vero controllo per le autorizzazioni che sarai in grado di creare il file, mentre allo stesso tempo sarai in grado di eseguirlo abbastanza presto che in caso contrario non hai passato il tempo ad aspettare i lunghi controlli.


AGGIORNATO: Per evitare di ostruire il file nel caso in cui i controlli falliscano, utilizzare il fd<>filereindirizzamento di bash che non tronca immediatamente il file. (Non ci interessa leggere dal file, questa è solo una soluzione alternativa, quindi non >>la tronciamo. L'aggiunta con probabilmente probabilmente funzionerebbe anche troppo, ma tendo a trovarlo un po 'più elegante, tenendo fuori il flag O_APPEND dell'immagine.)

Quando siamo pronti a sostituire il contenuto, dobbiamo prima troncare il file (altrimenti se scriviamo meno byte di quanti ce ne fossero prima nel file, i byte finali rimarrebbero lì.) Possiamo usare il troncato (1) comando da coreutils a tale scopo, e possiamo passargli il descrittore di file aperto che abbiamo (usando lo /dev/fd/3pseudo-file), quindi non è necessario riaprire il file. (Ancora una volta, tecnicamente qualcosa di più semplice : >dir/file.txtprobabilmente funzionerebbe, ma non dover riaprire il file è una soluzione più elegante.)


Avevo considerato questo, ma il problema è che se si verifica un altro errore innocente nel momento in cui creo il file e il punto dopo qualche tempo in cui lo scriverei, l'utente probabilmente sarà sconvolto per scoprire che il file specificato è stato sovrascritto .
BeeOnRope,

1
@BeeOnRope Un'altra opzione che non bloccherà il file è quella di provare ad aggiungere nulla : echo -n >>fileo true >> file. Ovviamente ext4 ha file di sola aggiunta, ma potresti vivere con quel falso positivo.
mosvy,

1
@BeeOnRope Se devi conservare (a) il file esistente o (b) il (completo) nuovo file, questa è una domanda diversa da quella che hai posto. Una buona risposta è spostare il file esistente su un nuovo nome (ad es. my-file.txt.backup) Prima di crearne uno nuovo. Un'altra soluzione è scrivere il nuovo file in un file temporaneo nella stessa cartella, quindi copiarlo sul vecchio file dopo che il resto dello script ha esito positivo --- se l'ultima operazione non è riuscita, l'utente potrebbe risolvere manualmente il problema senza perdere i suoi progressi.
jpaugh

1
@BeeOnRope Se scegli la route "sostituzione atomica" (che è buona!), In genere dovrai scrivere il file temporaneo nella stessa directory del file finale (devono essere nello stesso filesystem per rinominare (2) per riuscire) e usare un nome univoco (quindi se ci sono due istanze dello script in esecuzione, non si ostruiranno reciprocamente i file temporanei.) L'apertura di un file temporaneo per scrivere nella stessa directory è di solito una buona indicazione Sarò in grado di rinominarlo in seguito (bene, tranne se il target finale esiste ed è una directory), quindi in una certa misura affronta anche la tua domanda.
filbranden,

1
@jpaugh - Sono abbastanza riluttante a farlo. Ciò porterebbe inevitabilmente a risposte che provano a risolvere il mio problema di livello superiore, il che potrebbe ammettere soluzioni che non hanno nulla a che fare con la domanda "Come posso verificare ...". Indipendentemente dal mio problema di alto livello, penso che questa domanda sia da sola come qualcosa che potresti ragionevolmente voler fare. Sarebbe ingiusto per le persone che stanno rispondendo a quella specifica domanda se l'ho cambiata per "risolvere questo specifico problema che sto avendo con la mia sceneggiatura". Ho incluso questi dettagli qui perché mi è stato chiesto, ma non voglio porre una domanda principale al cambiamento.
BeeOnRope,

4

Penso che la soluzione di DopeGhoti sia migliore, ma dovrebbe funzionare anche:

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

Il primo if build verifica se l'argomento è un percorso completo (inizia con /) e imposta la dirvariabile sul percorso della directory fino all'ultimo /. Altrimenti se l'argomento non inizia con a /ma contiene un /(specificando una sottodirectory), verrà impostato dirsulla directory di lavoro corrente + il percorso della sottodirectory. Altrimenti assume la directory di lavoro attuale. Quindi controlla se quella directory è scrivibile.


4

Che dire dell'utilizzo del testcomando normale come indicato di seguito?

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

Ho quasi duplicato questa risposta! Bella introduzione, ma potresti menzionarla man test, ed [ ]è equivalente al test (non più conoscenza comune). Ecco un one-liner che stavo per usare nella mia risposta inferiore, per coprire il caso esatto, per i posteri:if [ -w $1] && [ ! -d $1 ] ; then echo "do stuff"; fi
Iron Gremlin

4

Hai detto che l'esperienza dell'utente stava guidando la tua domanda. Risponderò da un punto di vista UX, dal momento che hai buone risposte dal punto di vista tecnico.

Invece di eseguire il controllo in anticipo, che ne dici di scrivere i risultati in un file temporaneo e poi alla fine, inserendo i risultati nel file desiderato dall'utente? Piace:

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

Bene con questo approccio: produce l'operazione desiderata nel normale scenario Happy Path, affronta il problema di atomicità test-and-set, conserva le autorizzazioni del file di destinazione durante la creazione, se necessario, ed è semplicissimo da implementare.

Nota: se avessimo usato mv, avremmo mantenuto le autorizzazioni del file temporaneo - non lo vogliamo, penso: vogliamo mantenere le autorizzazioni impostate sul file di destinazione.

Ora, il male: richiede il doppio dello spazio ( cat .. >costrutto), costringe l'utente a fare un po 'di lavoro manuale se il file di destinazione non era scrivibile nel momento in cui doveva essere e lascia il file temporaneo in posa (che potrebbe avere sicurezza o problemi di manutenzione).


In effetti, questo è più o meno quello che sto facendo ora. Scrivo la maggior parte dei risultati in un file temporaneo e alla fine eseguo la fase di elaborazione finale e scrivo i risultati nel file finale. Il problema è che voglio salvare in anticipo (all'inizio dello script) se è probabile che l'ultimo passaggio fallisca. Lo script può essere eseguito incustodito per minuti o ore, quindi vuoi davvero sapere in anticipo che è destinato a fallire!
BeeOnRope

Certo, ma ci sono molti modi in cui questo potrebbe fallire: il disco potrebbe riempirsi, la directory upstream potrebbe essere rimossa, le autorizzazioni potrebbero cambiare, il file di destinazione potrebbe essere usato per alcune altre cose importanti e l'utente ha dimenticato di aver assegnato lo stesso file per essere distrutto da questo operazione. Se ne parliamo da una prospettiva UX pura, forse la cosa giusta da fare è trattarla come una presentazione di lavoro: alla fine, quando sai che ha funzionato correttamente fino al completamento, basta dire all'utente dove risiede il contenuto risultante e offrire un comando suggerito per loro di spostarlo da soli.
vescovo

In teoria, sì, ci sono infiniti modi in cui questo potrebbe fallire. In pratica, la stragrande maggioranza delle volte questo fallisce perché il percorso fornito non è valido. Non riesco ragionevolmente a impedire ad alcuni utenti non autorizzati di modificare contemporaneamente FS per interrompere il lavoro nel mezzo dell'operazione, ma certamente posso verificare la causa di errore n. 1 di un percorso non valido o non scrivibile.
BeeOnRope

2

TL; DR:

: >> "${userfile}"

Diversamente da <> "${userfile}"o touch "${userfile}", questo non apporterà alcuna modifica spuria ai timestamp del file e funzionerà anche con file di sola scrittura.


Dall'OP:

Voglio fare un controllo ragionevole se il file può essere creato / sovrascritto, ma in realtà non lo crea.

E dal tuo commento alla mia risposta da una prospettiva UX :

La stragrande maggioranza delle volte questo fallisce perché il percorso fornito non è valido. Non posso ragionevolmente impedire ad alcuni utenti canaglia che modificano contemporaneamente FS per interrompere il lavoro nel mezzo dell'operazione, ma certamente posso controllare la causa di errore n. 1 di un percorso non valido o non scrivibile.

L'unico test affidabile è open(2)il file, perché solo questo risolve ogni domanda sulla scrivibilità: percorso, proprietà, filesystem, rete, contesto di sicurezza, ecc. Qualsiasi altro test affronterà alcune parti della scrivibilità, ma non altre. Se desideri un sottoinsieme di test, alla fine dovrai scegliere ciò che è importante per te.

Ma ecco un altro pensiero. Da quello che ho capito:

  1. il processo di creazione del contenuto è di lunga durata e
  2. il file di destinazione deve essere lasciato in uno stato coerente.

Volete fare questo controllo preliminare a causa del n. 1 e non volete armeggiare con un file esistente a causa del n. 2. Quindi perché non chiedi semplicemente alla shell di aprire il file per aggiungere, ma in realtà non aggiungere nulla?

$ 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

Sotto il cofano, questo risolve la domanda di scrivibilità esattamente come desiderato:

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

ma senza modificare il contenuto o i metadati del file. Ora sì, questo approccio:

  • non ti dice se il file è solo append, il che potrebbe essere un problema quando si aggiorna al termine della creazione del contenuto. Puoi rilevarlo, in una certa misura, con lsattre reagire di conseguenza.
  • crea un file che non esisteva in precedenza, se è il caso: mitigalo con un selettivo rm.

Mentre sostengo (nella mia altra risposta) che l' approccio più intuitivo è quello di creare un file temporaneo che l'utente deve spostare, penso che questo sia l' approccio meno ostile all'utente per controllare completamente il proprio input.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.