Skip to main content

mkit_cli/commands/
pack_shard.rs

1//! `mkit pack-shard <hash>` — encode an existing pack into Reed-Solomon
2//! shards plus a manifest.
3//!
4//! This is the producer side of SPEC-PACK-SHARDS. Given a pack
5//! object hash, it reads the pack bytes from the local store, gates on
6//! the SPEC §6 size threshold (1 MiB), runs the encoder, and writes:
7//!
8//! ```text
9//!   <out>/packs/<hex>/shards.manifest    (manifest, MKSH/v0 bytes)
10//!   <out>/packs/<hex>/shards/<index>     (one file per shard)
11//! ```
12//!
13//! Operators publish those files to whichever HTTP / S3 location their
14//! clients hit. Shard-aware clients (`mkit-transport-http`,
15//! `mkit-transport-s3` with `--features pack-shards`) discover them via
16//! the predictable URL / key paths.
17//!
18//! Compiled only when the CLI is built with `--features pack-shards`
19//! (default off — the commonware dep stack is large).
20
21use std::fs;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use clap::Parser;
26use mkit_core::hash::{Hash, from_hex, to_hex};
27use mkit_core::pack_shard::{SHARD_SIZE_THRESHOLD, encode_manifest, encode_pack_to_shards};
28use mkit_core::store::ObjectStore;
29
30use crate::clap_shim;
31use crate::exit;
32
33#[derive(Debug, Parser)]
34#[command(
35    name = "mkit pack-shard",
36    about = "Encode a stored pack into Reed-Solomon shards (+ manifest)."
37)]
38struct ShardOpts {
39    /// Hex-encoded BLAKE3 hash of the pack object to shard.
40    hash: String,
41
42    /// Output directory. Defaults to `<repo>/.mkit/pack-shards`.
43    /// Shards are written under `<out>/packs/<hex>/`.
44    #[arg(long)]
45    out: Option<PathBuf>,
46
47    /// Encode even if the pack is below the SPEC §6 size threshold
48    /// (1 MiB). Useful for tests; production producers should leave
49    /// this off.
50    #[arg(long)]
51    force: bool,
52}
53
54#[must_use]
55pub fn run(args: &[String]) -> u8 {
56    let opts = match clap_shim::parse::<ShardOpts>("mkit pack-shard", args) {
57        Ok(o) => o,
58        Err(code) => return code,
59    };
60
61    let cwd = match std::env::current_dir() {
62        Ok(p) => p,
63        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
64    };
65
66    let hash: Hash = match from_hex(&opts.hash) {
67        Ok(h) => h,
68        Err(_) => {
69            return emit_err(
70                &format!("invalid hash '{}': expected 64 hex chars", opts.hash),
71                exit::USAGE,
72            );
73        }
74    };
75
76    let layout = match super::resolve_layout(&cwd) {
77        Ok(layout) => layout,
78        Err(code) => return code,
79    };
80    let store = match ObjectStore::open(&layout) {
81        Ok(s) => s,
82        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
83    };
84
85    let pack = match store.read(&hash) {
86        Ok(b) => b,
87        Err(e) => return emit_err(&format!("read pack {}: {e}", to_hex(&hash)), exit::NOINPUT),
88    };
89
90    // SPEC §6 size gate. Producers should not waste bytes on packs
91    // below 1 MiB; the per-shard Merkle overhead dominates.
92    if !opts.force && (pack.len() as u64) < SHARD_SIZE_THRESHOLD {
93        return emit_err(
94            &format!(
95                "pack is {} bytes, below the {} byte (1 MiB) shard threshold; \
96                 pass --force to encode anyway",
97                pack.len(),
98                SHARD_SIZE_THRESHOLD
99            ),
100            exit::USAGE,
101        );
102    }
103
104    let out_root = opts.out.unwrap_or_else(|| layout.pack_shards_dir());
105
106    let (shards, manifest) =
107        match encode_pack_to_shards(&pack, mkit_core::pack_shard::default_config()) {
108            Ok(p) => p,
109            Err(e) => return emit_err(&format!("encode: {e}"), exit::DATAERR),
110        };
111
112    let hex = to_hex(&hash);
113    let pack_dir = out_root.join("packs").join(&hex);
114    let shards_dir = pack_dir.join("shards");
115
116    if let Err(e) = fs::create_dir_all(&shards_dir) {
117        return emit_err(
118            &format!("mkdir {}: {e}", shards_dir.display()),
119            exit::CANTCREAT,
120        );
121    }
122
123    // Shards first, manifest last — the manifest is the *publish
124    // commit point*. Clients that race the producer either see no
125    // manifest (clean fall-through to monolithic) or see manifest +
126    // all shards (clean shard path). Writing the manifest before the
127    // shards would let a racing reader observe "manifest present,
128    // shards missing", which forces a shard-fetch failure and either
129    // a noisy retry loop or (worse) a silent downgrade.
130    let manifest_bytes = match encode_manifest(&manifest) {
131        Ok(b) => b,
132        Err(e) => return emit_err(&format!("encode manifest: {e}"), exit::DATAERR),
133    };
134
135    for shard in &shards {
136        let path = shards_dir.join(shard.index.to_string());
137        if let Err(e) = write_atomic(&path, &shard.bytes) {
138            return emit_err(&format!("write {}: {e}", path.display()), exit::CANTCREAT);
139        }
140    }
141
142    let manifest_path = pack_dir.join("shards.manifest");
143    if let Err(e) = write_atomic(&manifest_path, &manifest_bytes) {
144        return emit_err(
145            &format!("write {}: {e}", manifest_path.display()),
146            exit::CANTCREAT,
147        );
148    }
149
150    let mut stdout = std::io::stdout().lock();
151    let _ = writeln!(
152        stdout,
153        "wrote {} shards + manifest under {}",
154        shards.len(),
155        pack_dir.display()
156    );
157    exit::OK
158}
159
160/// Write `bytes` to `path` via a same-dir tempfile + rename. Mirrors
161/// the atomic-write pattern used elsewhere in mkit-core but tailored
162/// to the cli's "no-deps" footprint — we don't need fsync since
163/// shards are recoverable from the source pack at any time.
164fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
165    let parent = path.parent().unwrap_or_else(|| Path::new("."));
166    let mut tmp = path.to_path_buf();
167    let fname = path.file_name().and_then(|f| f.to_str()).unwrap_or("shard");
168    tmp.set_file_name(format!(".{fname}.tmp"));
169    {
170        let mut f = fs::OpenOptions::new()
171            .write(true)
172            .create(true)
173            .truncate(true)
174            .open(&tmp)?;
175        f.write_all(bytes)?;
176    }
177    fs::rename(&tmp, path).inspect_err(|_| {
178        // Best-effort cleanup; the tmp file is the only thing left behind.
179        let _ = fs::remove_file(&tmp);
180        let _ = parent; // unused, but kept for clarity in case we add fsync(parent)
181    })
182}
183
184use super::error as emit_err;
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use std::env;
190
191    #[test]
192    fn invalid_hex_returns_usage_error() {
193        // Run with a hash that isn't 64 hex chars. We don't need to
194        // be in a real repo because the error fires before ObjectStore
195        // is touched.
196        let dir = tempfile::tempdir().unwrap();
197        // The env::set_current_dir / set_var pair is process-global;
198        // mkit's other unit tests do the same and run single-threaded
199        // by default.
200        let saved_cwd = env::current_dir().ok();
201        env::set_current_dir(dir.path()).unwrap();
202        let code = run(&["not-hex".to_string()]);
203        assert_eq!(code, exit::USAGE);
204        if let Some(p) = saved_cwd {
205            env::set_current_dir(p).unwrap();
206        }
207    }
208}