Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ jobs:
fi
done

# littlefs has no packaged fsck; the reference C implementation is
# reached through its Python binding instead (tests/littlefs_external.rs
# skips itself when the module is missing).
- name: Install littlefs reference implementation
run: |
python3 -m pip install --user --break-system-packages littlefs-python \
|| python3 -m pip install --user littlefs-python \
|| echo "littlefs-python unavailable (littlefs cross-checks will skip)"
python3 -c "import littlefs; print('littlefs-python ok')" || true

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
Expand Down
36 changes: 35 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.22](https://github.com/KarpelesLab/fstool/compare/v0.4.21...v0.4.22) - 2026-08-17

### Added

- *(littlefs)* read, write and in-place edits for lfs2 images
- *(web)* create, edit and download images in the browser
- *(memedit)* in-memory authoring — blank filesystems, partitioned disks

### Fixed

- *(ext)* honour journal revoke records across transactions
- *(ext)* decode HTree directory roots and honour journal revoke blocks ([#32](https://github.com/KarpelesLab/fstool/pull/32))

### Other

- *(littlefs)* redraw the tag and skip-list diagrams in our own form

### Added

- *(littlefs)* new backend for the embedded-flash filesystem (`lfs2`, disk
versions 2.0 and 2.1): read, write, and in-place edits. Metadata pairs are
replayed from their CRC-committed logs (tags, splices, tails, global-state
deltas, lfs2.1 forward-CRCs) and written back as compactions; files live
inline in metadata or in CTZ skip-lists, which are rebuilt only from the
first changed block onwards so a partial write leaves earlier blocks
untouched. Block allocation reconstructs the in-use map by traversing the
volume, as littlefs itself does. Wired into `create -t littlefs`, the TOML
spec (`type = "littlefs"`, with `block_size` / `block_count` / `prog_size` /
`version` / `name_max` / `inline_max` options), `repack --fs-type littlefs`,
`info`, `add`/`rm`, the in-memory authoring surface, and the browser build.
littlefs user attributes surface as `user.littlefs.<type>` extended
attributes; the format has no symlinks, device nodes or POSIX metadata, so
those are refused rather than faked. Cross-validated in both directions
against the reference C implementation via `littlefs-python`, including
handing an image back and forth mid-edit.

- *(memedit)* new in-memory authoring surface: `Workspace` formats a blank
filesystem or lays out a partitioned disk (MBR/GPT), takes files and
directories, and hands back the image bytes at any point.
`creatable_filesystems()` advertises 14 types with their real minimum sizes.
`creatable_filesystems()` advertises 15 types with their real minimum sizes.
- *(wasm)* `Workspace` and `creatable_filesystems()` bindings, so the browser
build can author images as well as read them.
- *(web)* "Create a new image" mode: pick a filesystem and size, or build a
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fstool"
version = "0.4.21"
version = "0.4.22"
edition = "2024"
# Floor is the edition-2024 minimum (1.85) bumped to 1.88 by the
# `purecrypto` dependency (encrypted-DMG crypto), whose MSRV is 1.88 as of
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fstool repack base.tar patch.tar flat.tar # OCI-style layer merge with .w

fstool also ships as a static, client-side web app. It builds images as well
as reads them: format a blank filesystem (ext2/3/4, FAT12/16/32, exFAT, NTFS,
XFS, HFS+, HFS, AFFS, F2FS, GRF), or lay out an MBR/GPT disk with a
XFS, HFS+, HFS, AFFS, F2FS, littlefs, GRF), or lay out an MBR/GPT disk with a
filesystem per partition, add files, and download the image — then keep
editing and download again. Or upload any archive or disk
image, browse what's inside, extract individual files, and convert the whole
Expand Down Expand Up @@ -74,6 +74,7 @@ See [web/README.md](web/README.md) for the build and local-dev steps.
| APFS | ✅ | ✅ | 🚧 | **Read**: multi-level omap + fs-tree, directory listings + file extents, embedded xattrs, snapshots (read-only, single-leaf snap-meta). **Write**: format + `create_dir`/`create_file`/`create_symlink` + `chmod`/`chown`/`set_times`/`rename`/`unlink`/`link` via fresh COW checkpoints (spaceman with IP ring + SFQ free-queues), round-tripped through a real macOS mount. **Gaps**: in-place edits are whole-file overwrite (no partial-extent COW); `UF_COMPRESSED`/decmpfs files read as empty; encryption, sealed-volume integrity, Fusion tiering, and dstream-backed xattrs are refused; not yet `fsck_apfs`-clean |
| NTFS | ✅ | ✅ | ✅ | MFT, attributes, $DATA + ADS, indexes; xattr map; multi-class `$Secure` ($SDS/$SDH/$SII); real `$LogFile` LFS records (Path A) |
| F2FS | ✅ | ✅ | — | CP / NAT / dnodes / inline data + dentries; writer passes `fsck.f2fs`; **build-once** — the writer serializes the whole FS from memory at flush, so a re-opened image is read-only (reports `Immutable`) |
| littlefs | ✅ | ✅ | ✅ | The embedded-flash filesystem (`lfs2`, disk versions 2.0 + 2.1): metadata pairs with CRC-committed logs, CTZ skip-list files, inline small files, user attributes (surfaced as `user.littlefs.<type>` xattrs). Every mutation is a real littlefs commit, so `create` / `repack` / `add` / `rm` / `open_file_rw` all write images the reference C implementation mounts and keeps writing to — cross-validated both directions against `littlefs-python` (upstream `lfs.c`), including block-for-block agreement on which blocks are live. No symlinks, device nodes or POSIX metadata: the format has none |
| SquashFS | ✅ | ✅ | — | gzip / xz / lz4 / zstd / lzo / lzma via Cargo features; writer round-trips via `unsquashfs`; repack-only |
| ISO 9660 | ✅ | ✅ | — | PVD + Joliet (UCS-2) + Rock Ridge (PX/NM/SL/TF) + El Torito boot catalog; repack-only |
| GRF | ✅ | ✅ | ✅ | Gravity Ragnarok Online archive — v0x102 / v0x103 / v0x200; permutation cipher (`MIXCRYPT` / `DES`); CP949 filenames |
Expand All @@ -90,7 +91,7 @@ See [web/README.md](web/README.md) for the build and local-dev steps.

`🚧` marks writers / mutation paths with known gaps (see Limitations).
All writable filesystems — ext2/3/4, FAT12/16/32, exFAT, XFS, HFS+, NTFS,
APFS, F2FS, SquashFS, ISO 9660, GRF — implement a single
APFS, F2FS, littlefs, SquashFS, ISO 9660, GRF — implement a single
`Filesystem` trait, so the CLI (`build`, `repack`, `add`, `rm`) and
the TOML `[filesystem] type = "…"` spec dispatch through one
codepath; pick a target FS by setting `--fs-type` on `repack` or
Expand Down Expand Up @@ -123,7 +124,7 @@ through xattrs under `user.ntfs.*` and `system.ntfs_security`.

| Command | What it does |
|---------------|-------------------------------------------------------------------------|
| `create` | Build a bare image of any supported FS (`-t ext4` / `fat12` / `fat16` / `fat32` / `xfs` / `hfs+` / `ntfs` / `f2fs` / `squashfs` / `iso` / `apfs` / `exfat` / `grf` / `zip` / `cpio` / `ar`) from a host directory tree. FS-specific knobs go through `-O key=val,key=val`. |
| `create` | Build a bare image of any supported FS (`-t ext4` / `fat12` / `fat16` / `fat32` / `xfs` / `hfs+` / `ntfs` / `f2fs` / `littlefs` / `squashfs` / `iso` / `apfs` / `exfat` / `grf` / `zip` / `cpio` / `ar`) from a host directory tree. FS-specific knobs go through `-O key=val,key=val`. |
| `build` | Build from a TOML spec — bare FS or a partitioned disk image. |
| `info` | Print partition table (whole-disk) or FS summary + root listing. |
| `ls` | List a directory inside an image; `-R` walks subdirectories recursively. |
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ point `source` at your own tree to populate the filesystems.
|------|--------|
| [`bare-ext4.toml`](bare-ext4.toml) | A single ext4 filesystem, no partition table (the `genext2fs` replacement). |
| [`bare-fat32.toml`](bare-fat32.toml) | A single FAT32 filesystem, no partition table. |
| [`bare-littlefs.toml`](bare-littlefs.toml) | A single littlefs image for embedded flash (block / program size tuned to the target part). |
| [`raspberry-pi.toml`](raspberry-pi.toml) | Raspberry Pi SD card — MBR with a FAT32 boot partition + ext4 root. |
| [`efi-disk.toml`](efi-disk.toml) | UEFI-bootable GPT disk (also a UEFI USB image) — ESP (FAT32) + ext4 root. |
| [`bios-legacy-disk.toml`](bios-legacy-disk.toml) | Legacy-BIOS MBR disk — ext2 `/boot` + ext4 root. |
Expand Down
42 changes: 42 additions & 0 deletions examples/bare-littlefs.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Example fstool spec: a bare littlefs image for embedded flash.
#
# fstool build examples/bare-littlefs.toml -o /tmp/lfs.img
# fstool info /tmp/lfs.img
#
# littlefs is the fail-safe filesystem used on microcontroller NOR/NAND
# flash. Sizing is exact: the image is whatever the metadata pairs and the
# file data need, so match `size` to the flash region you will write it to.

[filesystem]
type = "littlefs"

# Total image size. littlefs derives its block count from this and the
# block size below, so keep it a whole multiple of `block_size`.
size = "1MiB"

# Host directory whose contents become the image's tree. Optional —
# omit it for an empty volume with just the root directory.
# source = "./rootfs"

# Erase-block size of the target flash. Must be a power of two, at least
# 128 bytes. 4 KiB matches most NOR parts; some use 64 KiB.
block_size = 4096

[filesystem.options]
# Program (page) size the target flash writes in. Commits are padded to
# it so a device mounting this image can append in place.
prog_size = 256

# On-disk version: "2.1" (default, current) or "2.0" for firmware built
# against a littlefs older than v2.5, whose reader predates forward-CRC
# tags.
version = "2.1"

# Files at or below this many bytes are stored inside their directory's
# metadata instead of getting blocks of their own. Defaults to an eighth
# of a block.
# inline_max = 512

# Longest filename the volume accepts (must match what the firmware's
# LFS_NAME_MAX allows). Default 255.
# name_max = 255
13 changes: 13 additions & 0 deletions src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,19 @@ pub fn plan_size(source: &Source, plan: &mut dyn FsSizePlan) -> Result<u64> {
Ok(plan.total_size())
}

/// Content-fit sizing for an already-open filesystem — the [`plan_size`]
/// counterpart `repack --shrink` uses, where the source is mounted rather
/// than a path. Metadata only: file bodies are never read.
pub fn plan_size_fs(
fs: &mut AnyFs,
dev: &mut dyn BlockDevice,
plan: &mut dyn FsSizePlan,
) -> Result<u64> {
let mut sink = SizingSink { plan };
walk_anyfs(fs, dev, &mut sink)?;
Ok(plan.total_size())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
99 changes: 98 additions & 1 deletion src/bin/fstool/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,15 @@ fn repack_cmd(
// Tar output streams to a file; no pre-sized device, so
// the destination size is unused.
"tar" => 0,
"littlefs" | "lfs" => {
// littlefs reserves nothing up front, so its own size
// plan gives an exact content fit.
let opts = fstool::fs::littlefs::LittleFsFormatOpts::default();
let mut plan = <fstool::fs::littlefs::LittleFs as fstool::fs::FilesystemFactory>
::size_plan(&opts)
.expect("littlefs has a size plan");
fstool::analyze::plan_size_fs(&mut src_fs, src_dev, plan.as_mut())?
}
"iso" | "iso9660" => {
// ISO writer needs ~32 MiB headroom for a small tree.
// Real sizing happens during flush; we just want enough
Expand Down Expand Up @@ -1114,6 +1123,12 @@ fn repack_cmd(
let bytes = sum_source_file_bytes(src_dev, &mut src_fs).unwrap_or(0);
bytes.saturating_mul(2).saturating_add(16 * 1024 * 1024)
}
"littlefs" | "lfs" => {
// File data rounded up to whole blocks, plus room for
// metadata pairs and later edits.
let bytes = sum_source_file_bytes(src_dev, &mut src_fs).unwrap_or(0);
bytes.saturating_mul(2).saturating_add(1024 * 1024)
}
_ => src_total,
},
};
Expand Down Expand Up @@ -1241,6 +1256,16 @@ fn repack_cmd(
src_dev,
false,
)?,
// littlefs has neither symlinks nor device nodes, so the sink
// runs lossy: those entries are skipped instead of failing the
// whole repack.
"littlefs" | "lfs" => repack_via_trait::<fstool::fs::littlefs::LittleFs>(
dst_dev.as_mut(),
&fstool::fs::littlefs::LittleFsFormatOpts::default(),
&mut src_fs,
src_dev,
true,
)?,
"squashfs" => repack_via_trait::<fstool::fs::squashfs::Squashfs>(
dst_dev.as_mut(),
&fstool::fs::squashfs::FormatOpts::default(),
Expand Down Expand Up @@ -1602,6 +1627,13 @@ fn repack_layered_to_dst(
&layers,
false,
)?,
"littlefs" | "lfs" => repack_layered_via_trait::<fstool::fs::littlefs::LittleFs>(
dst_dev.as_mut(),
&fstool::fs::littlefs::LittleFsFormatOpts::default(),
&model,
&layers,
true,
)?,
"iso" | "iso9660" => {
let opts = fstool::fs::iso9660::FormatOpts {
volume_id: "FSTOOL".into(),
Expand Down Expand Up @@ -1845,6 +1877,14 @@ fn repack_tar_stream_to_fs(
codec,
false,
)?,
// Lossy: littlefs stores neither symlinks nor device nodes.
"littlefs" | "lfs" => repack_stream_via_trait::<fstool::fs::littlefs::LittleFs>(
dst_dev.as_mut(),
&fstool::fs::littlefs::LittleFsFormatOpts::default(),
tar_path,
codec,
true,
)?,
"squashfs" => repack_stream_via_trait::<fstool::fs::squashfs::Squashfs>(
dst_dev.as_mut(),
&fstool::fs::squashfs::FormatOpts::default(),
Expand Down Expand Up @@ -2570,6 +2610,46 @@ fn create_cmd(args: CreateArgs<'_>) -> fstool::Result<()> {
},
DEFAULT_MIN_SIZE,
)?,
"littlefs" | "lfs" => create_via_factory::<fstool::fs::littlefs::LittleFs>(
"littlefs",
source.as_ref(),
args.output,
args.size,
opts,
is_device,
qcow2_cluster_size,
fstool::fs::littlefs::LittleFsFormatOpts::default(),
|o: &mut fstool::fs::littlefs::LittleFsFormatOpts, m| {
if let Some(b) = m.take_u32("block_size")? {
o.block_size = b;
}
if let Some(b) = m.take_u32("block_count")? {
o.block_count = Some(b);
}
if let Some(p) = m.take_u32("prog_size")? {
o.prog_size = p;
}
if let Some(v) = m.take_str("version") {
o.disk_version = match v.trim() {
"2.0" => fstool::fs::littlefs::DISK_VERSION_2_0,
"2.1" => fstool::fs::littlefs::DISK_VERSION_2_1,
other => {
return Err(fstool::Error::InvalidArgument(format!(
"littlefs: unknown disk version {other:?} (use 2.0 or 2.1)"
)));
}
};
}
if let Some(n) = m.take_u32("name_max")? {
o.name_max = n;
}
if let Some(n) = m.take_u32("inline_max")? {
o.inline_max = Some(n);
}
Ok(())
},
DEFAULT_MIN_SIZE,
)?,
"ntfs" => create_via_factory::<fstool::fs::ntfs::Ntfs>(
"ntfs",
source.as_ref(),
Expand Down Expand Up @@ -2695,7 +2775,7 @@ fn create_cmd(args: CreateArgs<'_>) -> fstool::Result<()> {
other => {
return Err(fstool::Error::InvalidArgument(format!(
"create: unknown --type {other:?} (try ext4, fat32, exfat, hfs+, ntfs, \
f2fs, squashfs, xfs, iso, grf, apfs, zip, cpio, ar)"
f2fs, littlefs, squashfs, xfs, iso, grf, apfs, zip, cpio, ar)"
)));
}
}
Expand Down Expand Up @@ -3480,6 +3560,7 @@ fn print_fs_info(dev: &mut dyn fstool::block::BlockDevice, fs: &mut fstool::insp
fstool::inspect::AnyFs::HfsPlus(hfs) => print_hfs_plus_info(hfs),
fstool::inspect::AnyFs::Hfs(hfs) => print_hfs_info(hfs),
fstool::inspect::AnyFs::Affs(affs) => print_affs_info(affs),
fstool::inspect::AnyFs::LittleFs(lfs) => print_littlefs_info(dev, lfs),
fstool::inspect::AnyFs::Apfs(apfs) => print_apfs_info(apfs),
fstool::inspect::AnyFs::Ntfs(ntfs) => print_ntfs_info(ntfs),
fstool::inspect::AnyFs::F2fs(f2) => print_f2fs_info(f2),
Expand Down Expand Up @@ -3630,6 +3711,22 @@ fn print_affs_info(affs: &fstool::fs::affs::Affs) {
);
}

fn print_littlefs_info(
dev: &mut dyn fstool::block::BlockDevice,
lfs: &mut fstool::fs::littlefs::LittleFs,
) {
let (block_size, block_count) = lfs.geometry();
let (major, minor) = lfs.version();
println!("disk version: {major}.{minor}");
println!("block size: {block_size}");
println!("block count: {block_count}");
println!("inline max: {}", lfs.inline_max());
match lfs.used_blocks(dev) {
Ok(used) => println!("blocks used: {used} / {block_count}"),
Err(e) => println!("blocks used: <unavailable: {e}>"),
}
}

fn print_apfs_info(apfs: &fstool::fs::apfs::Apfs) {
println!("total bytes: {}", apfs.total_bytes());
println!("block size: {}", apfs.block_size());
Expand Down
Loading
Loading