NukeBase

File Rules

fileRead, fileWrite and fileValidate govern the file store exactly the way read, write and validate govern the data tree — on the same trie, with the same path arrays and the same wildcards. A path carries both sets independently.

rules.js — record rules and attachment rules side by side
module.exports = {
  "posts": {
    "$id": {
      "read":  "true",
      "write": "admin.uid == data.authorId",

      "attachments": {
        "fileRead":  "true",
        // A file rule reaching for a sibling RECORD must go through `root` —
        // `data` here is file-side state, not the post.
        "fileWrite": "admin.uid == root.posts[$id]?.authorId",
        "fileValidate": "file.size < 10 * 1024 * 1024 && file.type.startsWith('image/')"
      }
    }
  },

  "avatars": {
    "$uid": {
      "fileRead":  "true",
      "fileWrite": "admin.uid == $uid",
      "fileValidate": "file.size <= 2 * 1024 * 1024"
    }
  }
};

Semantics mirror the data side exactly:

  • fileRead grants — any level along the path returning true allows, and the grant cascades over everything beneath it. Governs download and listing. Because a grant cascades, one check on a directory covers every entry in it, so a listing never costs one rule evaluation per file.
  • fileWrite grants — same walk. Governs upload and delete.
  • fileValidate conjoins — every matching level must pass, and a single failure denies. It does not run on delete, exactly as validate doesn't run on remove.

"!data" is write-once. Because data is the existing file's stat and is undefined when nothing is there, "fileWrite": "admin.uid == $uid && !data" permits the first upload to a path and refuses every overwrite — the same idiom as "write": "!data" on the data side.

Two deliberate differences from the data side:

  • data is the stat of the path being acted on, and it is the same at every level of the walk — not the entry at each prefix. Statting every ancestor would be a syscall per level to answer a question nobody asks. So data means "what is already at this path" ({ name, ext, size, type, mtime, isDir }) or undefined if nothing is.
  • fileValidate cascades over path prefixes only. A file descriptor is flat, so unlike validate there is nothing to descend into.

The file descriptor

In fileWrite and fileValidate, the incoming value is bound to both file and newData — the same object under two names, because newData reads like a JSON payload and this isn't one. It is null on a delete.

FieldValue
file.nameThe last path segment, e.g. "hero.png"
file.extLowercased extension including the dot, e.g. ".png"
file.sizeSize in bytes
file.typeResolved MIME type: sniffed content wins, then the extension, then the declared header
file.declaredTypeRaw Content-Type the client sent (least trustworthy — it is whatever the caller typed)
file.sniffedTypeType detected from the leading bytes, or ""
file.pathThe full path array

Uploads are validated twice, and the second run is the real one.

  • Before the first byte, against Content-Length and Content-Type — a cheap early-out so a request with no write grant never reaches the disk.
  • After the last byte, against the actual byte count and the type sniffed from the leading bytes — authoritative. Both headers are attacker-chosen, so a rule like file.size < 5*1024*1024 enforced only against the declared value would be enforced against a number the client picked.

Content sniffing recognises PNG, JPEG, GIF, WebP, MP4, PDF, ZIP, gzip, SVG, HTML and XML. The case that matters is markup wearing an image extension: a rule reading file.type.startsWith('image/') sees text/html for a .png full of <script>, so the check it appears to make is the check it actually makes.

A throwing file rule denies, and logs. Data rules let the error reach the WebSocket reply; file operations are served over HTTP, where a rule's internal error text should not reach the caller. The failure is written to the server log so a broken rule is diagnosable rather than mysteriously silent — but from the client it is indistinguishable from a deliberate refusal. Keep ?. in any rule that walks the tree: root.usage?.[$uid]?.bytes is a working quota check, root.usage[$uid].bytes is one that denies everything the moment usage is empty.

The store root can never be listed. An empty path array is rejected outright, and fileRead on one returns false because there is no level at which to grant.