- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs/fmp12 | ||
| 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, and the scripts with their steps. It is read-only and still early: it covers the core of a database (tables, fields, records) plus the outline of scripts. 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" }
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
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.record(); // &[u8]: the step's raw 28-byte record
Only the outline of a script is read: which steps it has and what type each one is. The steps' arguments (the variable name and value of a Set Variable, the title and buttons of a Show Custom Dialog, calculations, ...) are not decoded yet; record() is the raw data they are derived from. Script folders and separators haven't been verified against a real file, so they may show up as scripts with an empty name.
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 and index
- Adding records to an in-memory table
- Forward compatibility: unrecognised values come back as
Unknown(..)variants
Next up
- Script step arguments (variable names, dialog text, script parameters, ...)
- Calculations, which are stored as bytecode (needed for script arguments, calculation fields and auto-enter calculations)
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 from byte 21.
Only the final step's results are public API. The earlier layers are internal to the crate.
Project layout
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
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
fixtures/ Untitled.fmp12 (a small FileMaker 12 sample database)
and Scripts.fmp12 (one script with one Show Custom Dialog step)
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
_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 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.
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.