Skip to main content

gen_gomod/
build_spec.rs

1//! `Go.build-spec.json` — the canonical typed build manifest for the
2//! gomod ecosystem.
3//!
4//! Two shapes live here, dispatched by [`BuildSpec::renderer`]:
5//!
6//! - **v2 incremental** (this module's top-level types) — one
7//!   content-addressed derivation node PER Go package, keyed by
8//!   `<import-path>#<goos>-<goarch>[+tags]`. Editing one package's
9//!   sources rebuilds only that node + its transitive dependents;
10//!   internal/shared packages compile once and are reused across every
11//!   binary in the monorepo. This is `rustc-per-crate`, in Go.
12//! - **v1 coarse** ([`coarse`]) — the module-level `buildGoModule`
13//!   kwargs shape, preserved verbatim so the existing coarse
14//!   `renderer: coarse` substrate path (`go/lockfile-builder.nix`)
15//!   keeps working unchanged.
16//!
17//! Mirrors gen-cargo's `build_spec.rs`: every OUTPUT-BEARING keyed map
18//! is a [`BTreeMap`] so the serialized JSON key order is canonical
19//! (lexicographic) BY CONSTRUCTION — the spec is byte-identical
20//! regardless of the resolver's traversal order (linux CI vs darwin).
21//! `IndexMap` is used only for working/never-serialized maps.
22//!
23//! The encoder that populates this shape is [`crate::interp::apply`]
24//! (driven by `go list -deps -json` over a vendored tree — see
25//! [`crate::golist`]); the substrate interpreter is
26//! `substrate/lib/build/go/package-builder.nix`.
27
28use std::collections::BTreeMap;
29
30use indexmap::IndexMap;
31use serde::{Deserialize, Serialize};
32
33/// Schema version — v2 = per-package incremental (this module).
34/// v1 = module-level coarse (see [`coarse::SCHEMA_VERSION`]).
35pub const SCHEMA_VERSION: u32 = 2;
36
37// ─────────────────────────────────────────────────────────────────────
38// v1 coarse shape (preserved). The module-level buildGoModule kwargs the
39// existing `renderer: coarse` substrate path spreads verbatim.
40// ─────────────────────────────────────────────────────────────────────
41pub mod coarse {
42    //! Module-level `buildGoModule` kwargs — the v1 coarse shape.
43    //! nixpkgs reference: `pkgs/build-support/go/module.nix`.
44
45    use indexmap::IndexMap;
46    use serde::{Deserialize, Serialize};
47
48    pub const SCHEMA_VERSION: u32 = 1;
49
50    #[derive(Clone, Debug, Serialize, Deserialize, gen_macros::SpecShape)]
51    #[spec(
52        args = "PackageArgs",
53        quirk = "crate::quirks::GomodQuirk",
54        args_field = "args",
55        root_field = "root_package",
56        members_field = "workspace_members",
57        crates_field = "packages"
58    )]
59    pub struct BuildSpec {
60        pub version: u32,
61        pub packages: IndexMap<String, PackageSpec>,
62        pub root_package: String,
63        pub workspace_members: Vec<String>,
64    }
65
66    #[derive(Clone, Debug, Serialize, Deserialize)]
67    pub struct PackageSpec {
68        pub name: String,
69        pub version: String,
70        pub args: PackageArgs,
71        #[serde(default, skip_serializing_if = "Vec::is_empty")]
72        pub quirks: Vec<crate::quirks::GomodQuirk>,
73    }
74
75    /// Pre-shaped `buildGoModule` kwargs. Field names match nixpkgs'
76    /// builder signature (camelCase via serde rename) so substrate
77    /// spreads verbatim.
78    #[derive(Clone, Debug, Default, Serialize, Deserialize)]
79    pub struct PackageArgs {
80        pub pname: Option<String>,
81        pub version: Option<String>,
82        #[serde(rename = "vendorHash", skip_serializing_if = "Option::is_none")]
83        pub vendor_hash: Option<String>,
84        #[serde(rename = "proxyVendor", skip_serializing_if = "Option::is_none")]
85        pub proxy_vendor: Option<bool>,
86        #[serde(skip_serializing_if = "Vec::is_empty")]
87        pub tags: Vec<String>,
88        #[serde(skip_serializing_if = "Vec::is_empty")]
89        pub ldflags: Vec<String>,
90        #[serde(rename = "subPackages", skip_serializing_if = "Vec::is_empty")]
91        pub sub_packages: Vec<String>,
92        #[serde(rename = "doCheck", skip_serializing_if = "Option::is_none")]
93        pub do_check: Option<bool>,
94        #[serde(skip_serializing_if = "IndexMap::is_empty")]
95        pub env: IndexMap<String, String>,
96        #[serde(rename = "nativeBuildInputs", skip_serializing_if = "Vec::is_empty")]
97        pub native_build_inputs: Vec<String>,
98        #[serde(rename = "buildInputs", skip_serializing_if = "Vec::is_empty")]
99        pub build_inputs: Vec<String>,
100    }
101}
102
103// ─────────────────────────────────────────────────────────────────────
104// Target tuple + node-key canonicalization.
105// ─────────────────────────────────────────────────────────────────────
106
107/// One build target: `(goos, goarch, tags)`. M1 populates exactly one
108/// tuple per build; the compact resolve shape is future-proof for the
109/// multi-target milestone.
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub struct TargetTuple {
112    pub goos: String,
113    pub goarch: String,
114    /// Build tags in effect (already applied to each node's `go_files`
115    /// by the encoder — Go-I6). Sorted for canonical suffixes.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub tags: Vec<String>,
118}
119
120impl TargetTuple {
121    #[must_use]
122    pub fn new(goos: impl Into<String>, goarch: impl Into<String>, mut tags: Vec<String>) -> Self {
123        tags.sort();
124        tags.dedup();
125        Self { goos: goos.into(), goarch: goarch.into(), tags }
126    }
127
128    /// The build host's tuple, in Go naming (macos→darwin,
129    /// aarch64→arm64, x86_64→amd64). No tags. The M1 default when the
130    /// caller supplies no explicit target.
131    #[must_use]
132    pub fn host() -> Self {
133        let goos = match std::env::consts::OS {
134            "macos" => "darwin",
135            other => other, // linux, windows, … map 1:1
136        };
137        let goarch = rust_arch_to_go(std::env::consts::ARCH);
138        Self::new(goos, goarch, Vec::new())
139    }
140
141    /// Best-effort map from a Rust target triple (`AdapterCtx.target`,
142    /// e.g. `aarch64-apple-darwin` / `x86_64-unknown-linux-musl`) to a
143    /// Go `(goos, goarch)` tuple. Returns `None` when the OS token isn't
144    /// recognized so the caller can fall back to [`host`](Self::host).
145    #[must_use]
146    pub fn from_rust_triple(triple: &str) -> Option<Self> {
147        let parts: Vec<&str> = triple.split('-').collect();
148        let arch = parts.first()?;
149        let goos = if triple.contains("darwin") || triple.contains("apple") {
150            "darwin"
151        } else if triple.contains("linux") {
152            "linux"
153        } else if triple.contains("windows") {
154            "windows"
155        } else {
156            return None;
157        };
158        Some(Self::new(goos, rust_arch_to_go(arch), Vec::new()))
159    }
160
161    /// The `#<goos>-<goarch>[+tag,tag]` suffix appended to a node key.
162    /// Tags are sorted so the suffix is canonical regardless of the
163    /// order `go list` reported them.
164    #[must_use]
165    pub fn suffix(&self) -> String {
166        if self.tags.is_empty() {
167            format!("#{}-{}", self.goos, self.goarch)
168        } else {
169            format!("#{}-{}+{}", self.goos, self.goarch, self.tags.join(","))
170        }
171    }
172}
173
174/// Map a Rust arch token to Go's `GOARCH`. Unknown tokens pass through
175/// unchanged (Go and Rust agree on many: `arm`, `riscv64`, `s390x`, …).
176fn rust_arch_to_go(arch: &str) -> &str {
177    match arch {
178        "aarch64" => "arm64",
179        "x86_64" => "amd64",
180        "x86" | "i686" => "386",
181        "powerpc64" => "ppc64le",
182        other => other,
183    }
184}
185
186/// Canonical node key for a package at a tuple. Std packages get a
187/// `std/` prefix so they never collide with a module package that
188/// happens to share an import path.
189#[must_use]
190pub fn node_key(import_path: &str, kind: PackageKind, tuple: &TargetTuple) -> String {
191    let base = if kind.is_std() {
192        format!("std/{import_path}")
193    } else {
194        import_path.to_string()
195    };
196    format!("{base}{}", tuple.suffix())
197}
198
199// ─────────────────────────────────────────────────────────────────────
200// v2 incremental shape — the per-package build graph.
201// ─────────────────────────────────────────────────────────────────────
202
203#[derive(Clone, Debug, Serialize, Deserialize, gen_macros::SpecShape)]
204#[spec(
205    args = "GoPackageArgs",
206    quirk = "crate::quirks::GomodQuirk",
207    args_field = "args",
208    root_field = "root_package",
209    members_field = "workspace_members",
210    crates_field = "packages"
211)]
212pub struct BuildSpec {
213    pub version: u32,
214    /// `Coarse` = the v1 buildGoModule path; `Incremental` = this
215    /// per-package graph. The M1 encoder always emits `Incremental`.
216    pub renderer: Renderer,
217    pub module: ModuleSpec,
218
219    /// EVERY build-graph node, keyed canonically (see [`node_key`]).
220    /// `BTreeMap` ⇒ canonical JSON key order by construction.
221    #[serde(default)]
222    pub packages: BTreeMap<String, PackageSpec>,
223
224    /// The primary buildable node key (a `package main`).
225    pub root_package: String,
226    /// Every buildable `main` node (akeyless: logan/gator/auth/… → many).
227    #[serde(default)]
228    pub workspace_members: Vec<String>,
229
230    /// Compact per-target import graph. M1 populates exactly one target;
231    /// the shape is future-proof for the multi-target milestone.
232    /// Lossless: substrate reconstructs `base // overrides[tuple]`.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub target_resolves: Option<GoCompactTargetResolves>,
235
236    /// SHA-256 of `go.sum` content at emit — the D2 freshness tie
237    /// (mirror of `cargo_lock_sha256`; consumed by substrate's
238    /// `go/lockfile-delta.nix`). Empty-string hash `e3b0c4…` when the
239    /// module is dep-free.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub go_sum_sha256: Option<String>,
242}
243
244#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, gen_macros::IsVariant)]
245#[serde(rename_all = "kebab-case")]
246pub enum Renderer {
247    Coarse,
248    Incremental,
249}
250
251#[derive(Clone, Debug, Serialize, Deserialize)]
252pub struct ModuleSpec {
253    /// go.mod `module` directive, e.g. `akeyless.io/akeyless-main-repo`.
254    pub module_path: String,
255    /// go.mod `go` directive, e.g. `"1.26"`.
256    pub go_version: String,
257    /// go.mod `toolchain` directive when pinned, e.g. `"go1.26.4"`.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub toolchain: Option<String>,
260    /// True when go.mod declares any `require` edge (drives the coarse
261    /// vendorHash decision on the fallback path).
262    pub has_external_deps: bool,
263    /// M1 ⇒ `Vendored`. `Proxy` is the M-proxy milestone.
264    pub dep_mode: DepMode,
265    /// Coarse/fallback only. The M1 incremental path never fetches
266    /// (`-mod=vendor`, `GOPROXY=off`) ⇒ `None`.
267    #[serde(rename = "vendorHash", default, skip_serializing_if = "Option::is_none")]
268    pub vendor_hash: Option<String>,
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, gen_macros::IsVariant)]
272#[serde(rename_all = "kebab-case")]
273pub enum DepMode {
274    Vendored,
275    Proxy,
276}
277
278/// ONE build-graph node = one Go package compiled for one target tuple.
279#[derive(Clone, Debug, Serialize, Deserialize)]
280pub struct PackageSpec {
281    /// Full import path, e.g.
282    /// `akeyless.io/akeyless-main-repo/go/src/microservices/auth`.
283    pub import_path: String,
284    /// `Std` | `Module` | `Main` (Cgo/Tool are deferred to M-cgo).
285    pub kind: PackageKind,
286    /// `Vendored { relative_path }` | `Std`.
287    pub source: PackageSource,
288    /// The build-tree placement of THIS node (Go-I2 seam). Workload +
289    /// std nodes are `Target`; build-tool / cgo-host nodes are `Host`
290    /// (deferred kinds). M1 emits `Target` for every node — the seam
291    /// exists so M-cgo is a classifier-arm swap, not a rewrite.
292    #[serde(default)]
293    pub tree: BuildTree,
294    /// The *resolved* file list — build constraints already applied by
295    /// the encoder for this tuple (Go-I6). Relative to the node's
296    /// source root. Excludes `_test.go`.
297    pub go_files: Vec<String>,
298    /// The tuple's build tags in effect (already applied to `go_files`;
299    /// kept for the compile invocation + audit provenance).
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub build_tags: Vec<String>,
302    /// `//go:embed` — patterns + resolved files (drives `-embedcfg`).
303    #[serde(default, skip_serializing_if = "EmbedSpec::is_empty")]
304    pub embed: EmbedSpec,
305    /// Import edges → other node keys in `packages`. The per-package
306    /// DAG; the interpreter builds `importcfg` from these (Go-I1).
307    #[serde(default)]
308    pub imports: Vec<String>,
309    /// Vendor import rewrite map (go list `ImportMap`) — source import
310    /// path → actual package path, when they differ (vendored deps).
311    /// Carried so the interpreter can emit `importmap` lines.
312    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
313    pub import_map: BTreeMap<String, String>,
314    /// Provenance — the owning module (own module vs a vendored dep).
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub module: Option<PackageModuleRef>,
317    /// BLAKE3 over (sorted `go_files` ⧺ `embed.files`) content — the
318    /// incremental cache key + determinism/drift tie. (Nix's derivation
319    /// hash is the *actual* store boundary; this is the encoder-side
320    /// content address for the delta path + `gen check-spec` drift.)
321    pub source_hash: String,
322    pub args: GoPackageArgs,
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub quirks: Vec<crate::quirks::GomodQuirk>,
325}
326
327#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, gen_macros::IsVariant)]
328#[serde(rename_all = "kebab-case")]
329pub enum PackageKind {
330    Std,
331    Module,
332    Main,
333    // + Cgo, Tool at M-cgo.
334}
335
336/// Which build tree a node compiles into (Go-I2). Mirrors gen-cargo's
337/// `BuildTree`. Workload + std → `Target`; build-tooling / cgo-host →
338/// `Host`.
339#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, gen_macros::IsVariant)]
340#[serde(rename_all = "kebab-case")]
341pub enum BuildTree {
342    /// Built for the workload's arch. Default — every M1 node.
343    #[default]
344    Target,
345    /// Built for the build-machine's arch. Tool / cgo-host nodes (M-cgo).
346    Host,
347}
348
349#[derive(Clone, Debug, Serialize, Deserialize)]
350#[serde(tag = "kind", rename_all = "kebab-case")]
351pub enum PackageSource {
352    /// In-tree package — a subdir of the one committed workspace `src`
353    /// (a vendored dep under `vendor/…`, OR the module's own package
354    /// under `go/src/…`). Honors in-tree `replace` (go list points
355    /// `Dir` at the replacement for free).
356    Vendored { relative_path: String },
357    /// Std package — provided by the shared std derivation, never fetched.
358    Std,
359    // Proxy { module, version, zip_sha256 } → M-proxy.
360}
361
362impl PackageSource {
363    #[must_use]
364    pub fn is_std(&self) -> bool {
365        matches!(self, PackageSource::Std)
366    }
367}
368
369#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
370pub struct EmbedSpec {
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub patterns: Vec<String>,
373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
374    pub files: Vec<String>,
375}
376
377impl EmbedSpec {
378    #[must_use]
379    pub fn is_empty(&self) -> bool {
380        self.patterns.is_empty() && self.files.is_empty()
381    }
382}
383
384/// The owning Go module for a node — provenance only. `path` is the
385/// module path; `version` is `Some` for a vendored dep, `None` for the
386/// main module.
387#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
388pub struct PackageModuleRef {
389    pub path: String,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub version: Option<String>,
392}
393
394/// Pre-shaped `go tool compile`/`link` kwargs — spread verbatim by the
395/// substrate interpreter.
396#[derive(Clone, Debug, Default, Serialize, Deserialize)]
397pub struct GoPackageArgs {
398    /// Extra `go tool compile` flags.
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub gcflags: Vec<String>,
401    /// `go tool link` flags — link nodes only (e.g. `-X main.version=…`,
402    /// `-s -w`).
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub ldflags: Vec<String>,
405    /// GOOS/GOARCH/CGO_ENABLED for THIS node.
406    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
407    pub env: IndexMap<String, String>,
408}
409
410// ─────────────────────────────────────────────────────────────────────
411// Compact per-target resolves (mirror of gen-cargo, Go edge payload).
412// M1 populates exactly one tuple; future-proof for M-multitarget.
413// ─────────────────────────────────────────────────────────────────────
414
415/// Per-node import edges for one target tuple — the per-(tuple)-varying
416/// data (Go's build constraints select a different `go_files`/`imports`
417/// per tuple). Keyed by the same node key as `BuildSpec.packages`.
418#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
419pub struct GoTargetEdges {
420    #[serde(default, skip_serializing_if = "Vec::is_empty")]
421    pub imports: Vec<String>,
422    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
423    pub import_map: BTreeMap<String, String>,
424}
425
426/// The FULL in-memory per-tuple resolve (one complete node-edges map per
427/// tuple). The serialized form is [`GoCompactTargetResolves`].
428#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
429pub struct GoTargetResolve {
430    #[serde(default)]
431    pub packages: BTreeMap<String, GoTargetEdges>,
432}
433
434/// Compact serialized representation of per-target resolves — `base`
435/// holds edges identical across every tuple (stored once); `targets`
436/// holds only the per-tuple differences. Decode contract:
437/// `packages(tuple) = base // targets[tuple].overrides`.
438///
439/// Lifted verbatim in shape/algorithm from gen-cargo's
440/// `CompactTargetResolves`. (Sharing ONE generic implementation across
441/// cargo + gomod is the M-multitarget `gen-types` lift — see the M1
442/// build doc §7.)
443#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
444pub struct GoCompactTargetResolves {
445    #[serde(default)]
446    pub base: BTreeMap<String, GoTargetEdges>,
447    #[serde(default)]
448    pub targets: BTreeMap<String, GoTargetOverrides>,
449}
450
451#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
452pub struct GoTargetOverrides {
453    #[serde(default)]
454    pub overrides: BTreeMap<String, GoTargetEdges>,
455}
456
457impl GoCompactTargetResolves {
458    /// Split a full per-tuple resolve map into base + overrides. A node
459    /// is universal+identical iff it appears in every tuple with a
460    /// byte-identical edge set. Lossless: for every tuple `T`,
461    /// `base // overrides[T]` reconstructs the original map.
462    #[must_use]
463    pub fn from_full(full: IndexMap<String, GoTargetResolve>) -> Self {
464        let Some((_first, first_resolve)) = full.iter().next() else {
465            return Self::default();
466        };
467
468        let mut base: BTreeMap<String, GoTargetEdges> = BTreeMap::new();
469        for (key, first_edges) in &first_resolve.packages {
470            let present_in_all = full
471                .values()
472                .all(|resolve| resolve.packages.get(key) == Some(first_edges));
473            if present_in_all {
474                base.insert(key.clone(), first_edges.clone());
475            }
476        }
477
478        let mut targets: BTreeMap<String, GoTargetOverrides> = BTreeMap::new();
479        for (tuple, resolve) in &full {
480            let mut overrides: BTreeMap<String, GoTargetEdges> = BTreeMap::new();
481            for (key, edges) in &resolve.packages {
482                if !base.contains_key(key) {
483                    overrides.insert(key.clone(), edges.clone());
484                }
485            }
486            targets.insert(tuple.clone(), GoTargetOverrides { overrides });
487        }
488
489        Self { base, targets }
490    }
491
492    /// Reconstruct the full per-tuple resolve map: `base // overrides[T]`
493    /// for every tuple. Inverse of [`from_full`](Self::from_full).
494    #[must_use]
495    pub fn expand(&self) -> IndexMap<String, GoTargetResolve> {
496        let mut out: IndexMap<String, GoTargetResolve> = IndexMap::new();
497        for (tuple, over) in &self.targets {
498            let mut packages = self.base.clone();
499            for (key, edges) in &over.overrides {
500                packages.insert(key.clone(), edges.clone());
501            }
502            out.insert(tuple.clone(), GoTargetResolve { packages });
503        }
504        out
505    }
506}
507
508// ─────────────────────────────────────────────────────────────────────
509// Content addressing — source_hash (BLAKE3) + go_sum_sha256 (SHA-256).
510// ─────────────────────────────────────────────────────────────────────
511
512/// BLAKE3 content address over a node's resolved sources. Entries are
513/// `(relative_path, content_bytes)`; the hash is over the sorted-by-path
514/// list with length-prefixed path + content (the estante/CA discipline),
515/// so identical source ⇒ identical hash ⇒ identical node (cross-consumer
516/// / cross-binary dedup, Go-I8).
517#[must_use]
518pub fn source_hash(entries: &[(String, Vec<u8>)]) -> String {
519    let mut sorted: Vec<&(String, Vec<u8>)> = entries.iter().collect();
520    sorted.sort_by(|a, b| a.0.cmp(&b.0));
521    let mut hasher = blake3::Hasher::new();
522    for (path, bytes) in sorted {
523        hasher.update(&(path.len() as u64).to_le_bytes());
524        hasher.update(path.as_bytes());
525        hasher.update(&(bytes.len() as u64).to_le_bytes());
526        hasher.update(bytes);
527    }
528    hasher.finalize().to_hex().to_string()
529}
530
531/// Lowercase-hex SHA-256 of `go.sum` content — the D2 freshness tie
532/// (Go-I7). Matches `builtins.hashFile "sha256"`. Empty content yields
533/// the canonical empty-string hash `e3b0c442…`.
534#[must_use]
535pub fn go_sum_sha256(bytes: &[u8]) -> String {
536    use sha2::{Digest, Sha256};
537    let mut hasher = Sha256::new();
538    hasher.update(bytes);
539    format!("{:x}", hasher.finalize())
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn tuple_suffix_is_canonical_and_tag_sorted() {
548        let t = TargetTuple::new("linux", "amd64", vec!["osusergo".into(), "netgo".into()]);
549        // tags sorted → netgo,osusergo regardless of input order.
550        assert_eq!(t.suffix(), "#linux-amd64+netgo,osusergo");
551        let bare = TargetTuple::new("darwin", "arm64", vec![]);
552        assert_eq!(bare.suffix(), "#darwin-arm64");
553    }
554
555    #[test]
556    fn node_key_prefixes_std() {
557        let t = TargetTuple::new("linux", "amd64", vec![]);
558        assert_eq!(node_key("fmt", PackageKind::Std, &t), "std/fmt#linux-amd64");
559        assert_eq!(
560            node_key("example.com/x/cmd/a", PackageKind::Main, &t),
561            "example.com/x/cmd/a#linux-amd64"
562        );
563    }
564
565    #[test]
566    fn source_hash_is_order_independent_and_content_sensitive() {
567        let a = vec![("b.go".to_string(), b"two".to_vec()), ("a.go".to_string(), b"one".to_vec())];
568        let b = vec![("a.go".to_string(), b"one".to_vec()), ("b.go".to_string(), b"two".to_vec())];
569        assert_eq!(source_hash(&a), source_hash(&b), "path order must not change the hash");
570        let c = vec![("a.go".to_string(), b"one!".to_vec()), ("b.go".to_string(), b"two".to_vec())];
571        assert_ne!(source_hash(&a), source_hash(&c), "a one-byte edit must change the hash");
572    }
573
574    #[test]
575    fn empty_go_sum_is_the_canonical_empty_hash() {
576        assert_eq!(
577            go_sum_sha256(b""),
578            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
579        );
580    }
581
582    #[test]
583    fn compact_resolves_round_trip_lossless() {
584        let mut pkgs = BTreeMap::new();
585        pkgs.insert(
586            "example.com/x/a#linux-amd64".to_string(),
587            GoTargetEdges { imports: vec!["std/fmt#linux-amd64".into()], import_map: BTreeMap::new() },
588        );
589        let mut full: IndexMap<String, GoTargetResolve> = IndexMap::new();
590        full.insert("#linux-amd64".to_string(), GoTargetResolve { packages: pkgs.clone() });
591        let compact = GoCompactTargetResolves::from_full(full.clone());
592        assert_eq!(compact.expand(), full, "from_full ∘ expand must be identity");
593    }
594}