Skip to content

Commit 4ffee36

Browse files
authored
Merge pull request #23 from kduma-OSS/claude/pcf-compact-pfs-session-chain-gM5hG
2 parents d1e87c3 + 77e69a5 commit 4ffee36

12 files changed

Lines changed: 613 additions & 7 deletions

File tree

reference/PFS-MS-v1.0/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,20 @@ cargo run --bin pfs -- extract backup.pfs ./restore --at 2 # by session
131131
cargo run --bin pfs -- extract backup.pfs ./restore --at-time 1700000000000
132132
```
133133

134+
### Compaction
135+
136+
`pfs compact` rebuilds a multi-session file into a single fresh session holding
137+
the current tree, **discarding history** (Section 15): deleted nodes are gone,
138+
superseded versions and delta chains collapse to the newest full content, and
139+
abandoned tails are reclaimed. The output is a fully valid, verifiable PFS-MS
140+
file. (Generic `pcf-compact` must *not* be used on a PFS-MS file — it would
141+
corrupt the session chain.)
142+
143+
```
144+
cargo run --bin pfs -- compact fs.pfs # in place
145+
cargo run --bin pfs -- compact fs.pfs out.pfs # to a new file
146+
```
147+
134148
POSIX permission bits and modification time are captured on import and restored
135149
on extract; pass `--no-metadata` (on either side) to skip this, and `--store` to
136150
disable compression. Symlinks and other non-regular files are skipped with a
@@ -184,12 +198,14 @@ reference/PFS-MS-v1.0/
184198
│ ├── tree.rs # liveness, tree, reconstruction (Sections 9.3, 10)
185199
│ ├── fs.rs # high-level FsReader
186200
│ ├── dirsync.rs # directory <-> archive tooling (create/update/extract)
201+
│ ├── compact.rs # single-session compaction (Section 15)
187202
│ ├── vector.rs # canonical Section 17 reference vector
188203
│ └── bin/pfs.rs # demo CLI
189204
├── tests/
190205
│ ├── roundtrip.rs # end-to-end black-box tests
191206
│ ├── coverage.rs # targeted error-path / edge-case tests
192207
│ ├── dirsync.rs # directory create/update/extract round-trips
208+
│ ├── compact.rs # single-session compaction round-trips
193209
│ └── spec_compliance.rs # one test per normative MUST (R1..R8, W2/W3)
194210
└── examples/
195211
└── gen_testvector.rs # writes pfs_ms_testvector.bin + hex dumps

reference/PFS-MS-v1.0/src/bin/pfs.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
//! pfs create <archive> <dir> [--store] [--no-metadata]
1818
//! pfs update <archive> <dir> [--delete] [--store] [--no-metadata]
1919
//! pfs extract <archive> <dir> [--at <seq>] [--at-time <unix_ms>] [--no-metadata]
20+
//! pfs compact <file> [<out>] # rebuild as one fresh session (discards history)
2021
//! pfs keygen <priv_out> <pub_out>
2122
//! pfs sign <file> --key <priv> [--resign]
2223
//! pfs verify-sig <file> [--key <trusted_pub>] [--no-recheck]
@@ -70,6 +71,7 @@ fn run(args: &[String]) -> CliResult {
7071
"create" => cmd_create(rest),
7172
"update" => cmd_update(rest),
7273
"extract" => cmd_extract(rest),
74+
"compact" => cmd_compact(rest),
7375
"keygen" => cmd_keygen(rest),
7476
"sign" => cmd_sign(rest),
7577
"verify-sig" => cmd_verify_sig(rest),
@@ -83,7 +85,7 @@ fn run(args: &[String]) -> CliResult {
8385

8486
fn print_usage() {
8587
eprintln!(
86-
"usage:\n pfs mkfs <file> [--key <priv>]\n pfs mkdir <file> <path> [--key <priv>]\n pfs put <file> <path> [<src|->] [--store] [--key <priv>]\n pfs mv <file> <src> <dst> [--key <priv>]\n pfs rm <file> <path> [--key <priv>]\n pfs ls <file> [<path>]\n pfs cat <file> <path>\n pfs get <file> <path> <out>\n pfs log <file>\n pfs verify <file>\n pfs create <archive> <dir> [--store] [--no-metadata] [--key <priv>]\n pfs update <archive> <dir> [--delete] [--store] [--no-metadata] [--key <priv>]\n pfs extract <archive> <dir> [--at <seq>] [--at-time <unix_ms>] [--no-metadata]\n pfs keygen <priv_out> <pub_out>\n pfs sign <file> --key <priv> [--resign]\n pfs verify-sig <file> [--key <trusted_pub>] [--no-recheck]\n\nmutating commands accept --key <priv> to auto-sign after the commit."
88+
"usage:\n pfs mkfs <file> [--key <priv>]\n pfs mkdir <file> <path> [--key <priv>]\n pfs put <file> <path> [<src|->] [--store] [--key <priv>]\n pfs mv <file> <src> <dst> [--key <priv>]\n pfs rm <file> <path> [--key <priv>]\n pfs ls <file> [<path>]\n pfs cat <file> <path>\n pfs get <file> <path> <out>\n pfs log <file>\n pfs verify <file>\n pfs create <archive> <dir> [--store] [--no-metadata] [--key <priv>]\n pfs update <archive> <dir> [--delete] [--store] [--no-metadata] [--key <priv>]\n pfs extract <archive> <dir> [--at <seq>] [--at-time <unix_ms>] [--no-metadata]\n pfs compact <file> [<out>]\n pfs keygen <priv_out> <pub_out>\n pfs sign <file> --key <priv> [--resign]\n pfs verify-sig <file> [--key <trusted_pub>] [--no-recheck]\n\nmutating commands accept --key <priv> to auto-sign after the commit."
8789
);
8890
}
8991

@@ -321,6 +323,14 @@ fn cmd_update(a: &[String]) -> CliResult {
321323
maybe_autosign(archive, p.values.get("key"))
322324
}
323325

326+
fn cmd_compact(a: &[String]) -> CliResult {
327+
let p = parse_flags(a, &[])?;
328+
let file = pos(&p, 0, "<file>")?;
329+
// In-place when <out> is omitted; otherwise write a fresh file.
330+
let out = p.positional.get(1).map(String::as_str).unwrap_or(file);
331+
pfs_ms::compact_archive(Path::new(file), Path::new(out)).map_err(|e| e.to_string())
332+
}
333+
324334
fn cmd_extract(a: &[String]) -> CliResult {
325335
let p = parse_flags(a, &["at", "at-time"])?;
326336
let archive = p.positional.first().ok_or("missing argument: <archive>")?;
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! PFS-MS-aware compaction: rebuild a multi-session file into a fresh,
2+
//! single-session snapshot of its current state (spec Section 15).
3+
//!
4+
//! Generic PCF compaction (PCF Section 11.5, [`pcf::Container::compacted_image`])
5+
//! MUST NOT be used on a PFS-MS file: it repacks entries into shared blocks and
6+
//! rewrites every `table_hash`, which destroys the one-`PFS_SESSION`-per-HEAD
7+
//! invariant and the inter-session hash commitments (`member_blocks_digest`,
8+
//! `prev_session_hash`). The result no longer scans or verifies as PFS-MS.
9+
//!
10+
//! Compaction here is therefore profile-aware. It resolves the live tree at the
11+
//! head and re-emits it as **one** session (`session_seq = 1`,
12+
//! `prev_session_hash = 0`). This is a full rewrite that *discards history*:
13+
//!
14+
//! * deleted nodes are gone — only live nodes are re-emitted;
15+
//! * every file is stored as fresh `Direct` (or `Empty`) content, collapsing
16+
//! any delta chain to the newest full version;
17+
//! * superseded versions and abandoned tails are reclaimed.
18+
//!
19+
//! The output is a fully valid, verifiable PFS-MS file.
20+
21+
use std::fs::{self, OpenOptions};
22+
use std::io::{Read, Seek, Write};
23+
use std::path::{Path, PathBuf};
24+
25+
use pcf::HashAlgo;
26+
27+
use crate::error::{Error, Result};
28+
use crate::fs::FsReader;
29+
use crate::tree::Tree;
30+
use crate::writer::{Change, FsWriter};
31+
use crate::ROOT_NODE_ID;
32+
33+
/// Rebuild the PFS-MS file in `src` into a fresh, single-session image written
34+
/// to `dst`, returning the destination handle.
35+
///
36+
/// The resolved current tree of `src` becomes session 1 (`session_seq = 1`,
37+
/// `prev_session_hash = 0`); history is discarded (Section 15). The source is
38+
/// verified before any output is produced, so a corrupt input is rejected
39+
/// rather than propagated. `dst` must be a fresh, writable, empty handle.
40+
///
41+
/// The whole source tree (every live file's content) is materialised in memory
42+
/// before `dst` is touched, so `src` and `dst` may be distinct handles to the
43+
/// same logical data without interfering.
44+
pub fn compact<R, W>(src: R, mut dst: W) -> Result<W>
45+
where
46+
R: Read + Write + Seek,
47+
W: Read + Write + Seek,
48+
{
49+
let mut r = FsReader::open(src)?;
50+
// Refuse to compact a corrupt source (mirrors pcf-compact's verify-before).
51+
r.verify()?;
52+
53+
let algo = source_hash_algo(&mut r)?;
54+
let tree = r.tree()?;
55+
let changes = collect_changes(&mut r, &tree)?;
56+
57+
let mut w = FsWriter::create(&mut dst, algo)?;
58+
w.set_writer_id(b"pfs-compact");
59+
w.set_compression(true);
60+
// An empty source tree yields no changes; `commit_changes` then commits
61+
// nothing and `dst` stays at the valid empty-table state from `create`.
62+
w.commit_changes(&changes)?;
63+
drop(w);
64+
65+
Ok(dst)
66+
}
67+
68+
/// Compact the PFS-MS file at `src` into `dst` on the host filesystem.
69+
///
70+
/// When `dst == src` the file is compacted in place. Output is written to a
71+
/// sibling temp file, fsynced, and atomically renamed into place, so a crash
72+
/// leaves either the original or the fully written replacement.
73+
pub fn compact_archive(src: &Path, dst: &Path) -> Result<()> {
74+
// Build the compacted image in memory from the source.
75+
let image = {
76+
let in_file = OpenOptions::new()
77+
.read(true)
78+
.write(true)
79+
.open(src)
80+
.map_err(Error::Io)?;
81+
let out = compact(in_file, std::io::Cursor::new(Vec::new()))?;
82+
out.into_inner()
83+
};
84+
85+
// Write to a sibling temp file, fsync, then atomically rename into place.
86+
let dir = dst.parent().filter(|p| !p.as_os_str().is_empty());
87+
let tmp: PathBuf = {
88+
let name = dst
89+
.file_name()
90+
.map(|n| n.to_string_lossy().into_owned())
91+
.unwrap_or_else(|| "pfs".into());
92+
let pid = std::process::id();
93+
let tmp_name = format!(".{name}.pfs-compact.tmp.{pid}");
94+
match dir {
95+
Some(d) => d.join(tmp_name),
96+
None => PathBuf::from(tmp_name),
97+
}
98+
};
99+
100+
let mut f = OpenOptions::new()
101+
.read(true)
102+
.write(true)
103+
.create(true)
104+
.truncate(true)
105+
.open(&tmp)
106+
.map_err(Error::Io)?;
107+
f.write_all(&image).map_err(Error::Io)?;
108+
f.sync_all().map_err(Error::Io)?;
109+
drop(f);
110+
111+
fs::rename(&tmp, dst).map_err(|e| {
112+
let _ = fs::remove_file(&tmp);
113+
Error::Io(e)
114+
})?;
115+
Ok(())
116+
}
117+
118+
/// The table-hash algorithm of the source's head session (`Sha256` if empty).
119+
fn source_hash_algo<S: Read + Write + Seek>(r: &mut FsReader<S>) -> Result<HashAlgo> {
120+
let scan = r.scan()?;
121+
Ok(scan
122+
.sessions
123+
.first()
124+
.map(|s| s.block_hashes[0].2)
125+
.unwrap_or(HashAlgo::Sha256))
126+
}
127+
128+
/// Build the change set re-creating the whole live tree in one session.
129+
fn collect_changes<S: Read + Write + Seek>(
130+
r: &mut FsReader<S>,
131+
tree: &Tree,
132+
) -> Result<Vec<Change>> {
133+
let mut out = Vec::new();
134+
walk(r, tree, ROOT_NODE_ID, "", &mut out)?;
135+
Ok(out)
136+
}
137+
138+
fn walk<S: Read + Write + Seek>(
139+
r: &mut FsReader<S>,
140+
tree: &Tree,
141+
node: [u8; 16],
142+
prefix: &str,
143+
out: &mut Vec<Change>,
144+
) -> Result<()> {
145+
let kids = match tree.children.get(&node) {
146+
Some(k) => k.clone(),
147+
None => return Ok(()),
148+
};
149+
for cid in kids {
150+
let rec = tree.nodes.get(&cid).ok_or(Error::NotFound)?;
151+
let name = rec.name_str();
152+
let rel = if prefix.is_empty() {
153+
name
154+
} else {
155+
format!("{prefix}/{name}")
156+
};
157+
if rec.is_dir() {
158+
// Emit every directory (preserving empty ones), then recurse.
159+
out.push(Change::Mkdir {
160+
path: rel.clone(),
161+
mode: rec.mode,
162+
mtime_unix_ms: rec.mtime_unix_ms,
163+
});
164+
walk(r, tree, cid, &rel, out)?;
165+
} else {
166+
// Reconstruct the full current content; re-emitted as Direct/Empty.
167+
let (mode, mtime) = (rec.mode, rec.mtime_unix_ms);
168+
let content = r.read_path(&rel)?;
169+
out.push(Change::PutFile {
170+
path: rel,
171+
content,
172+
mode,
173+
mtime_unix_ms: mtime,
174+
});
175+
}
176+
}
177+
Ok(())
178+
}

reference/PFS-MS-v1.0/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
//! assert_eq!(r.read_path("docs/hello.txt").unwrap(), b"Hello, world\n");
3939
//! ```
4040
41+
mod compact;
4142
mod compress;
4243
pub mod consts;
4344
mod delta;
@@ -52,6 +53,7 @@ mod tree;
5253
mod vector;
5354
mod writer;
5455

56+
pub use compact::{compact, compact_archive};
5557
pub use compress::{compress_deflate, decompress};
5658
pub use consts::*;
5759
pub use dirsync::{create_archive, extract_archive, session_at_time, update_archive, SyncOptions};

0 commit comments

Comments
 (0)