- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
No behavior changes. Each item says what changed and why.
chunk.rs
- Add Decoded::data/path_push/key_value/one_byte. Every branch of
Chunk::decode built the same struct by hand; the branches still mirror
the spec one for one, with far less noise.
- Read lengths with len_at/wide_len_at instead of `as usize`. The casts
could truncate on 32-bit targets; oversized lengths now return
MalformedChunk.
- Rewrite parse_all as a `while let [first, second, ..]` loop. The second
stop check after each chunk was already covered by the loop condition.
Add a test for a single trailing zero byte.
dictionary.rs
- apply_chunks takes chunks by value (impl IntoIterator). Values are moved
into the tree instead of cloned; the caller doesn't use them afterwards.
- KeyValue chunks push/pop the path instead of allocating a new Vec per
chunk.
- children() returns an iterator that borrows only the dictionary, not the
path argument, so callers can pass a temporary path.
- Add Entry::child_or_empty to replace three copies of
`.get().map_or(&[][..], Entry::value_or_empty)`.
table.rs
- new_record uses entry().or_insert() instead of insert + get +
expect("just inserted"). Library code shouldn't panic, and this also
drops the `# Panics` doc gap.
- last_record_id comes from the records already read instead of walking
the dictionary a second time.
- Replace RECORDS_SUFFIX[0] with RECORDS_KEY and build paths with
.concat() instead of chain/collect.
- Use to_owned() for &str -> String, as the rest of the crate does.
calculation.rs
- Names::read builds the field map with flat_map instead of a nested loop
that inserts into a mutable HashMap.
- Function::is_single_byte reuses the binary-searched name() instead of a
linear scan over the same table.
script.rs
- Add Target::describe and use it in ScriptStep::details. At most one
target applies, so five Option accessors chained together were
needlessly roundabout.
- read_name builds its path with .concat().
scsu.rs
- Name the 0xFFFD marker REPLACEMENT instead of repeating the literal.
- Add next_pair for the repeated `(iter.next(), iter.next())` matches.
- Flatten push_code_point from a nested match to if / else if / else.
file.rs, sector.rs
- Header and payload buffers are stack arrays, not heap Vecs; their size
is a constant.
- Check the magic with starts_with instead of slicing and comparing.
- read_dictionary accepts any Read + Seek, not only File.
fmp12-cli, tests
- show_script matches Format::Json explicitly instead of `_`, so a new
format variant forces this match to be updated.
- Pass Column::name and ToString::to_string directly instead of
redundant closures.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
||
| docs/fmp12 | ||
| fmp12-cli | ||
| src | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE.md | ||
| README.md | ||
fmp12
A Rust library for reading Claris FileMaker Pro .fmp12 database files, without FileMaker.
FileMaker's .fmp12 format is proprietary and (intended to be :p) undocumented. If you have a file but no FileMaker license, or you want to pull data into another tool, there is little to reach for. This crate decodes the format by building upon prior community efforts (fmptools, fmplib, and fp5dump).
Point it at a file and you get the tables, the field definitions, the record data, the scripts with their steps, and the formulas of calculation fields and script steps. It is read-only and still early: it covers the core of a database (tables, fields, records), the outline of scripts, and calculations. More features are on the way!
If your file fails to parse, please let me know.
use fmp12::FmpFile;
fn main() -> Result<(), fmp12::FmpError> {
let file = FmpFile::open("database.fmp12")?;
if let Some(table) = file.table("Contacts") {
for record in table.records() {
println!("{:?}", record.value(table, "Name"));
}
}
Ok(())
}
Contents
Installing
The crate is named fmp12, MIT licensed, and uses Rust edition 2024 (so it needs a recent Rust toolchain). It is not yet published to crates.io, but it can be installed directly from the git repo:
[dependencies]
fmp12 = { git = "https://forge.smartyellow.nl/romein/fmp" }
Command line
The fmp12-cli crate in this repo builds an fmp12 command that looks inside a file from the terminal. It is a separate crate, so the library itself has no command-line dependencies.
cargo install --git https://forge.smartyellow.nl/romein/fmp fmp12-cli # or: cargo run -p fmp12-cli -- <command>
fmp12 info database.fmp12 # creator, version date, size, counts
fmp12 tables database.fmp12 # id, name, number of fields and records
fmp12 fields database.fmp12 Contacts # type, data type, storage, auto-enter, calculation
fmp12 records database.fmp12 Contacts -l 10 # the first 10 records
fmp12 scripts database.fmp12 # id, name and number of steps
fmp12 scripts database.fmp12 "Script 1" # the steps of one script
fmp12 scripts database.fmp12 "Script 1" -n # ... with line numbers
Output is text for reading in a terminal: a table, labelled lines for info, and for one script a line per step as in FileMaker's script editor, numbered with -n/--number (Set Variable [ $i ; 0 ; 1 ], with a // in front of a disabled step, # text for a comment and an empty line for a blank line). Add -j/--json to any of these commands to get minified JSON instead: an array with one object per row, or for info a single object. JSON values are strings, as in the library, except the booleans Enabled (of a script step) and Indexed (of a field); a record with nothing stored for a column has null. A value with line breaks (a formula, say) is shown on one line in the text, with ↵ for each break, and kept as it is in JSON.
Records come out in record order and tables in id order, so the output is the same on every run. A script step of a type the crate doesn't recognise is listed as Unknown (opcode) and warned about on stderr. Errors go to stderr with a non-zero exit code; an unknown table or script name lists the names that do exist.
Usage
Everything starts with FmpFile::open, which reads the whole file into memory and parses it.
File information
let file = fmp12::FmpFile::open("database.fmp12")?;
file.file_size(); // size on disk, in bytes
file.creator_name(); // the app that created it, e.g. "Pro 12.0"
file.version_date(); // a chrono::NaiveDate: the FileMaker version's save date
Tables
for table in file.tables() {
println!("{} (id {})", table.name(), table.id());
}
let contacts = file.table("Contacts"); // Option<&Table>, looked up by name
Columns (fields)
let table = file.table("Contacts").unwrap();
for column in table.columns() {
println!("{}: {:?}", column.name(), column.data_type());
}
let column = table.column("Email").unwrap();
column.field_type(); // FieldType: Simple, Calculation, Summary
column.data_type(); // DataType: Text, Number, Date, Time, Timestamp, Container
column.storage_type(); // FieldStorageType: Regular, Global, Calculation, UnstoredCalculation
column.auto_enter(); // AutoEnterOption: serial number, creation/modification stamps, calculations, ...
column.repetitions(); // how many repetitions the field has (1 for a normal field)
column.indexed(); // whether the field is always indexed
column.calculation(); // Option<&Calculation>: the formula of a calculation or auto-enter calculation field
Anything the crate doesn't recognise comes back as an Unknown(u8) variant carrying the raw byte, so a file from a newer FileMaker version still opens.
Records
// All records. The order is not defined.
for record in table.records() {
let name: Option<&str> = record.value(table, "Name");
}
// One record by its index. Indexes start at 1.
let first = table.record(1);
record.value(table, "Column") returns None when the column doesn't exist or the record has no value stored for it. Values are always returned as strings, including numbers, dates and timestamps; the crate does not convert them to typed values.
Scripts
for script in file.scripts() { // ordered by id
println!("{} (id {})", script.name(), script.id());
for step in script.steps() { // in execution order
println!(" {:?} (opcode {})", step.name(), step.opcode());
}
}
let script = file.script("Script 1"); // Option<&Script>, looked up by name
Each ScriptStep has:
step.index(); // u16: the step's index, which keys its arguments in the file
step.opcode(); // u8: the step type's numeric id (e.g. 141 = Set Variable)
step.name(); // Option<&'static str>: "Set Variable", ...; None for an unrecognised opcode
step.is_enabled(); // false for a step that is commented out (disabled) in the script
step.calculations(); // &[Calculation]: the formulas among the step's arguments
step.variable(); // Option<&str>: the variable of Set Variable / Insert from URL, e.g. "$i"
step.comment(); // Option<&str>: the text of a Blank Line/Comment step ("" for a blank line)
step.field(); // Option<&FieldRef>: the field a Set Field step sets
step.layout(); // Option<&Reference>: the layout a Go to Layout step goes to (id and name)
step.script(); // Option<&Reference>: the script a Perform Script step performs (id and name)
step.record(); // &[u8]: the step's raw 28-byte record
step.display_name(); // String: the name, or "Unknown (opcode)" for an unrecognised step
step.details(); // String: the arguments above, separated by " ; "
step.line(); // String: the step as in FileMaker's script editor, e.g. `Set Variable [ $i ; 0 ; 1 ]`
The outline of a script is read (which steps it has, what type each one is, and whether it is enabled), the calculations in the steps' arguments, and what some steps point at: the variable of Set Variable and Insert from URL, the text of comments, the field of Set Field, the layout of Go to Layout and the script of Perform Script. These accessors return None for any other step type. The other arguments (dialog text, options, sort orders) are not decoded yet; record() is the raw data the step's type is derived from. calculations() lists a step's formulas in the order of its arguments, without saying which argument each is; for Set Variable that is the value, then the repetition. line() writes a step as FileMaker's script editor does, without a line number: the name and the arguments in brackets, // in front of a disabled step, # text for a comment and an empty line for a blank line. Script folders and separators haven't been verified against a real file, so they may show up as scripts with an empty name.
Calculations
let table = file.table("Rooms").unwrap();
let formula = table.column("RemainingDays").unwrap().calculation().unwrap();
println!("{formula}"); // RemainingHours / 24
for token in formula.tokens() { // in order, white space included
match token {
fmp12::Token::Field(field) => println!("{:?}::{:?}", field.table(), field.field()),
fmp12::Token::Function(function) => println!("{:?}", function.name()),
_ => {}
}
}
Display writes the formula back as the text FileMaker shows, with every space and line break, FileMaker's English function names, and field references with the names they have in the file. tokens() gives it as a list of Tokens: Whitespace, Text (unescaped), Number (as decimal text), Name (such as a Let variable), Variable ($x), Field, Function, Operator and Unknown. Fields are Option<&str> names because a reference whose table or field can't be found is written as Table#3d00001::Field#17.
Only the functions and operators that occur in the test files are named; any other function is a Function whose name() is None (and whose code() you can still use), and an unrecognised byte is a Token::Unknown. Comments in formulas are not decoded yet. Only the formulas of fields and script steps are read, not those of layouts.
Adding records (in memory only)
let mut file = fmp12::FmpFile::open("database.fmp12")?;
let table = file.table_mut("Contacts").unwrap();
let new_index = table
.new_record([("Name", "Ada Lovelace"), ("Email", "ada@example.com")])?
.index();
This adds the record to the in-memory Table only. Nothing is written back to the file. An unknown column name returns FmpError::UnknownColumn.
Text
FileMaker stores strings obfuscated with a simple XOR (0x5A) and compressed with the Standard Compression Scheme for Unicode (SCSU). This crate undoes the XOR and decompresses the SCSU, so text in any script comes out as regular UTF-8 strings. Malformed sequences are replaced with U+FFFD rather than failing the read.
Errors
Every fallible call returns FmpError:
| Variant | Meaning |
|---|---|
BadMagic |
The file does not start with the .fmp12 signature, so it is probably not an .fmp12 file. |
BadHeader |
The header couldn't be parsed (e.g. an unreadable version date). |
BadSectorHeader |
The first sector claims a previous sector, which should never happen. |
SectorOutOfRange(id) |
The sector chain points past the end of the file. |
UnknownChunkCode(code) |
A data chunk uses a code this crate can't decode. |
MalformedChunk |
A chunk's declared length doesn't match the data after it. |
UnexpectedEof |
The file ends before the format says it should. |
UnknownColumn { table, column } |
new_record was given a column the table doesn't have. |
Io(..) |
The file couldn't be read. |
Roadmap
- File header: creator app, version date, size
- Tables and their names
- Field definitions: type, data type, storage, auto-enter, repetitions, indexing
- Record values, as text
- Scripts: names, and each step's type, index and whether it is enabled
- Calculations: the formulas of calculation fields, auto-enter calculations and script step arguments, as tokens
- What script steps point at: variables, comment text, the Set Field field, the layout of Go to Layout, the script of Perform Script
- Adding records to an in-memory table
- A command-line tool (
fmp12-cli) to list tables, fields, records and scripts - Forward compatibility: unrecognised values come back as
Unknown(..)variants
Next up
- The remaining script step arguments (dialog text and buttons, options such as Set Error Capture on/off, sort orders, ...)
- The rest of the functions and operators of calculations (only those in the test files are named), comments in formulas, and a tree of the formula instead of a list of tokens
- Calculations in layouts (conditional formatting, hide conditions) and in field validation
Later
- Relationships and table occurrences
- Layouts
- Value lists
- Field validation rules
- Container data (images, files): the field type is reported today, but not its contents
- Script folders and separators, and empty scripts (not yet checked against a real file)
- Writing or saving changes back to a file
Non-goals
- The older
.fp3/.fp5formats, which are structured differently
How it works
You don't need this section to use the library, but it helps when something fails to parse.
An .fmp12 file is a chain of 4096-byte sectors:
- The header (sector 0) starts with a magic number plus the text
HBAM7, and holds the creator app name and the version date. A leading sector 1 is unused. - Body sectors form a linked list: each says which sector comes next, and they may be stored out of order.
FmpFile::openfollows the chain from the start. - Each sector's payload is a stream of small encoded chunks: push or pop a path segment, store a value at the current path, store a key/value pair, or store one segment of a large value.
- Replaying every chunk builds a dictionary: a tree, like a tiny file system, where a path such as
[3].[16].[5]holds the table definitions. Values are plain bytes until something interprets them. - The table reader looks at well-known paths in that tree, decodes the text, reads each field's flag bytes into the
FieldType/DataType/etc. enums, and gathers the records. The script reader does the same for[17].[5]: it splits each script's step code into 28-byte records and reads the step type (negative for a disabled step) from bytes 20–21, and the layout, script or field a step points at from bytes 6 onward. The calculation reader turns the formula stored at a field's or a step argument's calculation node into tokens: text, numbers, field references (resolved to names through the table occurrences), variables, functions and operators.
Only the final step's results are public API. The earlier layers are internal to the crate.
Project layout
fmp12-cli/ the `fmp12` command-line tool (its own crate; depends on this one)
src/main.rs entry point and error reporting
src/cli.rs the arguments and subcommands (clap)
src/commands.rs one function per subcommand
src/render.rs text tables and minified JSON output
tests/cli.rs runs the binary against the fixtures
src/
lib.rs crate docs and public exports
file.rs FmpFile: opening a file, header parsing, walking the sector chain
sector.rs reading one 4096-byte sector and its chunks
chunk.rs decoding the variable-length chunk encoding
dictionary.rs the path-addressed tree that chunks are replayed into
table.rs Table, Column, Record and the field-flag enums
script.rs Script, ScriptStep and the step type names
calculation.rs Calculation, Token and the decoding of formulas
bytes.rs big-endian integers and the XOR text decoding
scsu.rs SCSU (Unicode compression) decoder
error.rs FmpError
tests/
open_file.rs integration tests against a real file
calculations.rs integration tests for calculations and script steps, checked against the XML export
fixtures/ Untitled.fmp12 (a small FileMaker 12 sample database)
Scripts.fmp12 (one script: a Show Custom Dialog step among comments and blank lines),
Roommatch.fmp12 (two tables, 304 records, four scripts),
and Roommatch_fmp12.xml (FileMaker's own XML export of it, UTF-16)
docs/fmp12/
README.md index of our notes on the file format, and how far to trust them
file.md header and sectors
chunks.md chunk encoding, integers and text
dictionary.md the path tree and a map of known paths
tables.md tables, fields and records
scripts.md scripts and script steps
calculations.md calculation formulas and how they are stored
_fmplib.md community notes on the format (Rasmus20B), unmodified
_fmptools.txt community notes on the format (Evan Miller), unmodified
Development
cargo build
cargo test # unit tests, integration tests against the fixture, and the doc example
cargo clippy
The repo is a Cargo workspace of the library and fmp12-cli; these commands cover both. Add -p fmp12 or -p fmp12-cli to pick one. fmp12-cli/tests/cli.rs runs the fmp12 binary against the fixtures below.
The integration tests open tests/fixtures/Untitled.fmp12, a one-table database with three records, and check the header, the table and column metadata, record reading, and in-memory record creation. Scripts are tested against tests/fixtures/Scripts.fmp12, and tests/fixtures/Roommatch.fmp12, a larger two-table database, checks that a file with long key-value chunks opens and reads its tables and scripts. tests/calculations.rs compares every script's steps, what they point at, and every formula the crate reads from Roommatch with tests/fixtures/Roommatch_fmp12.xml, the XML that FileMaker (Save a Copy as XML) wrote for the same file.
Because the format is reverse-engineered, chunk decoding in chunk.rs mirrors the community notes byte for byte. When a real-world file fails to parse, the docs/fmp12/ notes are the first place to look for what the format does there.
About
Thanks for your interest in fmplib! In case you have any questions, ideas, or remarks, please feel free to drop a line to romein@smartyellow.nl. Also do let me know what you're using fmplib for.
Released under the MIT License. Copyright © 2025-present Romein van Buren.