◐ Off-By-One · answer catalog

shell-20261008-overlayfs-whiteout-rename-crash-reconciliation

1 answer(s)shellbash

Problem class: shell-20261008-overlayfs-whiteout-rename-crash-reconciliation

📦 Source in repository (JSON)

Answer

OverlayFS Crash Reconciliation: Reconstructing the Unique Merged Namespace

Problem class: shell-20261008-overlayfs-whiteout-rename-crash-reconciliation Environment: GNU/Linux, Bash 5+ Deliverable: a bounded-memory Bash tool that detects contradictory upper-layer records, emits a deterministic repair plan, and proves idempotence when the plan is replayed.

The complete, tested deliverable is saved at ~/overlayfs-whiteout-reconciliation.md. It was extracted and re-run end-to-end from the markdown itself to confirm it is directly usable.


1. Symptom

An OverlayFS upper layer is edited by a sequence of copy-up, whiteout, rename and opaque-directory operations. The machine crashes. On the next boot the upper directory is a mixture of entries that were durably written, inodes from a copy-up that was interrupted half-way, whiteout markers that are missing or that conflict with a later create, opaque markers applied to a non-directory, and rename residue where the source was not whiteouted.

Naively mounting that upper gives a different merged view on every attempt to "fix" it: the lower entry either resurfaces or stays buried depending on which stray inode is present. The task is to make reconciliation a pure function of the durable journal prefix, so exactly one merged namespace is produced, and to prove the produced repair is a fixed point.


2. Root-cause analysis

2.1 The upper layer is namespace metadata, not just data

OverlayFS does not edit the lower layer. Deletion, rename and directory hiding are all encoded as upper-layer tricks:

Intent Upper-layer artifact
hide a lower file whiteout marker at the same path
replace lower content copied-up upper inode (copy-up)
hide all remaining lower children of a dir opaque marker on the dir
rename a merged entry new upper name plus a whiteout at the old name

A crash can therefore lose a metadata edit without losing the data inode — exactly why "the same bytes" can still yield two different namespaces.

2.2 Crash prefixes are ambiguous unless a durability rule is fixed

A write-ahead journal only tells us what was attempted. A crash at record k means records 1..k may be only partially durable. Two repairs can always be argued for a torn copy-up: redo it, or roll back it. If the implementation is allowed to choose per path, the merged namespace is no longer a function of the prefix. The only way to make reconciliation deterministic (and therefore verifiable and idempotent) is to fix a total rule up front and apply it uniformly. This tool adopts the dependency/durability rule:

An operation is effective only if all of its prerequisites are durably completed. A copy-up that has copyup-begin without copyup-end inside the prefix is rolled back: the upper inode is removed so the lower entry remains visible. Every later record that depended on that copy-up is reported as a contradiction and discarded.

Roll-back is chosen (over redo) because it is the only choice that never invents data: on the next mount either the operation fully happened, or it fully did not.

2.3 The five crash classes this tool canonicalises

# Contradiction code Corrupted artifact Lower-visibility hazard Canonical repair
1 E_TORN_COPYUP copyup-begin with no copyup-end partial upper shadows/overwrites lower remove upper inode; lower shows again
2 E_ORPHAN_COPYUP_END / E_DUP_COPYUP_BEGIN unpaired copy-up controls undefined copy-up identity trust the end / ignore duplicate begin
3 E_WHITEOUT_WRITE whiteout p then write p new file is hidden by its own whiteout remove the whiteout (create wins)
4 E_RENAME_NO_WHITEOUT rename of lower-backed src without whiteout deleted lower name resurrects insert whiteout at src
5 E_OPAQUE_NONDIR opaque marker on a non-directory corrupt merge / no hiding materialise the dir, keep opaque

Additional malformed records (E_RENAME_LOWER_SRC, E_UNKNOWN_OP) are reported and resolved by the same conservative rule.


3. Exact fix

3.1 Interface and record grammar

On-disk conventions (portable, rootless, identical to OCI/AUFS layer tarballs):

<dir>/.wh.<name>        whiteout for <dir>/<name>
<dir>/.wh..wh..opq      <dir> is opaque

Journal grammar (one record per line, seq strictly increasing, no spaces in paths):

<seq> copyup-begin <path>
<seq> copyup-end   <path> [dir|file]
<seq> write        <path>
<seq> mkdir        <path>
<seq> whiteout     <path>
<seq> unwhiteout   <path>
<seq> opaque       <dir>
<seq> clear-opaque <dir>
<seq> rename       <src> <dst>

Subcommands:

overlayfs-reconcile.sh replay [--prefix N]   # print canonical merged namespace
overlayfs-reconcile.sh check  [--prefix N]   # list contradictions; exit 3 if any
overlayfs-reconcile.sh plan   [--prefix N]   # deterministic repair plan
overlayfs-reconcile.sh apply  --plan FILE    # apply plan idempotently
overlayfs-reconcile.sh verify [--prefix N]   # fixed-point + idempotence proof
overlayfs-reconcile.sh hash   [--prefix N]   # sha256 of the merged namespace

All subcommands accept --lower DIR --upper DIR --journal FILE and an optional --max-paths N hard bound on the working set.

3.2 The complete tool

Save the following as overlayfs-reconcile.sh and chmod +x it.

#!/usr/bin/env bash
# overlayfs-reconcile.sh -- bounded-memory OverlayFS crash reconciliation tool.
#
# Model
# -----
#   lower/            read-only reference tree (ground truth).
#   upper/            writable layer, using OCI/AUFS-style whiteout markers:
#                        <dir>/.wh.<name>          -> whiteout for <dir>/<name>
#                        <dir>/.wh..wh..opq        -> <dir> is opaque
#   journal           ordered records describing intended upper operations.
#
# Record grammar (whitespace separated, one per line, seq strictly increasing):
#   <seq> copyup-begin <path>
#   <seq> copyup-end   <path> [dir|file]
#   <seq> write        <path>
#   <seq> mkdir        <path>
#   <seq> whiteout     <path>
#   <seq> unwhiteout   <path>
#   <seq> opaque       <dir>
#   <seq> clear-opaque <dir>
#   <seq> rename       <src> <dst>
#
# Durability rule: an operation is only meaningful if its prerequisites are
# durable.  A copy-up that began but never ended is rolled back (the upper
# inode is removed so the lower entry stays visible); any later record that
# depended on it is reported as a contradiction and discarded.  This makes the
# merged namespace a pure function of the durable prefix, hence unique.
#
# All state is keyed by path, so memory is O(#paths), not O(#records): the
# journal is streamed and never retained.
#
# Subcommands:
#   replay [--prefix N]   print canonical merged namespace (lower + repaired upper)
#   check  [--prefix N]   print contradictions, exit 3 if any
#   plan   [--prefix N]   print deterministic repair plan (sorted, rank-keyed)
#   apply  --plan FILE    apply a plan idempotently
#   verify [--prefix N]   prove the plan reaches a fixed point and is idempotent
#   hash   [--prefix N]   sha256 of canonical merged namespace
#
set -o pipefail

PROG=${0##*/}

die() { printf '%s: error: %s\n' "$PROG" "$*" >&2; exit 1; }

usage() {
  sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
  exit "${1:-0}"
}

# ---------------------------------------------------------------------------
# small helpers
# ---------------------------------------------------------------------------
joinp() { # joinp <dir> <name> ; dir may be empty or "."
  if [ -z "$1" ] || [ "$1" = "." ]; then printf '%s' "$2"; else printf '%s/%s' "$1" "$2"; fi
}
parent_of() { # parent dir of a path; "" for top level
  case "$1" in
    */*) printf '%s' "${1%/*}" ;;
    *)   printf '%s' "" ;;
  esac
}
base_of() { printf '%s' "${1##*/}"; }
depth_of() { local p=$1 d=0; while [ "${p#*/}" != "$p" ]; do d=$((d+1)); p=${p#*/}; done; printf '%s' "$d"; }
wh_path() { # upper path of the whiteout marker for a namespace path
  local u=$1 p=$2 par base
  par=$(parent_of "$p"); base=$(base_of "$p")
  if [ -z "$par" ]; then printf '%s/.wh.%s' "$u" "$base"; else printf '%s/%s/.wh.%s' "$u" "$par" "$base"; fi
}

# scan_tree <root> ; emits  <type>\t<path>  (and  opaque\t<dir>)
scan_tree() {
  local root=$1
  [ -d "$root" ] || return 0
  find "$root" -mindepth 1 -printf '%y\t%p\n' 2>/dev/null | while IFS=$'\t' read -r ty full; do
    local rel=${full#"$root"/} base dir t
    base=${rel##*/}
    dir=${rel%/*}
    [ "$dir" = "$rel" ] && dir=""
    case "$base" in
      .wh..wh..opq) printf 'opaque\t%s\n' "$dir" ;;
      .wh.*)        printf 'whiteout\t%s\n' "$(joinp "$dir" "${base#.wh.}")" ;;
      *) case "$ty" in d) t=dir ;; *) t=file ;; esac
         printf '%s\t%s\n' "$t" "$rel" ;;
    esac
  done
}

# ---------------------------------------------------------------------------
# journal replay -> desired upper state (bash associative arrays)
# ---------------------------------------------------------------------------
declare -A DTYPE DOPQ PENDING
CONTRA=""

contra() { CONTRA+="$1"$'\n'; }

replay() { # replay <journal> <prefix>
  local journal=$1 prefix=$2 line seq op p1 p2 st
  DTYPE=(); DOPQ=(); PENDING=(); CONTRA=""
  [ -r "$journal" ] || die "journal not readable: $journal"
  while read -r seq op p1 p2 _junk; do
    [ -z "${seq:-}" ] && continue
    case "$seq" in \#*) continue ;; esac
    [ "$seq" -le "$prefix" ] 2>/dev/null || break
    case "$op" in
      copyup-begin)
        if [ "${PENDING[$p1]:-}" = 1 ]; then
          contra "E_DUP_COPYUP_BEGIN $seq $p1"
        fi
        PENDING[$p1]=1
        ;;
      copyup-end)
        if [ "${PENDING[$p1]:-}" != 1 ]; then
          contra "E_ORPHAN_COPYUP_END $seq $p1"
        fi
        unset 'PENDING[$p1]'
        DTYPE[$p1]="${p2:-file}"
        ;;
      write)
        if [ "${PENDING[$p1]:-}" = 1 ]; then
          contra "E_WRITE_DURING_COPYUP $seq $p1"
        fi
        if [ "${DTYPE[$p1]:-}" = whiteout ]; then
          contra "E_WHITEOUT_WRITE $seq $p1"
        fi
        DTYPE[$p1]=file
        ;;
      mkdir)
        DTYPE[$p1]=dir
        ;;
      whiteout)
        DTYPE[$p1]=whiteout
        unset 'DOPQ[$p1]'
        ;;
      unwhiteout)
        if [ "${DTYPE[$p1]:-}" = whiteout ]; then unset 'DTYPE[$p1]'; fi
        ;;
      opaque)
        if [ "${DTYPE[$p1]:-}" != dir ]; then
          contra "E_OPAQUE_NONDIR $seq $p1"
          DTYPE[$p1]=dir
        fi
        DOPQ[$p1]=1
        ;;
      clear-opaque)
        unset 'DOPQ[$p1]'
        ;;
      rename)
        st="${DTYPE[$p1]:-}"
        if [ -z "$st" ]; then
          contra "E_RENAME_LOWER_SRC $seq $p1"
          st=file
        fi
        DTYPE[$p2]="$st"
        if [ -n "${LTYPE[$p1]:-}" ]; then
          # renaming a merged entry must leave a whiteout at the source,
          # otherwise the lower entry becomes visible again.
          contra "E_RENAME_NO_WHITEOUT $seq $p1"
          DTYPE[$p1]=whiteout
        else
          unset 'DTYPE[$p1]'
        fi
        if [ "${DOPQ[$p1]:-}" = 1 ]; then DOPQ[$p2]=1; unset 'DOPQ[$p1]'; fi
        ;;
      *)
        contra "E_UNKNOWN_OP $seq $op"
        ;;
    esac
  done < "$journal"

  # Roll back every copy-up that never committed (durability rule).
  local p
  for p in "${!PENDING[@]}"; do
    contra "E_TORN_COPYUP $p"
    unset 'DTYPE[$p]'
    unset 'DOPQ[$p]'
  done

  # Optional hard bound on the working set (proves O(#paths) memory).
  if [ "$MAXP" -gt 0 ] 2>/dev/null; then
    local used=$(( ${#DTYPE[@]} + ${#DOPQ[@]} ))
    [ "$used" -le "$MAXP" ] || die "working set $used exceeds --max-paths $MAXP"
  fi
  return 0
}

# ---------------------------------------------------------------------------
# diff observed upper vs desired upper -> plan file
# ---------------------------------------------------------------------------
declare -A OTYPE OOPQ
declare -A LTYPE

load_lower() { # load_lower <lower>
  local lower=$1
  LTYPE=()
  local t p
  while IFS=$'\t' read -r t p; do LTYPE[$p]=$t; done < <(scan_tree "$lower")
}

has_desired_descendant() { # true if any desired path lives strictly under $1
  local p=$1 q
  for q in "${!DTYPE[@]}"; do
    case "$q" in "$p"/*) return 0 ;; esac
  done
  return 1
}

plan_dump() { # plan_dump <planfile> <lower> <upper>
  local plan=$1 lower=$2 upper=$3
  : > "$plan"
  local t p
  OTYPE=(); OOPQ=()
  while IFS=$'\t' read -r t p; do
    case "$t" in
      opaque) OOPQ[$p]=1 ;;
      *) OTYPE[$p]=$t ;;
    esac
  done < <(scan_tree "$upper")

  local -A KEYS=()
  for p in "${!OTYPE[@]}"; do KEYS[$p]=1; done
  for p in "${!DTYPE[@]}"; do KEYS[$p]=1; done

  local ot dt d
  for p in "${!KEYS[@]}"; do
    ot=${OTYPE[$p]:-absent}
    dt=${DTYPE[$p]:-absent}
    d=$(depth_of "$p")
    if [ "$dt" = absent ]; then
      if [ "$ot" = whiteout ]; then
        printf '%s\tUNWH\t%s\t\n' "$((100-d))" "$p" >> "$plan"
      elif [ "$ot" = dir ]; then
        # never sweep away an upper directory that still carries desired
        # descendants; those children are reconciled individually.
        has_desired_descendant "$p" || printf '%s\tRM\t%s\t\n' "$((100-d))" "$p" >> "$plan"
      elif [ "$ot" = file ]; then
        printf '%s\tRM\t%s\t\n' "$((100-d))" "$p" >> "$plan"
      fi
      continue
    fi
    if [ "$dt" = whiteout ]; then
      if [ "$ot" != whiteout ]; then
        printf '%s\tWH\t%s\t\n' "$((110-d))" "$p" >> "$plan"
      fi
      continue
    fi
    # dt is file or dir
    if [ "$ot" = "$dt" ]; then
      :
    elif [ "$ot" = whiteout ]; then
      printf '%s\tUNWH\t%s\t\n' "$((100-d))" "$p" >> "$plan"
      _emit_materialize "$plan" "$p" "$dt" "$d"
    elif [ "$ot" = file ] || [ "$ot" = dir ]; then
      printf '%s\tRM\t%s\t\n' "$((100-d))" "$p" >> "$plan"
      _emit_materialize "$plan" "$p" "$dt" "$d"
    else
      _emit_materialize "$plan" "$p" "$dt" "$d"
    fi
  done

  # opaque markers
  for p in "${!OOPQ[@]}"; do KEYS[$p]=1; done
  for p in "${!DOPQ[@]}"; do KEYS[$p]=1; done
  for p in "${!KEYS[@]}"; do
    local wo=${OOPQ[$p]:-0} wd=${DOPQ[$p]:-0}
    [ "$wo" = "$wd" ] && continue
    local dd=$(depth_of "$p")
    if [ "$wd" = 1 ]; then
      printf '%s\tOPQ\t%s\t\n' "$((200+dd))" "$p" >> "$plan"
    else
      printf '%s\tCLR_OPQ\t%s\t\n' "$((100-dd))" "$p" >> "$plan"
    fi
  done

  # deterministic order: rank asc, path asc
  LC_ALL=C sort -t$'\t' -k1,1n -k3,3 "$plan" -o "$plan"
}

_emit_materialize() { # <plan> <path> <type> <depth>
  local plan=$1 p=$2 dt=$3 d=$4 lower=$LOWER
  if [ "$dt" = dir ]; then
    printf '%s\tMKDIR\t%s\t\n' "$((300+d))" "$p" >> "$plan"
  elif [ -f "$lower/$p" ]; then
    printf '%s\tCOPYUP\t%s\t\n' '500' "$p" >> "$plan"
  else
    printf '%s\tWRITE\t%s\t\n' '500' "$p" >> "$plan"
  fi
}

# ---------------------------------------------------------------------------
# apply a plan idempotently
# ---------------------------------------------------------------------------
apply_plan() { # apply_plan <planfile> <upper>
  local plan=$1 upper=$2
  local rank action p val wp par
  [ -r "$plan" ] || die "plan not readable: $plan"
  while IFS=$'\t' read -r rank action p val; do
    [ -z "${action:-}" ] && continue
    wp=$(wh_path "$upper" "$p")
    par=$(parent_of "$p")
    case "$action" in
      RM)      rm -rf -- "$upper/$p" "$wp" ;;
      UNWH)    rm -f -- "$wp" ;;
      WH)      rm -rf -- "$upper/$p"; mkdir -p -- "$(dirname -- "$wp")"; : > "$wp" ;;
      MKDIR)   mkdir -p -- "$upper/$p" ;;
      WRITE)   mkdir -p -- "$upper/$( [ -n "$par" ] && printf '%s' "$par" || printf '.' )"; : > "$upper/$p" ;;
      COPYUP)  mkdir -p -- "$upper/$( [ -n "$par" ] && printf '%s' "$par" || printf '.' )"
               if [ -f "$LOWER/$p" ];     then cp -f  -- "$LOWER/$p" "$upper/$p"
               elif [ -d "$LOWER/$p" ];   then mkdir -p -- "$upper/$p"
               else : > "$upper/$p"; fi ;;
      OPQ)     mkdir -p -- "$upper/$p"; : > "$upper/$p/.wh..wh..opq" ;;
      CLR_OPQ) rm -f -- "$upper/$p/.wh..wh..opq" ;;
      *) die "unknown plan action: $action" ;;
    esac
  done < "$plan"
}

# ---------------------------------------------------------------------------
# merged namespace
# ---------------------------------------------------------------------------
ancestor_opaque() { # <path> ; true if any ancestor dir is opaque
  local p=$1 q
  q=$(parent_of "$p")
  while [ -n "$q" ]; do
    [ "${DOPQ[$q]:-}" = 1 ] && return 0
    q=$(parent_of "$q")
  done
  return 1
}

print_merged() {
  local -A KEYS=()
  local p t out
  for p in "${!LTYPE[@]}"; do KEYS[$p]=1; done
  for p in "${!DTYPE[@]}"; do KEYS[$p]=1; done
  {
    for p in "${!KEYS[@]}"; do
      if [ "${DTYPE[$p]:-}" = whiteout ]; then continue; fi
      if [ -n "${DTYPE[$p]:-}" ]; then
        printf '%s\t%s\n' "${DTYPE[$p]}" "$p"
        continue
      fi
      ancestor_opaque "$p" && continue
      if [ -n "${LTYPE[$p]:-}" ]; then
        printf '%s\t%s\n' "${LTYPE[$p]}" "$p"
      fi
    done
  } | LC_ALL=C sort
}

merged_hash() { print_merged | sha256sum | cut -d' ' -f1; }

# ---------------------------------------------------------------------------
# argument handling
# ---------------------------------------------------------------------------
LOWER=""; UPPER=""; JOURNAL=""; PREFIX=""
PLANFILE=""; MAXP=0

parse_common() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --lower)  LOWER=$2; shift 2 ;;
      --upper)  UPPER=$2; shift 2 ;;
      --journal) JOURNAL=$2; shift 2 ;;
      --prefix) PREFIX=$2; shift 2 ;;
      --plan)   PLANFILE=$2; shift 2 ;;
      --max-paths) MAXP=$2; shift 2 ;;
      -h|--help) usage 0 ;;
      *) die "unknown argument: $1" ;;
    esac
  done
  [ -n "$LOWER" ] || LOWER=$PWD/lower
  [ -n "$UPPER" ] || UPPER=$PWD/upper
  [ -n "$JOURNAL" ] || JOURNAL=$PWD/journal.log
}

need_prefix() {
  [ -n "$PREFIX" ] || die "--prefix N is required for this subcommand"
}

cmd_replay() {
  parse_common "$@"; need_prefix
  load_lower "$LOWER"
  replay "$JOURNAL" "$PREFIX"
  print_merged
}

cmd_hash() {
  parse_common "$@"; need_prefix
  load_lower "$LOWER"
  replay "$JOURNAL" "$PREFIX"
  merged_hash
}

cmd_check() {
  parse_common "$@"; need_prefix
  load_lower "$LOWER"
  replay "$JOURNAL" "$PREFIX"
  if [ -z "$CONTRA" ]; then
    echo "OK no contradictions"
    return 0
  fi
  printf '%s' "$CONTRA" | LC_ALL=C sort -u
  return 3
}

cmd_plan() {
  parse_common "$@"; need_prefix
  load_lower "$LOWER"
  replay "$JOURNAL" "$PREFIX"
  local tmp; tmp=$(mktemp) || die "mktemp failed"
  plan_dump "$tmp" "$LOWER" "$UPPER"
  cat "$tmp"
  rm -f "$tmp"
}

cmd_apply() {
  parse_common "$@"
  [ -n "$PLANFILE" ] || die "--plan FILE is required"
  apply_plan "$PLANFILE" "$UPPER"
}

cmd_verify() {
  parse_common "$@"; need_prefix
  local p1 p2 h1 h2
  load_lower "$LOWER"
  replay "$JOURNAL" "$PREFIX"
  p1=$(mktemp); p2=$(mktemp)
  plan_dump "$p1" "$LOWER" "$UPPER"
  apply_plan "$p1" "$UPPER"
  h1=$(merged_hash)
  plan_dump "$p2" "$LOWER" "$UPPER"
  if [ -s "$p2" ]; then
    echo "FAIL: plan did not reach a fixed point"
    echo "residual plan:"; cat "$p2"
    rm -f "$p1" "$p2"; return 1
  fi
  apply_plan "$p1" "$UPPER"   # replay the same plan on the repaired tree
  h2=$(merged_hash)
  rm -f "$p1" "$p2"
  if [ "$h1" != "$h2" ]; then
    echo "FAIL: replay changed the merged namespace ($h1 != $h2)"; return 1
  fi
  if [ -n "$CONTRA" ]; then
    echo "contradictions (resolved deterministically):"
    printf '%s' "$CONTRA" | LC_ALL=C sort -u
  fi
  echo "PASS idempotent fixed point, merged hash $h1"
}

# ---------------------------------------------------------------------------
main() {
  [ $# -ge 1 ] || usage 1
  local sub=$1; shift
  case "$sub" in
    replay) cmd_replay "$@" ;;
    hash)   cmd_hash "$@" ;;
    check)  cmd_check "$@" ;;
    plan)   cmd_plan "$@" ;;
    apply)  cmd_apply "$@" ;;
    verify) cmd_verify "$@" ;;
    -h|--help|help) usage 0 ;;
    *) die "unknown subcommand: $sub (try --help)" ;;
  esac
}

main "$@"

3.3 Reproducible crash scenario

Save as setup_demo.sh (or copy the commands) and run it inside an empty directory.

#!/usr/bin/env bash
# Build a deterministic crash scenario in the current directory.
set -euo pipefail
rm -rf lower upper journal.log plan.txt
mkdir -p lower/etc lower/var/log lower/bin
printf 'root:x:0:0\n'          > lower/etc/passwd
printf '<ip-address> localhost\n' > lower/etc/hosts
printf 'boot\n'                > lower/var/log/app.log
printf '#!/bin/sh\n'           > lower/bin/tool

# upper: mixture of durable, partial and stale records that survived a crash
mkdir -p upper/etc upper/var/log upper/data upper/bin
printf 'root:x:0:0\n'  > upper/etc/passwd       # durable write
printf 'stale\n'       > upper/etc/ghost        # orphan entry
printf 'partial\n'     > upper/var/log/app.log  # torn copy-up residue
printf '#!/bin/sh\n'   > upper/bin/tool         # pre-rename residue
printf '#!/bin/sh\n'   > upper/bin/tool2        # renamed target

cat > journal.log <<'EOF'
1 mkdir etc
2 copyup-begin etc/passwd
3 copyup-end etc/passwd
4 write etc/passwd
5 whiteout etc/hosts
6 copyup-begin var/log/app.log
7 mkdir data
8 opaque data
9 copyup-begin bin/tool
10 copyup-end bin/tool
11 rename bin/tool bin/tool2
EOF

4. Verification

All commands below were executed on the generated files. T denotes the tool.

chmod +x overlayfs-reconcile.sh
mkdir demo && cd demo && bash ../setup_demo.sh
T=../overlayfs-reconcile.sh

4.1 Contradictions are detected (exit 3)

$ cat j.log
1 copyup-begin a
2 copyup-begin a
3 copyup-end b
4 whiteout c
5 write c
6 mkdir d
7 opaque d
8 opaque e
9 rename loweronly dst
10 bogus-op q
11 copyup-begin torn
12 copyup-end torn

$ overlayfs-reconcile.sh check --lower lower --upper upper --journal j.log --prefix 12
E_DUP_COPYUP_BEGIN 2 a
E_OPAQUE_NONDIR 8 e
E_ORPHAN_COPYUP_END 3 b
E_RENAME_LOWER_SRC 9 loweronly
E_TORN_COPYUP a
E_UNKNOWN_OP 10 bogus-op
E_WHITEOUT_WRITE 5 c
exit=3

The check subcommand is intentionally non-zero on contradictions so CI can gate a mount on it.

4.2 Deterministic repair plan for the full prefix

98  RM  var/log/app.log 
99  RM  etc/ghost   
99  RM  var/log 
100 RM  var 
109 WH  bin/tool    
109 WH  etc/hosts   
200 OPQ data    

The plan is sorted by (rank, path). Crash fingerprints:

4.3 Canonical merged namespace

dir bin
dir data
dir etc
dir var
dir var/log
file    bin/tool2
file    etc/passwd
file    var/log/app.log

4.4 Idempotence of the plan

$ overlayfs-reconcile.sh apply ... --plan plan.txt
$ overlayfs-reconcile.sh hash  ... --prefix 11
b77436fb091235c6be3525a23b52a3c723867f766bbe23ebe1e1f8015da7fe48
$ overlayfs-reconcile.sh apply ... --plan plan.txt   # replay same plan
$ overlayfs-reconcile.sh hash  ... --prefix 11
b77436fb091235c6be3525a23b52a3c723867f766bbe23ebe1e1f8015da7fe48
$ overlayfs-reconcile.sh plan  ... --prefix 11   # residual
(no output above = empty residual plan)

apply is idempotent by construction: every action is rm -f / mkdir -p / cp -f / : >. The second apply changes nothing and the residual plan is empty, so the repaired tree is a fixed point.

4.5 Full verify transcript

contradictions (resolved deterministically):
E_RENAME_NO_WHITEOUT 11 bin/tool
E_TORN_COPYUP var/log/app.log
PASS idempotent fixed point, merged hash b77436fb091235c6be3525a23b52a3c723867f766bbe23ebe1e1f8015da7fe48

verify performs: plan → apply → re-plan (must be empty) → hash → apply the same plan again → hash, and asserts the two hashes match.

4.6 Every crash prefix converges

prefix 1  : PASS idempotent fixed point, merged hash eda739518782ea4e4a8e9b4ed448544106f7fec8d9d6de36ed910da00d409686
prefix 2  : PASS idempotent fixed point, merged hash eda739518782ea4e4a8e9b4ed448544106f7fec8d9d6de36ed910da00d409686
prefix 3  : PASS idempotent fixed point, merged hash eda739518782ea4e4a8e9b4ed448544106f7fec8d9d6de36ed910da00d409686
prefix 4  : PASS idempotent fixed point, merged hash eda739518782ea4e4a8e9b4ed448544106f7fec8d9d6de36ed910da00d409686
prefix 5  : PASS idempotent fixed point, merged hash b25269699e8db74fcda3b278e7f8dc7886c2549c4798b41a9947367b244591a0
prefix 6  : PASS idempotent fixed point, merged hash b25269699e8db74fcda3b278e7f8dc7886c2549c4798b41a9947367b244591a0
prefix 7  : PASS idempotent fixed point, merged hash bec8773007aba49a255537c6ae71a9a11b740eadd0b772f4aeccf897dead1c6b
prefix 8  : PASS idempotent fixed point, merged hash bec8773007aba49a255537c6ae71a9a11b740eadd0b772f4aeccf897dead1c6b
prefix 9  : PASS idempotent fixed point, merged hash bec8773007aba49a255537c6ae71a9a11b740eadd0b772f4aeccf897dead1c6b
prefix 10 : PASS idempotent fixed point, merged hash bec8773007aba49a255537c6ae71a9a11b740eadd0b772f4aeccf897dead1c6b
prefix 11 : PASS idempotent fixed point, merged hash b77436fb091235c6be3525a23b52a3c723867f766bbe23ebe1e1f8015da7fe48

5. Why this is bounded-memory and unique

text $ overlayfs-reconcile.sh check ... --max-paths 2 overlayfs-reconcile.sh: error: working set 8 exceeds --max-paths 2


6. One-command reproduction

Save the following as run_tests.sh next to the tool and setup_demo.sh, then run bash run_tests.sh. It sweeps every crash prefix, applies the repair plan twice and asserts the fixed point.

#!/usr/bin/env bash
# Reset the crash-survivor upper for one prefix and exercise the tool.
set -euo pipefail
cd "$(dirname "$0")"
T=./overlayfs-reconcile.sh
rm -rf demo && mkdir demo && (cd demo && bash ../setup_demo.sh)
cp -a demo/upper demo/upper.crash

for k in $(seq 1 11); do
  rm -rf demo/upper && cp -a demo/upper.crash demo/upper
  printf 'prefix %-2s : ' "$k"
  ( cd demo && ../overlayfs-reconcile.sh verify --lower lower --upper upper \
      --journal journal.log --prefix "$k" 2>&1 | tr '\n' '|' )
  echo
done

# explicit plan/apply/idempotence transcript for the full prefix
rm -rf demo/upper && cp -a demo/upper.crash demo/upper
cd demo
echo "--- plan (prefix 11) ---"
../overlayfs-reconcile.sh plan --lower lower --upper upper --journal journal.log --prefix 11 | tee plan.txt
echo "--- apply plan twice ---"
../overlayfs-reconcile.sh apply --lower lower --upper upper --plan plan.txt
h1=$(../overlayfs-reconcile.sh hash --lower lower --upper upper --journal journal.log --prefix 11)
../overlayfs-reconcile.sh apply --lower lower --upper upper --plan plan.txt
h2=$(../overlayfs-reconcile.sh hash --lower lower --upper upper --journal journal.log --prefix 11)
echo "hash after 1st apply: $h1"
echo "hash after 2nd apply: $h2"
[ "$h1" = "$h2" ] && echo "IDEMPOTENT: yes" || { echo "IDEMPOTENT: no"; exit 1; }
echo "--- residual plan must be empty ---"
../overlayfs-reconcile.sh plan --lower lower --upper upper --journal journal.log --prefix 11
echo "(end)"

Evidence & signatures

# Evidence
- Problem class: shell-20261008-overlayfs-whiteout-rename-crash-reconciliation
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-08T10:05:38.102Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Given an OverlayFS upper-layer journal and a sequence of interrupted copy-up, whiteout, rename, and opaque-directory operations, reconstruct the only valid merged namespace after any crash prefix while preserving lower-layer visibility rules. Implement a bounded-memory Bash tool that detects contradictory records, emits a deterministic repair plan, and proves idempotence when that plan is replayed.", "environment": "bash", "language": "shell", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "shell-20261008-overlayfs-whiteout-rename-crash-reconciliation", "provider": "openrouter", "solved_at": "2026-10-08T10:05:38.105Z", "version": "5"}
Generated from the verified corpus · MIT licensedBack to the catalog