nativ 0.3.0

Nativ CLI — compile .nativ DSL to real SwiftUI and Jetpack Compose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! Nativ Live Updates (#144) — signed asset/i18n/config OTA.
//!
//! NOT logic OTA (store-forbidden). The manifest is a small JSON document
//! that lists the files in the bundle and a per-file SHA-256. The whole
//! `manifest.json` is signed with Ed25519; the verifier checks the signature
//! over the exact downloaded bytes (no re-serialization, so Swift/Kotlin
//! clients do not need a canonical JSON encoder).
//!
//! Backend contract (any HTTP server, swappable):
//!   GET <base_url>/manifest.json   — the signed manifest bytes
//!   GET <base_url>/manifest.sig    — 64-byte detached Ed25519 signature
//!   GET <base_url>/<file.path>     — raw file bytes (sha256 must match)
//!
//! Commands:
//!   nativ ota keygen   — write a new Ed25519 keypair (private + public)
//!   nativ ota pack DIR — write manifest.json + manifest.sig for DIR
//!   nativ ota verify   — verify a manifest+sig against a public key

use clap::{Args, Subcommand};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Args)]
pub struct OtaArgs {
    #[command(subcommand)]
    pub command: OtaCommand,
}

#[derive(Subcommand)]
pub enum OtaCommand {
    /// Generate a new Ed25519 keypair. Writes `nativ-ota.key` (private) and
    /// `nativ-ota.pub` (base64 public key suitable for nativ.toml).
    Keygen {
        /// Output directory for the keypair (defaults to current directory).
        #[arg(long, default_value = ".")]
        out_dir: PathBuf,
    },
    /// Scan a directory and write `manifest.json` + `manifest.sig` describing
    /// its contents. Use the private key produced by `keygen`.
    Pack {
        /// Directory whose contents are bundled (e.g. build/ota-bundle).
        dir: PathBuf,
        /// Path to the private key file (32 raw bytes or base64).
        #[arg(long)]
        key: PathBuf,
        /// Bundle version string (semver-ish). Stamped into the manifest.
        #[arg(long, default_value = "1.0.0")]
        version: String,
        /// Output directory for manifest.json + manifest.sig (defaults to DIR).
        #[arg(long)]
        out_dir: Option<PathBuf>,
    },
    /// Verify a manifest against its detached signature and a public key.
    Verify {
        /// Path to manifest.json.
        manifest: PathBuf,
        /// Path to manifest.sig (64 raw bytes).
        sig: PathBuf,
        /// Path to nativ-ota.pub (base64) or 32 raw bytes.
        #[arg(long)]
        public_key: PathBuf,
    },
}

pub fn run(args: OtaArgs) -> Result<(), Box<dyn std::error::Error>> {
    match args.command {
        OtaCommand::Keygen { out_dir } => keygen(&out_dir),
        OtaCommand::Pack {
            dir,
            key,
            version,
            out_dir,
        } => pack(&dir, &key, &version, out_dir.as_deref()),
        OtaCommand::Verify {
            manifest,
            sig,
            public_key,
        } => verify(&manifest, &sig, &public_key),
    }
}

// ─── Manifest type ───────────────────────────────────────────────────────

/// Schema version of the Nativ Live Updates manifest format. Bumped only on
/// backwards-incompatible changes to the manifest shape.
pub const MANIFEST_SCHEMA: u32 = 1;

/// One file in the bundle. `path` is forward-slash relative to the bundle
/// root and doubles as the URL path under `<base_url>/`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestFile {
    pub path: String,
    /// Lowercase hex SHA-256 of the file bytes (64 chars).
    pub sha256: String,
    pub size: u64,
}

/// Top-level manifest. Serialized as JSON (compact, no whitespace) and
/// signed in-place — clients verify the signature over the exact bytes the
/// server ships, so re-serialization rules do not apply.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
    pub schema: u32,
    pub version: String,
    /// RFC 3339 UTC timestamp the bundle was packed.
    pub created_at: String,
    pub files: Vec<ManifestFile>,
}

impl Manifest {
    /// Serialize as compact JSON with sorted object keys so two `pack`
    /// runs over identical inputs produce byte-identical manifests.
    pub fn to_canonical_bytes(&self) -> serde_json::Result<Vec<u8>> {
        let mut buf = Vec::new();
        let formatter = serde_json::ser::CompactFormatter;
        let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
        serde::Serialize::serialize(self, &mut ser)?;
        Ok(buf)
    }
}

// ─── Helpers ─────────────────────────────────────────────────────────────

fn sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex_encode(&hasher.finalize())
}

fn hex_encode(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

/// Read a key that is either 32 raw bytes or base64 of 32 bytes.
fn read_key_bytes(path: &Path) -> Result<[u8; 32], String> {
    let raw = fs::read(path).map_err(|e| format!("failed to read key {}: {e}", path.display()))?;
    if raw.len() == 32 {
        let mut out = [0u8; 32];
        out.copy_from_slice(&raw);
        return Ok(out);
    }
    use base64::Engine as _;
    let trimmed = String::from_utf8_lossy(&raw).trim().to_string();
    base64::engine::general_purpose::STANDARD
        .decode(trimmed)
        .ok()
        .filter(|v| v.len() == 32)
        .map(|v| {
            let mut out = [0u8; 32];
            out.copy_from_slice(&v);
            out
        })
        .ok_or_else(|| {
            format!(
                "key {} must be 32 raw bytes or base64 of 32 bytes (got {} raw)",
                path.display(),
                raw.len()
            )
        })
}

fn utc_now_rfc3339() -> String {
    // ponytail: hand-rolled UTC RFC3339 to avoid pulling in chrono/time for
    // one timestamp in a developer CLI. Calendar math from days since epoch
    // (Howard Hinnant's algorithm). Accuracy: second-resolution UTC.
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let day = secs / 86_400;
    let sod = secs % 86_400;
    let (h, m, s) = (sod / 3600, (sod / 60) % 60, sod % 60);
    let (year, month, dom) = civil_from_days(day as i64);
    format!("{year:04}-{month:02}-{dom:02}T{h:02}:{m:02}:{s:02}Z")
}

/// Howard Hinnant's days→(y,m,d) algorithm. Public domain.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
    (if m <= 2 { y + 1 } else { y }, m, d)
}

// ─── Commands ────────────────────────────────────────────────────────────

fn keygen(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
    fs::create_dir_all(out_dir)?;
    let mut rng = rand::rngs::OsRng;
    let signing = SigningKey::generate(&mut rng);
    let verifying = signing.verifying_key();

    let priv_path = out_dir.join("nativ-ota.key");
    let pub_path = out_dir.join("nativ-ota.pub");

    fs::write(&priv_path, signing.to_bytes())?;
    use base64::Engine as _;
    let pub_b64 = base64::engine::general_purpose::STANDARD.encode(verifying.to_bytes());
    fs::write(&pub_path, &pub_b64)?;

    println!(
        "Generated Ed25519 keypair:\n  private: {}\n  public:  {}\n\n\
         Add this to nativ.toml when OTA is enabled:\n  [ota]\n  enabled = true\n  \
         base_url = \"https://your-update-server.example\"\n  public_key = \"{pub_b64}\"",
        priv_path.display(),
        pub_path.display()
    );
    println!("\nKeep nativ-ota.key secret. It signs every Live Update bundle for this app.");
    Ok(())
}

fn pack(
    dir: &Path,
    key: &Path,
    version: &str,
    out_dir: Option<&Path>,
) -> Result<(), Box<dyn std::error::Error>> {
    if !dir.is_dir() {
        return Err(format!("bundle dir {} does not exist", dir.display()).into());
    }
    let key_bytes = read_key_bytes(key).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
    let signing = SigningKey::from_bytes(&key_bytes);

    // Walk DIR, hash every regular file, sort by path for stable output.
    let mut entries: Vec<(String, PathBuf)> = Vec::new();
    collect_files(dir, dir, &mut entries)?;
    entries.sort_by(|a, b| a.0.cmp(&b.0));

    let mut files = Vec::with_capacity(entries.len());
    for (rel, abs) in &entries {
        let bytes = fs::read(abs)?;
        files.push(ManifestFile {
            path: rel.clone(),
            sha256: sha256_hex(&bytes),
            size: bytes.len() as u64,
        });
    }

    let manifest = Manifest {
        schema: MANIFEST_SCHEMA,
        version: version.to_string(),
        created_at: utc_now_rfc3339(),
        files,
    };
    let manifest_bytes = manifest.to_canonical_bytes()?;
    let signature: Signature = signing.sign(&manifest_bytes);

    let out = out_dir.unwrap_or(dir);
    fs::create_dir_all(out)?;
    let manifest_path = out.join("manifest.json");
    let sig_path = out.join("manifest.sig");
    fs::write(&manifest_path, &manifest_bytes)?;
    fs::write(&sig_path, signature.to_bytes())?;

    println!(
        "Packed {} file(s) into {} (v{}, schema v{})\n  {}\n  {}",
        entries.len(),
        dir.display(),
        version,
        MANIFEST_SCHEMA,
        manifest_path.display(),
        sig_path.display()
    );
    Ok(())
}

fn collect_files(root: &Path, cur: &Path, out: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> {
    for entry in fs::read_dir(cur)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            // ponytail: skip VCS and manifest outputs themselves if present.
            let name = entry.file_name();
            if name == ".git" || name == "node_modules" {
                continue;
            }
            collect_files(root, &path, out)?;
        } else if path.is_file() {
            let rel = path
                .strip_prefix(root)
                .map_err(|e| std::io::Error::other(e.to_string()))?;
            let rel_str = rel.to_string_lossy().replace('\\', "/");
            // Skip the packer's own outputs when DIR == OUT.
            if rel_str == "manifest.json" || rel_str == "manifest.sig" {
                continue;
            }
            out.push((rel_str, path));
        }
    }
    Ok(())
}

fn verify(
    manifest: &Path,
    sig: &Path,
    public_key: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
    let manifest_bytes = fs::read(manifest)?;
    let sig_bytes = fs::read(sig)?;
    let key_bytes =
        read_key_bytes(public_key).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;

    if sig_bytes.len() != 64 {
        return Err(format!(
            "signature must be 64 raw bytes (got {}); did you pass a base64 file?",
            sig_bytes.len()
        )
        .into());
    }
    let mut sig_arr = [0u8; 64];
    sig_arr.copy_from_slice(&sig_bytes);
    let signature = Signature::from_bytes(&sig_arr);
    let verifying = VerifyingKey::from_bytes(&key_bytes)
        .map_err(|e| format!("invalid Ed25519 public key: {e}"))?;

    verifying
        .verify(&manifest_bytes, &signature)
        .map_err(|e| format!("signature verification failed: {e}"))?;

    let manifest: Manifest = serde_json::from_slice(&manifest_bytes)
        .map_err(|e| format!("manifest is not valid JSON: {e}"))?;

    println!(
        "OK — manifest v{} (schema v{}), {} file(s), created {}",
        manifest.version,
        manifest.schema,
        manifest.files.len(),
        manifest.created_at
    );
    Ok(())
}

// ─── Tests ───────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn keygen_pack_verify_roundtrip() {
        let tmp = tempdir().unwrap();
        let key_dir = tmp.path().join("keys");
        let bundle_dir = tmp.path().join("bundle");
        fs::create_dir_all(&bundle_dir).unwrap();
        fs::write(bundle_dir.join("en.json"), b"{\"hello\":\"world\"}").unwrap();
        fs::write(bundle_dir.join("tr.json"), b"{\"hello\":\"dunya\"}").unwrap();

        keygen(&key_dir).unwrap();
        let priv_key = key_dir.join("nativ-ota.key");
        let pub_key = key_dir.join("nativ-ota.pub");
        assert!(priv_key.exists());
        assert!(pub_key.exists());

        pack(&bundle_dir, &priv_key, "1.2.3", None).unwrap();
        let manifest_path = bundle_dir.join("manifest.json");
        let sig_path = bundle_dir.join("manifest.sig");
        assert!(manifest_path.exists());
        assert!(sig_path.exists());

        // Signature is exactly 64 bytes.
        assert_eq!(fs::read(&sig_path).unwrap().len(), 64);

        // Verify with the matching public key.
        verify(&manifest_path, &sig_path, &pub_key).unwrap();

        // Manifest contains both files with their SHA-256 hashes.
        let manifest_bytes = fs::read(&manifest_path).unwrap();
        let manifest: Manifest = serde_json::from_slice(&manifest_bytes).unwrap();
        assert_eq!(manifest.schema, MANIFEST_SCHEMA);
        assert_eq!(manifest.version, "1.2.3");
        assert_eq!(manifest.files.len(), 2);
        assert!(manifest.files.iter().any(|f| f.path == "en.json"));
        assert!(manifest.files.iter().any(|f| f.path == "tr.json"));
        let en = manifest.files.iter().find(|f| f.path == "en.json").unwrap();
        assert_eq!(en.size, 17);
        assert_eq!(en.sha256, sha256_hex(b"{\"hello\":\"world\"}"));
    }

    #[test]
    fn verify_rejects_tampered_manifest() {
        let tmp = tempdir().unwrap();
        let key_dir = tmp.path().join("keys");
        let bundle_dir = tmp.path().join("bundle");
        fs::create_dir_all(&bundle_dir).unwrap();
        fs::write(bundle_dir.join("en.json"), b"original").unwrap();

        keygen(&key_dir).unwrap();
        pack(&bundle_dir, &key_dir.join("nativ-ota.key"), "1.0.0", None).unwrap();

        // Tamper with the manifest contents after signing.
        let manifest_path = bundle_dir.join("manifest.json");
        let mut manifest_bytes = fs::read(&manifest_path).unwrap();
        // Flip a byte in the version field area by appending a space — keeps
        // it JSON-valid but changes the signed bytes.
        manifest_bytes.push(b' ');
        fs::write(&manifest_path, manifest_bytes).unwrap();

        let err = verify(
            &manifest_path,
            &bundle_dir.join("manifest.sig"),
            &key_dir.join("nativ-ota.pub"),
        )
        .unwrap_err();
        assert!(err.to_string().contains("verification failed"));
    }

    #[test]
    fn verify_rejects_wrong_public_key() {
        let tmp = tempdir().unwrap();
        let bundle_dir = tmp.path().join("bundle");
        let signer_dir = tmp.path().join("signer");
        let attacker_dir = tmp.path().join("attacker");
        fs::create_dir_all(&bundle_dir).unwrap();
        fs::write(bundle_dir.join("en.json"), b"hi").unwrap();

        keygen(&signer_dir).unwrap();
        keygen(&attacker_dir).unwrap();
        pack(
            &bundle_dir,
            &signer_dir.join("nativ-ota.key"),
            "1.0.0",
            None,
        )
        .unwrap();

        let err = verify(
            &bundle_dir.join("manifest.json"),
            &bundle_dir.join("manifest.sig"),
            &attacker_dir.join("nativ-ota.pub"),
        )
        .unwrap_err();
        assert!(err.to_string().contains("verification failed"));
    }

    #[test]
    fn pack_skips_existing_manifest_outputs() {
        let tmp = tempdir().unwrap();
        let key_dir = tmp.path().join("keys");
        let bundle_dir = tmp.path().join("bundle");
        fs::create_dir_all(&bundle_dir).unwrap();
        fs::write(bundle_dir.join("en.json"), b"x").unwrap();
        // Pre-existing manifest outputs must not be bundled.
        fs::write(bundle_dir.join("manifest.json"), b"{}").unwrap();
        fs::write(bundle_dir.join("manifest.sig"), [0u8; 64]).unwrap();

        keygen(&key_dir).unwrap();
        pack(&bundle_dir, &key_dir.join("nativ-ota.key"), "1.0.0", None).unwrap();

        let manifest: Manifest =
            serde_json::from_slice(&fs::read(bundle_dir.join("manifest.json")).unwrap()).unwrap();
        assert_eq!(manifest.files.len(), 1);
        assert_eq!(manifest.files[0].path, "en.json");
    }

    #[test]
    fn pack_uses_forward_slash_paths() {
        let tmp = tempdir().unwrap();
        let key_dir = tmp.path().join("keys");
        let bundle_dir = tmp.path().join("bundle");
        let sub = bundle_dir.join("i18n");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("en.json"), b"x").unwrap();

        keygen(&key_dir).unwrap();
        pack(&bundle_dir, &key_dir.join("nativ-ota.key"), "1.0.0", None).unwrap();

        let manifest: Manifest =
            serde_json::from_slice(&fs::read(bundle_dir.join("manifest.json")).unwrap()).unwrap();
        assert_eq!(manifest.files[0].path, "i18n/en.json");
    }

    #[test]
    fn manifest_round_trips_through_json() {
        let m = Manifest {
            schema: MANIFEST_SCHEMA,
            version: "2.0.0".into(),
            created_at: "2026-01-01T00:00:00Z".into(),
            files: vec![ManifestFile {
                path: "i18n/en.json".into(),
                sha256: "a".repeat(64),
                size: 42,
            }],
        };
        let bytes = m.to_canonical_bytes().unwrap();
        let back: Manifest = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(back.schema, m.schema);
        assert_eq!(back.version, m.version);
        assert_eq!(back.files.len(), 1);
    }

    #[test]
    fn read_key_accepts_raw_and_base64() {
        let tmp = tempdir().unwrap();
        let raw_path = tmp.path().join("raw.key");
        let b64_path = tmp.path().join("b64.key");
        fs::write(&raw_path, [7u8; 32]).unwrap();
        use base64::Engine as _;
        fs::write(
            &b64_path,
            base64::engine::general_purpose::STANDARD.encode([7u8; 32]),
        )
        .unwrap();
        assert_eq!(read_key_bytes(&raw_path).unwrap(), [7u8; 32]);
        assert_eq!(read_key_bytes(&b64_path).unwrap(), [7u8; 32]);
    }

    #[test]
    fn civil_from_days_matches_known_dates() {
        assert_eq!(civil_from_days(0), (1970, 1, 1));
        // 2026-06-25 = 20_629 days after the Unix epoch (1970-01-01).
        let (y, m, d) = civil_from_days(20_629);
        assert_eq!((y, m, d), (2026, 6, 25));
        // Leap-day handling: 2032-02-29 = 22_704.
        let (y, m, d) = civil_from_days(22_704);
        assert_eq!((y, m, d), (2032, 2, 29));
    }
}