MobDev.Attest (mob_dev v0.7.0)

Copy Markdown View Source

Prove that the code running on a device is the code you just pushed.

mix mob.deploy reports what it did, not what is now true. Those come apart more often than the exit code suggests, and the interesting failures are the quiet ones — a deploy that prints a tick while the app keeps running something else entirely.

The case that produced this module: two bundle ids diverged, so the BEAM push addressed one app's container while a different app was running. It did not fail with "not installed". It succeeded, printed a tick, and the app carried on with the old code. Both containers existed on the device, so every layer of the deploy was telling the truth about its own step, and the run as a whole was a lie.

Every guide in these repos says "verify effects, not exit codes". That instruction exists because the tools cannot be trusted, and it only works while a human remembers to follow it. This makes it a property the tool checks.

How

module_info(:md5) on a loaded module is the same digest :beam_lib.md5/1 reports for the .beam it was loaded from. Ask the device for one, compute the other locally, compare. A module that never arrived, arrived somewhere else, or arrived and was never loaded all show up.

This deliberately does not hash whole files or directories: two builds of the same source differ in timestamps and paths, and a check that cries wolf gets turned off.

Note the side effect: the device runs an interactive code server, so probing a module it has not loaded causes it to load. That makes the comparison stronger — it is the file on the code path, not just the resident set — at the cost of nudging the thing being measured.

Summary

Types

What a single module's comparison found.

Functions

Compare one module's local digest against what the device reports.

The digest of a module as built locally.

Counts per verdict, for the summary line and the JSON payload.

Whether a set of findings means the deploy can be believed.

Types

finding()

@type finding() :: %{
  module: module(),
  verdict: verdict(),
  expected: binary() | nil,
  actual: binary() | nil
}

verdict()

@type verdict() :: :match | :stale | :missing | :unreadable

What a single module's comparison found.

  • :match — the device is running the bytes we have.
  • :stale — it is running something, but not this. The push did not land, or landed somewhere else, or landed and was not loaded.
  • :missing — the device answered :undef. On an interactive code server — which is what Mob devices run — that means the module is on no code path at all, because asking for it would otherwise have loaded it. For a module mix mob.deploy pushed, that is a real failure, so it is fatal.
  • :unreadable — the check could not run: the local .beam would not digest, or the RPC failed for any reason other than :undef. Not a pass.

Functions

compare(module, expected, digest)

@spec compare(module(), binary() | nil, term()) :: finding()

Compare one module's local digest against what the device reports.

remote is whatever module_info(:md5) came back with, including the failure shapes: nil, {:badrpc, _}, or an :undef exit for a module the device has never loaded.

local_digest(path)

@spec local_digest(Path.t()) :: {module(), binary()} | nil

The digest of a module as built locally.

Returns nil when the file cannot be read or is not a beam — the caller must treat that as "cannot tell", never as agreement.

tally(findings)

@spec tally([finding()]) :: %{required(verdict()) => non_neg_integer()}

Counts per verdict, for the summary line and the JSON payload.

Every verdict is present whether or not it occurred, so a consumer reading .stale never gets nil on the runs that found nothing — the same reason mix mob.mutate's summary has a fixed shape.

verdict(findings)

@spec verdict([finding()]) :: :ok | {:error, String.t()}

Whether a set of findings means the deploy can be believed.

:stale is always fatal: the device is running code we did not build, which is the failure this module exists to catch.

:unreadable is fatal too. It means the check could not run, and a check that could not run must not report success — that is the same defect as a deploy exiting 0 having shipped nothing.

:missing is fatal, and the reason is the opposite of what an earlier version of this doc claimed. That version said interactive BEAM loads a module on first call, so most of a bundle is legitimately unloaded and :missing must be tolerated. Measured on a real device, the inference runs the other way: the code server is interactive, so asking for module_info(:md5) triggers the load and returns a digest. :undef therefore does not mean "not loaded yet" — it means the module is on no code path at all, which for something mix mob.deploy pushed is a genuine failure.

Two consequences worth stating plainly. The check is stronger than first documented: it compares the file the device would load, not merely the set that happens to be resident. And it has a side effect — probing an unloaded module loads it. That is a small mutation of the thing being measured, and it is the reason this connects without restarting: a restart would reload everything and destroy far more than a probe does.