| 1 | // The identity of a staged artifact (zero-downtime deploys spec §4.2, §7.9, |
| 2 | // §8.5): which bytes a process was exec'd from, kept so that recovery runs |
| 3 | // exactly those bytes again and can tell when they have changed. |
| 4 | package release |
| 5 | |
| 6 | import ( |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | // StagedIdentity is the retained identity of a staged artifact (spec §4.2, |
| 15 | // §8.5): the version name it was staged under, the immutable path it is |
| 16 | // exec'd from, the sha256 of its bytes, and the release's own swap demand as |
| 17 | // verified for those bytes ("hold" or ""). The agent captures it at exec, |
| 18 | // never from the live link afterwards, because the link moves under a |
| 19 | // running process. |
| 20 | type StagedIdentity struct { |
| 21 | Version string |
| 22 | StagedPath string |
| 23 | Digest string |
| 24 | Demand string |
| 25 | } |
| 26 | |
| 27 | // ErrStagedCopyChanged is recovery's refusal (spec §4.2): the staged file no |
| 28 | // longer hashes to the digest retained for it, so it is not the build that |
| 29 | // passed, and running it would be running something nobody verified. |
| 30 | var ErrStagedCopyChanged = errors.New("staged copy no longer matches the digest it was verified with") |
| 31 | |
| 32 | // ErrVersionReused is root staging's refusal (spec §4.2): the store holds |
| 33 | // different bytes under a version name this box already staged, and |
| 34 | // something on the box still refers to the staged copy, so it is not |
| 35 | // overwritten. The text is the spec's. |
| 36 | var ErrVersionReused = errors.New("version name reused with different content, ship under a new name") |
| 37 | |
| 38 | // maxLinkHops caps a live link's symlink chain, as Linux caps the symlinks |
| 39 | // one path lookup follows. |
| 40 | const maxLinkHops = 40 |
| 41 | |
| 42 | // linkChain follows link's leaf symlink chain one hop at a time: link's own |
| 43 | // target, then that target's target for as long as it is a symlink. An |
| 44 | // absolute target is a hop as written (cleaned). A relative target is |
| 45 | // resolved as the OS resolves it: against the REAL directory of the link |
| 46 | // that holds it (filepath.EvalSymlinks of that link's parent), so a link |
| 47 | // reached through a symlinked directory reaches what the OS reaches (Astra r2 |
| 48 | // N3, ruling R17). A parent that does not exist leaves the lexical join, |
| 49 | // whose hop then does not exist either; any other error resolving a parent |
| 50 | // is an error. |
| 51 | // |
| 52 | // It returns link's raw first target, every hop in order (the last is where |
| 53 | // the chain ends), and aliases: the lexical join of each relative hop whose |
| 54 | // real path differs from it. BoxPins pins those too, so a box whose opt dir |
| 55 | // is itself reached through a symlink keeps the pin it had under the opt |
| 56 | // dir's own name, which is how VersionPath names a staged copy. A hop that |
| 57 | // does not exist ends the chain and is included. A loop, more than |
| 58 | // maxLinkHops hops, or any other error reading a hop is an error: a chain |
| 59 | // that cannot be followed to its end names nothing for certain. ResolveLive |
| 60 | // and BoxPins both walk links with it, so the build a worker execs and the |
| 61 | // copies the pin set protects cannot disagree. |
| 62 | func linkChain(link string) (raw string, hops, aliases []string, err error) { |
| 63 | raw, err = os.Readlink(link) |
| 64 | if err != nil { |
| 65 | return "", nil, nil, fmt.Errorf("read live link %s: %w", link, err) |
| 66 | } |
| 67 | seen := map[string]bool{filepath.Clean(link): true} |
| 68 | holder, target := link, raw |
| 69 | for { |
| 70 | if len(hops) == maxLinkHops { |
| 71 | return "", nil, nil, fmt.Errorf("live link %s: more than %d symlink hops", link, maxLinkHops) |
| 72 | } |
| 73 | if !filepath.IsAbs(target) { |
| 74 | parent := filepath.Dir(holder) |
| 75 | lexical := filepath.Join(parent, target) |
| 76 | realParent, err := filepath.EvalSymlinks(parent) |
| 77 | switch { |
| 78 | case errors.Is(err, os.ErrNotExist): |
| 79 | target = lexical |
| 80 | case err != nil: |
| 81 | return "", nil, nil, fmt.Errorf("live link %s: resolve %s: %w", link, parent, err) |
| 82 | default: |
| 83 | target = filepath.Join(realParent, target) |
| 84 | if target != lexical { |
| 85 | aliases = append(aliases, lexical) |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | target = filepath.Clean(target) |
| 90 | if seen[target] { |
| 91 | return "", nil, nil, fmt.Errorf("live link %s: symlink loop at %s", link, target) |
| 92 | } |
| 93 | seen[target] = true |
| 94 | hops = append(hops, target) |
| 95 | fi, err := os.Lstat(target) |
| 96 | if errors.Is(err, os.ErrNotExist) { |
| 97 | return raw, hops, aliases, nil |
| 98 | } |
| 99 | if err != nil { |
| 100 | return "", nil, nil, fmt.Errorf("live link %s: %w", link, err) |
| 101 | } |
| 102 | if fi.Mode()&os.ModeSymlink == 0 { |
| 103 | return raw, hops, aliases, nil |
| 104 | } |
| 105 | next, err := os.Readlink(target) |
| 106 | if err != nil { |
| 107 | return "", nil, nil, fmt.Errorf("live link %s: %w", link, err) |
| 108 | } |
| 109 | holder, target = target, next |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // ResolveLive reads host's live link ONCE and reports the file at the end of |
| 114 | // its symlink chain (linkChain) and that file's sha256. Version is the link's |
| 115 | // version as LiveVersion reads it, from the link's own target, "" for a link |
| 116 | // outside the version store; the path and digest identify the bytes either |
| 117 | // way. Demand is left empty: only CanonicalDemand can vouch for it. |
| 118 | func ResolveLive(optDir, host string) (StagedIdentity, error) { |
| 119 | id, err := ResolveLiveLink(optDir, host) |
| 120 | if err != nil { |
| 121 | return StagedIdentity{}, err |
| 122 | } |
| 123 | sum, err := FileSHA256(id.StagedPath) |
| 124 | if err != nil { |
| 125 | return StagedIdentity{}, fmt.Errorf("hash %s: %w", id.StagedPath, err) |
| 126 | } |
| 127 | id.Digest = sum |
| 128 | return id, nil |
| 129 | } |
| 130 | |
| 131 | // ResolveLiveLink is ResolveLive without the hash: the file at the end of |
| 132 | // the live link's chain and the version that same reading of the link names, |
| 133 | // for a caller that opens and hashes the file itself (the claimed launch |
| 134 | // snapshot). Digest is left empty. |
| 135 | func ResolveLiveLink(optDir, host string) (StagedIdentity, error) { |
| 136 | raw, hops, _, err := linkChain(LiveLink(optDir, host)) |
| 137 | if err != nil { |
| 138 | return StagedIdentity{}, err |
| 139 | } |
| 140 | return StagedIdentity{Version: versionOfTarget(optDir, raw), StagedPath: hops[len(hops)-1]}, nil |
| 141 | } |
| 142 | |
| 143 | // RetainedStaged is ver's staged executable of app on this box, when its |
| 144 | // bytes still hash to this platform's artifact digest in its staged manifest |
| 145 | // sibling: the manifest the copy was verified against when it was staged |
| 146 | // (settings spec §4.1's recovery check; root's stageAuthorized and the held |
| 147 | // wake check both use it). nil when the sibling is absent or unreadable, |
| 148 | // names no artifact for this platform, or the bytes differ. Demand is left |
| 149 | // empty, as ResolveLive leaves it: only CanonicalDemand can vouch for it. |
| 150 | func RetainedStaged(optDir, acct, app, ver string) (*StagedIdentity, *Manifest) { |
| 151 | m, err := ReadStagedManifest(optDir, acct, ver, app) |
| 152 | if err != nil || m == nil { |
| 153 | return nil, nil |
| 154 | } |
| 155 | a, ok := m.Artifacts[adoptPlatform] |
| 156 | if !ok { |
| 157 | return nil, nil |
| 158 | } |
| 159 | id := StagedIdentity{Version: ver, StagedPath: VersionPath(optDir, acct, ver, app), Digest: strings.ToLower(a.SHA256)} |
| 160 | if VerifyStaged(id) != nil { |
| 161 | return nil, nil |
| 162 | } |
| 163 | return &id, m |
| 164 | } |
| 165 | |
| 166 | // ResolveLivePath is ResolveLiveLink: one read of host's live link, the file |
| 167 | // at the end of its chain (linkChain) and the link's version, with Digest |
| 168 | // empty. It is for a start that needs only to exec exactly the build it |
| 169 | // named (the activator's live exec gate, Task 9), where hashing a build of |
| 170 | // up to a hundred megabytes at every wake would buy nothing. |
| 171 | func ResolveLivePath(optDir, host string) (StagedIdentity, error) { |
| 172 | return ResolveLiveLink(optDir, host) |
| 173 | } |
| 174 | |
| 175 | // VerifyStaged checks the file at id.StagedPath against id.Digest, the digest |
| 176 | // retained when the build was exec'd (spec §4.2's recovery check). It never |
| 177 | // consults the canonical manifest: a later re-ship of the same name must not |
| 178 | // revoke local recovery. |
| 179 | func VerifyStaged(id StagedIdentity) error { |
| 180 | if id.StagedPath == "" || id.Digest == "" { |
| 181 | return fmt.Errorf("%w: no retained path and digest for %q", ErrStagedCopyChanged, id.Version) |
| 182 | } |
| 183 | sum, err := FileSHA256Cached(id.StagedPath) |
| 184 | if err != nil { |
| 185 | return fmt.Errorf("%w: %s: %v", ErrStagedCopyChanged, id.StagedPath, err) |
| 186 | } |
| 187 | if !strings.EqualFold(sum, id.Digest) { |
| 188 | return fmt.Errorf("%w: %s hashes to %s, retained %s", ErrStagedCopyChanged, id.StagedPath, sum, id.Digest) |
| 189 | } |
| 190 | return nil |
| 191 | } |
| 192 | |