A restore tool recreates any file whose os.Stat call returns an error, and a customer reports overwritten data. How do you diagnose and fix that check?
answer
- not every failure means absence
- the customer's tree is the input you lack
- one errno class is doing two jobs
- a denial looks identical to err != nil
- fail closed before the destructive branch
basics
~20 sThe check conflates absence with every other failure. A permission denial on an unreadable directory looks identical to a missing file, so it recreates data that was there. Match only fs.ErrNotExist for absence and fail on anything else.
solid answer
~50 sThe bug is that `err != nil` is being read as "the file is not there". On a customer machine with tighter directory permissions the walk hits a denial, `os.Stat` returns an error matching `fs.ErrPermission`, the tool concludes absence and writes over live data. To confirm it, pull the `*fs.PathError` out of the customer's log with `errors.As` — `Op` tells you the call and `Path` the exact name, and `errors.Is(err, fs.ErrPermission)` names the class. The fix is a three-way branch: `err == nil` means present, `errors.Is(err, fs.ErrNotExist)` means genuinely absent, and everything else aborts with the error wrapped for context. In a `filepath.WalkDir` the same rule applies to the `err` argument handed to the walk function — returning `nil` there silently continues past directories you could not read. Better still, drop the check: `os.OpenFile` with `os.O_CREATE|os.O_EXCL` gives you a definite answer in one call.
code
go · 4 lines// Any error at all selects the destructive branch.
if _, err := os.Stat(dst); err != nil {
restore(dst)
}go deeper
Know that a non-nil error from os.Stat can mean many things, and only errors.Is against fs.ErrNotExist means the file is absent. A denial matches fs.ErrPermission instead.
Explain the three-way branch — nil, not-exist, everything else — and why the default case must stop rather than fall through. Know how to pull Op and Path out of the error with errors.As for the log.
Show diagnosis from a report you cannot reproduce: classify with errors.Is, get coordinates with errors.As, look at parent directories for the denied bit. Argue for failing closed before any destructive branch, and for O_EXCL over check-then-act.
Own the posture: a data-recovery tool that cannot verify state must stop, not guess, and partial success must be visible in the exit status. Decide that policy once, then hold reviews to it wherever a predicate guards a destructive action.
## What the customer actually hit The code under suspicion looks harmless: ```go if _, err := os.Stat(dst); err != nil { restore(dst) // "it's not there, put it back" } ``` That conditional says "if anything at all went wrong, assume absence". On the developer's machine, running as the owner of every directory in the tree, the only error that ever occurs *is* absence, so the bug is invisible for the whole life of the project. On a customer machine — a restricted service account, a mount with different ownership, a directory the operator locked down — `os.Stat` starts failing for a second reason. A denial and an absence are indistinguishable to `err != nil`, so the tool decides the file is missing and writes over one that was there and readable by somebody else. That is the data loss. Other failures ride the same branch: a path component that is not a directory, an I/O error from a failing disk, a dangling symlink, a name that is too long. All of them mean "I could not find out", and none of them means "it is not there". ## Diagnosis from the report You usually cannot reproduce this, because the shape of the customer's tree is the input. Work from the error value instead: 1. **Get the class.** `errors.Is(err, fs.ErrNotExist)` versus `errors.Is(err, fs.ErrPermission)` splits absence from denial portably, without caring which platform the customer runs. 2. **Get the coordinates.** `errors.As(err, &pathErr)` with `pathErr` declared as `*fs.PathError` gives you `Op` — which call gave up, `"stat"` or `"open"` or `"mkdir"` — and `Path`, the name exactly as the tool built it. A log line carrying those two as separate fields is what turns "the restore corrupted my files" into "every failure was `op=stat` under one directory". 3. **Look one level up.** A denial is often on a *parent* directory rather than the file: without execute permission on a directory you cannot stat anything inside it. `Path` points at the child, so the check that actually failed is a level above what the message names. If the tool never logged the failure at all — because the error only ever selected a branch and was then discarded — that is the first thing to fix, and the reason support has nothing to read. ## The fix Make absence an explicit case and let everything else be a failure: ```go _, err := os.Stat(dst) switch { case err == nil: // already present: leave it alone case errors.Is(err, fs.ErrNotExist): restore(dst) default: return fmt.Errorf("stat restore target: %w", err) } ``` The shape matters more than the specific sentinel. Any predicate whose false branch is "do the destructive thing" must be *positively* established, not inferred from the absence of success. Failing closed here means a restore that stops with a clear error rather than one that quietly overwrites; on a data-recovery tool that trade is not close. ## The better fix: stop asking A check followed by an action asks the filesystem the same question twice and believes the first answer. Ask once instead: ```go f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if errors.Is(err, fs.ErrExist) { return nil // already there, nothing to restore } if err != nil { return fmt.Errorf("create restore target: %w", err) } ``` `O_EXCL` pushes the existence test into the same operation as the creation, so there is no window between deciding and acting, and the failure arrives as an error matching `fs.ErrExist` that you can branch on with the same vocabulary. Note that `os.Create` is not a substitute — it truncates an existing file, which is the very outcome you are trying to avoid. ## The same mistake inside a tree walk A backup or restore tool walks directories, and `filepath.WalkDir` hands the walk function an `err` argument when it could not read an entry: ```go err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { if errors.Is(err, fs.ErrPermission) { skipped = append(skipped, path) return fs.SkipDir } return err } ... }) ``` The common bug is `if err != nil { return nil }` — "keep going" — which turns an unreadable directory into an empty one. For a backup that means a subtree silently missing from the archive; for a restore it means the tool believes nothing is there. Whatever you decide to do with a denial, the decision must be explicit and the skipped paths must end up somewhere a person will see them, ideally in the tool's exit status as well as its log. ## What to take into a review Three things generalise beyond this tool. First, `err != nil` is not a classification — the class comes from `errors.Is` against a named sentinel. Second, the branch that destroys data must be reached only on a positively identified condition. Third, an error that is used to choose a branch and then dropped is an error that support will never see; log it with `Op` and `Path` before you act on it.
- Why is the denial often reported on a file rather than on the directory that is actually locked down?Without execute permission on a directory you cannot stat anything inside it, so the failure surfaces on the child. The `*fs.PathError`'s `Path` names the child, which sends people looking at the wrong object's permissions. When triaging, walk up the parents of the reported path — the restrictive bit is usually on one of them, not on the file the message names.
- How should the walk function passed to filepath.WalkDir handle a non-nil err argument?Deliberately, never by returning `nil` blindly. Returning `nil` continues as if the directory were empty, so an unreadable subtree silently vanishes from a backup. Decide per class: `fs.SkipDir` to skip a denied directory while recording the path, or return the error to abort. Whatever you choose, surface the skipped paths in the log and in the exit status.
- What does os.O_CREATE|os.O_EXCL buy over checking existence first?One call instead of two, and therefore no window between deciding the file is absent and creating it. The kernel answers definitively: either you get the new file, or you get an error matching `fs.ErrExist`. `os.Create` is not an alternative — it truncates an existing file, which is exactly the destructive outcome the check was meant to prevent.
- Should the tool abort the whole restore on the first denial, or skip and continue?Either is defensible, but it must be a stated choice with a visible result. A restore that silently skips is the same class of bug as one that silently overwrites — the operator believes it completed. If you continue, collect the skipped paths, report them at the end, and exit non-zero so an automated caller cannot mistake a partial restore for a full one.
It is a locksmith who concludes a house is empty because nobody answered the door, and then changes the locks.
saying these in an interview costs you the question
- Reading err != nil from os.Stat as "the file is missing"
- Returning nil from a walk function for any error
- Retrying on a permission denial as if it were transient
- Discarding the error after using it to pick a branch
- Reaching for os.Create because it sounds like create-if-absent
- Logging only the message text, so Op and Path are unindexed